diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c3286..4a5ab44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ The application version is maintained in `package.json` and displayed in the adm No changes recorded yet. +## 1.7.1 - 2026-09-09 + +### Fixed + +- Swedish å/ä/ö/Å/Ä/Ö rendered as garbled characters (or vanished entirely) in every PDF export - race results, start lists, heat sheets, team reports and the results archive - because the PDF's font never declared a character encoding and its text was written as UTF-8 instead of the single-byte encoding a PDF font actually expects. Both are now fixed at the shared PDF renderer, so every export is corrected at once. + ## 1.7.0 - 2026-09-09 ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 7d4a845..74e17d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,12 @@ Deferred 2026-09-09 at the user's request - the largest remaining item, on par w - [ ] Build compact mobile controls for start/stop, track call, flags and next heat. - [ ] Test concurrent control and accidental repeated commands. +## PDF export character encoding + +Fixed 2026-09-09, reported while exporting an old race from the new Results archive - correctly suspected as a general PDF export bug, not specific to that one feature. `buildSimplePdf` in `server.js` (the shared renderer behind every PDF export: race results, start lists, heat sheets, team reports, and the archive) declared its font with no `/Encoding` at all, so viewers fell back to the standard font's built-in encoding, which has no å/ä/ö glyphs - and separately, the content stream was written as UTF-8, so even a correctly-encoded font would have received the wrong bytes (a multi-byte UTF-8 sequence where a single WinAnsi byte was expected), the classic "åäö" mojibake pattern. Fixed by declaring `/Encoding /WinAnsiEncoding` and writing the content stream through a proper WinAnsi (~CP1252) byte mapper instead of UTF-8 - Swedish å/ä/ö/Å/Ä/Ö map directly to their Latin-1 byte values, with the handful of CP1252 "smart punctuation" code points (curly quotes, en/em dash, €, etc.) mapped explicitly; anything else falls back to `?` rather than corrupting the byte stream. + +Verified 2026-09-09: a new integration test (`tests/server_persistence.integration.test.js`) exports a PDF with a Swedish title and a table row containing å/ä/ö/Å/Ä/Ö, asserts the font declares WinAnsiEncoding, asserts the correct single-byte WinAnsi sequence is present in the raw PDF bytes, and explicitly asserts the old UTF-8 byte sequence is *not* present - confirmed this test genuinely fails against the pre-fix code (not a tautology) by re-running it with the fix reverted. + ## Documentation maintenance - [ ] Update the architecture's module/dependency inventory as modules change. diff --git a/package-lock.json b/package-lock.json index f0f9007..377a3a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "rc-racecore", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "rc-racecore", - "version": "1.7.0", + "version": "1.7.1", "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", diff --git a/package.json b/package.json index 35e304d..e7b5db6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rc-racecore", - "version": "1.7.0", + "version": "1.7.1", "private": true, "description": "RC RaceCore live timing and race control with AMMC ingest and SQLite persistence", "engines": { diff --git a/server.js b/server.js index c453c28..61fecc8 100644 --- a/server.js +++ b/server.js @@ -662,7 +662,7 @@ function buildSimplePdf(payload) { objects.push(createPdfTextObject(1, "<< /Type /Catalog /Pages 2 0 R >>")); objects.push(createPdfTextObject(2, `<< /Type /Pages /Count ${pages.length} /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] >>`)); - objects.push(createPdfTextObject(fontObjectId, "<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>")); + objects.push(createPdfTextObject(fontObjectId, "<< /Type /Font /Subtype /Type1 /BaseFont /Courier /Encoding /WinAnsiEncoding >>")); if (logo && imageObjectId) { objects.push( @@ -678,7 +678,7 @@ function buildSimplePdf(payload) { const resources = imageObjectId ? `<< /Font << /F1 ${fontObjectId} 0 R >> /XObject << /Im1 ${imageObjectId} 0 R >> >>` : `<< /Font << /F1 ${fontObjectId} 0 R >> >>`; - const contentStream = Buffer.from( + const contentStream = pdfWinAnsiBuffer( buildPdfContentStream( pages[index], marginLeft, @@ -692,8 +692,7 @@ function buildSimplePdf(payload) { height: logo.displayHeight, } : null - ), - "utf8" + ) ); objects.push( createPdfTextObject( @@ -943,6 +942,29 @@ function escapePdfText(text) { .replaceAll(")", "\\)"); } +// The content stream is declared with /Encoding /WinAnsiEncoding (see buildSimplePdf), so every +// character must become exactly one WinAnsi (~CP1252) byte or a PDF viewer renders garbage/boxes +// for anything outside plain ASCII - accented letters most of all (å, ä, ö, ...). WinAnsi matches +// Latin-1 one-for-one above 0x9F (where å/ä/ö/Å/Ä/Ö all live), so a plain code-point clamp covers +// Swedish text; only the narrow 0x80-0x9F "smart punctuation" block needs its own mapping. +const PDF_WIN_ANSI_OVERRIDES = { + 0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, + 0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a, 0x2039: 0x8b, 0x0152: 0x8c, 0x017d: 0x8e, + 0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, + 0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b, 0x0153: 0x9c, 0x017e: 0x9e, 0x0178: 0x9f, +}; + +function pdfWinAnsiBuffer(text) { + const bytes = []; + for (const char of String(text || "")) { + const code = char.codePointAt(0); + if (code < 0x80 || (code > 0x9f && code <= 0xff)) bytes.push(code); + else if (PDF_WIN_ANSI_OVERRIDES[code] !== undefined) bytes.push(PDF_WIN_ANSI_OVERRIDES[code]); + else bytes.push(0x3f); // "?" - outside what a standard PDF font/WinAnsiEncoding can represent + } + return Buffer.from(bytes); +} + function getLanUrls(port) { const interfaces = os.networkInterfaces(); const urls = []; diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index dec3c05..1f4c965 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -405,3 +405,51 @@ test("a stale full-state save from a second browser cannot revert a session's ju rmSync(dataDir, { recursive: true, force: true }); } }); + +test("PDF export renders Swedish åäö/ÅÄÖ correctly instead of mojibake", { timeout: 20000 }, async () => { + const dataDir = mkdtempSync(join(tmpdir(), "live-rc-test-pdf-")); + const port = await reservePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const child = spawn(process.execPath, [join(projectRoot, "server.js")], { + cwd: projectRoot, + env: { ...process.env, HOST: "127.0.0.1", PORT: String(port), RC_TIMING_DATA_DIR: dataDir, RC_TIMING_PID_FILE: "" }, + stdio: ["ignore", "pipe", "pipe"], + }); + + try { + await waitForServer(child); + + const response = await fetch(`${baseUrl}/api/export/pdf`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: "Test åäö ÅÄÖ", + filename: "swedish_test.pdf", + sections: [{ title: "Förare", headers: ["Namn", "Klass"], rows: [["Örjan Åström", "2WD"]] }], + }), + }); + assert.equal(response.status, 200); + assert.equal(response.headers.get("content-type"), "application/pdf"); + const pdfBuffer = Buffer.from(await response.arrayBuffer()); + + // The font must declare WinAnsiEncoding - without it a PDF viewer falls back to a standard + // font's built-in encoding, which has no å/ä/ö glyphs at all regardless of the bytes sent. + assert.ok(pdfBuffer.toString("latin1").includes("/Encoding /WinAnsiEncoding"), "the font must declare WinAnsiEncoding"); + + // "åäö ÅÄÖ" must appear as single WinAnsi/Latin-1 bytes (0xE5 0xE4 0xF6 0x20 0xC5 0xC4 0xD6), + // not as UTF-8 multi-byte sequences (which a WinAnsi-encoded viewer would render as garbage + // like "åäö"). + const winAnsiBytes = Buffer.from([0xe5, 0xe4, 0xf6, 0x20, 0xc5, 0xc4, 0xd6]); + assert.ok(pdfBuffer.includes(winAnsiBytes), "åäö ÅÄÖ must be present as single WinAnsi bytes in the content stream"); + + const utf8Bytes = Buffer.from("åäö ÅÄÖ", "utf8"); + assert.ok(!pdfBuffer.includes(utf8Bytes), "the raw UTF-8 byte sequence must not appear - that is the mojibake bug"); + + // A name with å from a table row must also be encoded correctly, not just the title. + const orjanWinAnsi = Buffer.from([0xd6, 0x72, 0x6a, 0x61, 0x6e]); // "Örjan" - Ö = 0xD6 + assert.ok(pdfBuffer.includes(orjanWinAnsi), "a Swedish name inside a table row must also use WinAnsi bytes"); + } finally { + await stopServer(child); + rmSync(dataDir, { recursive: true, force: true }); + } +});