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

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