Fix Swedish åäö rendering as mojibake in PDF exports

buildSimplePdf's font declared no /Encoding at all, so PDF viewers fell
back to a standard font's built-in encoding, which has no å/ä/ö glyphs
- and separately, the content stream was written as UTF-8 instead of a
single-byte encoding, so even a correctly-encoded font would receive
the wrong bytes (a multi-byte UTF-8 sequence where PDF expects exactly
one byte per character): 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.
This is the single shared renderer behind every PDF export (race
results, start lists, heat sheets, team reports, the results archive),
so the fix applies everywhere at once, matching what was reported.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-09 21:31:01 +02:00
parent f1959029c7
commit 2bbbd6e995
6 changed files with 89 additions and 7 deletions

View File

@@ -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

View File

@@ -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.

4
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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 = [];

View File

@@ -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 });
}
});