diff --git a/server/services/auftraege/pdf.js b/server/services/auftraege/pdf.js index d1769c6..0df6385 100644 --- a/server/services/auftraege/pdf.js +++ b/server/services/auftraege/pdf.js @@ -36,3 +36,43 @@ export function hatGlyph(codePoint) { const { regularFont, semiboldFont } = ladeSchriften() return regularFont.hasGlyphForCodePoint(codePoint) && semiboldFont.hasGlyphForCodePoint(codePoint) } + +const ERSATZ = new Map([ + [0x2010, '-'], [0x2011, '-'], [0x2012, '-'], [0x2013, '-'], [0x2014, '-'], [0x2015, '-'], [0x2212, '-'], + [0x2018, "'"], [0x2019, "'"], [0x201a, "'"], [0x2032, "'"], + [0x201c, '"'], [0x201d, '"'], [0x201e, '"'], [0x201f, '"'], [0x2033, '"'], + [0x2026, '...'], [0x20ac, 'EUR'], [0x2022, '-'], [0x00b7, '-'], [0x00d7, 'x'], [0x2192, '->'], +]) +// Steuerzeichen (außer \n), weiche Trennung, Zero-Width-Zeichen, Variantenselektoren, BOM +const ENTFERNEN = /[\u{0}-\u{8}\u{B}-\u{1F}\u{7F}-\u{9F}\u{AD}\u{200B}-\u{200D}\u{2060}\u{FE00}-\u{FE0F}\u{FEFF}]/gu + +/** Ersetzt Zeichen ohne Glyph, damit nie ein leeres Kästchen im PDF steht. */ +export function bereinigeText(text, hatGlyphFn = hatGlyph) { + const roh = String(text ?? '') + .normalize('NFC') + .replace(/\r\n?|[\u{2028}\u{2029}]/gu, '\n') + .replace(/\t/g, ' ') + .replace(ENTFERNEN, '') + let aus = '' + for (const zeichen of roh) { + const cp = zeichen.codePointAt(0) + if (zeichen === '\n' || hatGlyphFn(cp)) aus += zeichen + else if (ERSATZ.has(cp)) aus += ERSATZ.get(cp) + else if (/\p{Zs}/u.test(zeichen)) aus += ' ' + else if (/\p{M}/u.test(zeichen)) continue + else { + const basis = zeichen.normalize('NFD').codePointAt(0) + aus += basis !== cp && hatGlyphFn(basis) ? String.fromCodePoint(basis) : '?' + } + } + return aus +} + +/** bereinigeText für jede Zeichenkette in beliebig verschachtelten Objekten und Listen. */ +export function bereinigeTexte(wert, hatGlyphFn = hatGlyph) { + if (typeof wert === 'string') return bereinigeText(wert, hatGlyphFn) + if (Array.isArray(wert)) return wert.map((e) => bereinigeTexte(e, hatGlyphFn)) + if (wert && typeof wert === 'object') return Object.fromEntries(Object.entries(wert).map(([k, v]) => [k, bereinigeTexte(v, hatGlyphFn)])) + return wert +} + diff --git a/test/auftraegePdfSchrift.test.js b/test/auftraegePdfSchrift.test.js index 92cb589..b2c7201 100644 --- a/test/auftraegePdfSchrift.test.js +++ b/test/auftraegePdfSchrift.test.js @@ -39,3 +39,39 @@ test('hatGlyph: deutsche Buchstaben, €, Minuszeichen, geschütztes Leerzeichen for (const zeichen of noetig) assert.ok(hatGlyph(zeichen.codePointAt(0)), `Glyph fehlt: U+${zeichen.codePointAt(0).toString(16)}`) for (const code of [0x202f, 0x2009, 0x200b, 0x416, 0x1f600, 0x3042]) assert.equal(hatGlyph(code), false, `U+${code.toString(16)} sollte fehlen`) }) +test('bereinigeText: vorhandene Zeichen bleiben unverändert, getrennte Akzente werden zusammengesetzt', () => { + const text = `Müller & Söhne – „Größe“ 1.234,56${NBSP}€ ${cp(0x2212)} 20,00${NBSP}€ ÄÖÜ äöü ß … Łukasz Yıldız Çelik` + assert.equal(bereinigeText(text), text) + assert.equal(bereinigeText('a' + cp(0x301)), 'á'.normalize('NFC')) + assert.equal(bereinigeText(null), '') + assert.equal(bereinigeText(undefined), '') + assert.equal(bereinigeText(42), '42') +}) + +test('bereinigeText: Steuerzeichen, Zero-Width, weiche Trennung und BOM entfallen; Zeilenenden werden Zeilenumbrüche, Tab wird Leerzeichen', () => { + const roh = `a${cp(0x200b)}b${cp(0xad)}c${cp(0xfeff)}d${cp(0)}e\tf\r\ng\rh${cp(0x2028)}i${cp(0x200d, 0xfe0f)}j` + assert.equal(bereinigeText(roh), 'abcde f\ng\nh\nij') +}) + +test('bereinigeText mit Figtree: schmale Leerzeichen werden Leerzeichen, kyrillische und japanische Zeichen sowie Emoji je ein Fragezeichen', () => { + assert.equal(bereinigeText(`a${cp(0x202f)}b${cp(0x2009)}c`), 'a b c') + assert.equal(bereinigeText(`${cp(0x416)}${cp(0x3042)}${cp(0x1f600)}`), '???') +}) + +test('bereinigeText: Ersatzregeln bei fehlendem Glyph (Schrift ohne Zeichen über ASCII)', () => { + const nurAscii = (code) => code < 0x80 + const fall = (eingabe) => bereinigeText(eingabe, nurAscii) + assert.equal(fall('9,00' + NBSP + '€'), '9,00 EUR') + assert.equal(fall(`${cp(0x201e)}x${cp(0x201c)} ${cp(0x201a)}y${cp(0x2019)}`), `"x" 'y'`) + assert.equal(fall(`1 ${cp(0x2013)} 2 ${cp(0x2212)} 3 ${cp(0x2014)} 4`), '1 - 2 - 3 - 4') + assert.equal(fall(`wait${cp(0x2026)} 3${cp(0xd7)}4 a${cp(0x2192)}b`), 'wait... 3x4 a->b') + assert.equal(fall('Müller Çelik Ångström'), 'Muller Celik Angstrom') + assert.equal(fall('Łukasz Ğ'), '?ukasz G') + assert.equal(fall('e' + cp(0x301) + cp(0x301)), 'e') + assert.equal(fall(cp(0x301)), '') +}) + +test('bereinigeTexte: bearbeitet jede Zeichenkette in verschachtelten Objekten und Listen, andere Werte bleiben', () => { + const roh = { a: ['x' + cp(0x200b) + 'y', { b: 'ż' + cp(0x1f600) }], n: 5, f: null, t: true } + assert.deepEqual(bereinigeTexte(roh), { a: ['xy', { b: 'ż?' }], n: 5, f: null, t: true }) +})