From d3ad53bcf08bc8dee556705c9df6adbae36503ae Mon Sep 17 00:00:00 2001 From: flfeders Date: Fri, 24 Jul 2026 10:25:10 +0200 Subject: [PATCH] Improve unsupported PDF character errors --- backend/src/routes/functions.ts | 11 +++- backend/src/utils/pdfCharacterError.ts | 79 +++++++++++++++++++++++++ backend/tests/pdfCharacterError.test.ts | 27 +++++++++ frontend/plugins/api.ts | 5 ++ 4 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 backend/src/utils/pdfCharacterError.ts create mode 100644 backend/tests/pdfCharacterError.test.ts diff --git a/backend/src/routes/functions.ts b/backend/src/routes/functions.ts index 1511d52..23cf6f4 100644 --- a/backend/src/routes/functions.ts +++ b/backend/src/routes/functions.ts @@ -25,6 +25,7 @@ import { s3 } from "../utils/s3"; import { secrets } from "../utils/secrets"; import { storeExtractedTextForFile } from "../utils/documentText"; import { generateLiquidityForecast } from "../utils/liquidityForecast"; +import { getUnsupportedPdfCharacterMessage } from "../utils/pdfCharacterError"; dayjs.extend(customParseFormat) dayjs.extend(isoWeek) dayjs.extend(isBetween) @@ -134,7 +135,15 @@ export default async function functionRoutes(server: FastifyInstance) { return pdf // Fastify wandelt automatisch in JSON } catch (err) { console.log(err) - reply.code(500).send({ error: "Failed to create PDF" }) + const unsupportedCharacterMessage = getUnsupportedPdfCharacterMessage(err, body.data) + if (unsupportedCharacterMessage) { + return reply.code(422).send({ + error: unsupportedCharacterMessage, + code: "UNSUPPORTED_PDF_CHARACTER", + }) + } + + return reply.code(500).send({ error: "Failed to create PDF" }) } }) diff --git a/backend/src/utils/pdfCharacterError.ts b/backend/src/utils/pdfCharacterError.ts new file mode 100644 index 0000000..70d3f37 --- /dev/null +++ b/backend/src/utils/pdfCharacterError.ts @@ -0,0 +1,79 @@ +const PDF_CHARACTER_ERROR_PATTERN = /WinAnsi cannot encode "[\s\S]+?" \(0x([0-9a-f]+)\)/i + +const DOCUMENT_FIELD_LABELS: Record = { + title: "Dokumenttitel", + description: "Dokumentbeschreibung", + startText: "Einleitungstext", + endText: "Schlusstext", + text: "Bezeichnung", + descriptionText: "Beschreibung", + unit: "Einheit", +} + +type CharacterLocation = { + label: string + characterPosition: number +} + +const labelForPath = (path: Array, root: any): string => { + if (path[0] === "rows" && typeof path[1] === "number") { + const row = root?.rows?.[path[1]] + const position = row?.pos ? `Position ${row.pos}` : `Dokumentzeile ${path[1] + 1}` + const field = DOCUMENT_FIELD_LABELS[String(path[path.length - 1])] || String(path[path.length - 1]) + return `${position}, ${field}` + } + + const field = String(path[path.length - 1]) + return DOCUMENT_FIELD_LABELS[field] || field +} + +const findCharacterLocations = ( + value: unknown, + unsupportedCharacter: string, + root: any, + path: Array = [], +): CharacterLocation[] => { + if (typeof value === "string") { + const characters = Array.from(value) + return characters.flatMap((character, index) => + character === unsupportedCharacter + ? [{ label: labelForPath(path, root), characterPosition: index + 1 }] + : [] + ) + } + + if (Array.isArray(value)) { + return value.flatMap((entry, index) => + findCharacterLocations(entry, unsupportedCharacter, root, [...path, index]) + ) + } + + if (value && typeof value === "object") { + return Object.entries(value).flatMap(([key, entry]) => + findCharacterLocations(entry, unsupportedCharacter, root, [...path, key]) + ) + } + + return [] +} + +export const getUnsupportedPdfCharacterMessage = (error: unknown, documentData: any): string | null => { + const errorMessage = error instanceof Error ? error.message : String(error) + const match = errorMessage.match(PDF_CHARACTER_ERROR_PATTERN) + if (!match) return null + + const numericCodePoint = Number.parseInt(match[1], 16) + if (!Number.isSafeInteger(numericCodePoint) || numericCodePoint > 0x10FFFF) return null + + const unsupportedCharacter = String.fromCodePoint(numericCodePoint) + const [location] = findCharacterLocations(documentData, unsupportedCharacter, documentData) + const codePoint = numericCodePoint + .toString(16) + .toUpperCase() + .padStart(4, "0") + const locationText = location + ? ` Fundstelle: ${location.label}, Zeichen ${location.characterPosition}.` + : "" + + return `Das Zeichen „${unsupportedCharacter}“ (Unicode U+${codePoint}) wird in PDF-Dokumenten nicht unterstützt.${locationText} Bitte ersetze oder entferne das Zeichen.` +} diff --git a/backend/tests/pdfCharacterError.test.ts b/backend/tests/pdfCharacterError.test.ts new file mode 100644 index 0000000..3a1a440 --- /dev/null +++ b/backend/tests/pdfCharacterError.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { getUnsupportedPdfCharacterMessage } from "../src/utils/pdfCharacterError" + +test("reports unsupported characters with the document position and character offset", () => { + // pdf-lib displays supplementary Unicode characters as a private-use glyph, + // while the hexadecimal value still contains the original code point. + const error = new Error('WinAnsi cannot encode "" (0x1f600)') + const documentData = { + rows: [ + { pos: "1", text: "Montage" }, + { pos: "2.3", descriptionText: "Gerät 😀 montieren" }, + ], + } + + assert.equal( + getUnsupportedPdfCharacterMessage(error, documentData), + "Das Zeichen „😀“ (Unicode U+1F600) wird in PDF-Dokumenten nicht unterstützt. Fundstelle: Position 2.3, Beschreibung, Zeichen 7. Bitte ersetze oder entferne das Zeichen." + ) +}) + +test("returns null for unrelated PDF errors", () => { + assert.equal( + getUnsupportedPdfCharacterMessage(new Error("Background PDF is invalid"), {}), + null + ) +}) diff --git a/frontend/plugins/api.ts b/frontend/plugins/api.ts index 82efaaa..a206319 100644 --- a/frontend/plugins/api.ts +++ b/frontend/plugins/api.ts @@ -45,6 +45,11 @@ export default defineNuxtPlugin(() => { let title = "Fehler" let description = "Ein unerwarteter Fehler ist aufgetreten." + if (status === 422 && response._data?.code === "UNSUPPORTED_PDF_CHARACTER") { + title = "Nicht unterstütztes Zeichen" + description = response._data.error + } + switch (status) { case 400: title = "Anfrage fehlerhaft"