The template harness, tests and thumbnails now use a neutral vendor, bank account and a generated sample wordmark (scripts/logo/make-sample-logo.mjs). Layout goldens and the 14 picker thumbnails are regenerated. Fixture strings keep their original lengths where layout tests depend on them.
137 lines
5.3 KiB
TypeScript
137 lines
5.3 KiB
TypeScript
import { createElement as h } from "react";
|
|
import { Document, Page, Text } from "@react-pdf/renderer";
|
|
import { describe, expect, it } from "vitest";
|
|
import { stack } from "../fonts/register";
|
|
import { renderPdfText } from "../testing/pdfText";
|
|
import { setupPdfTest } from "../testing/setup";
|
|
import { chunkIdentifier } from "../model/text";
|
|
|
|
const m = setupPdfTest();
|
|
|
|
const SAMPLES: string[] = [
|
|
"Voice-over session 12 - narration for module 3",
|
|
"Total ₹1,23,456.78",
|
|
"AVATAR WAVE To Ty Yo",
|
|
"anon.longsurname.voiceover@examplestudios.co.in",
|
|
"29ABCDE1234F1Z5",
|
|
"office fi fl ffi final",
|
|
"BILLED TO",
|
|
"Amount: ₹5,00,000.00 (Rupees Five Lakh Only) नमस्ते",
|
|
"नमस्ते दुनिया, यह एक परीक्षण है",
|
|
"ಕನ್ನಡ ಭಾಷೆಯ ಪರೀಕ್ಷೆ ಇನ್ವಾಯ್ಸ್",
|
|
"Invoice क्रमांक INV-0042 ಇನ್ವಾಯ್ಸ್",
|
|
"1234567890 0.00 111 000",
|
|
];
|
|
|
|
interface Case {
|
|
text: string;
|
|
size: number;
|
|
weight: number;
|
|
italic: boolean;
|
|
spacing: number;
|
|
caps: boolean;
|
|
}
|
|
|
|
const isIndic = (t: string) => /[\u0900-\u0DFF]/.test(t);
|
|
|
|
const cases: Case[] = [];
|
|
for (const text of SAMPLES) {
|
|
for (const size of [7.5, 9.5, 18]) cases.push({ text, size, weight: 400, italic: false, spacing: 0, caps: false });
|
|
}
|
|
cases.push(
|
|
{ text: "Bold semibold text ₹99", size: 9.5, weight: 600, italic: false, spacing: 0, caps: false },
|
|
{ text: "Italic amount in words only", size: 9.5, weight: 400, italic: true, spacing: 0, caps: false },
|
|
{ text: "Light weight 300", size: 13, weight: 300, italic: false, spacing: 0, caps: false },
|
|
{ text: "Heavy 800 falls back to bold", size: 13, weight: 800, italic: false, spacing: 0, caps: false },
|
|
{ text: "tax invoice", size: 18, weight: 600, italic: false, spacing: 1.5, caps: true },
|
|
{ text: "description", size: 7.5, weight: 400, italic: false, spacing: 0.8, caps: true },
|
|
{ text: "billed to नमस्ते", size: 7.5, weight: 400, italic: false, spacing: 1, caps: true },
|
|
);
|
|
|
|
describe("measurer parity with react-pdf", () => {
|
|
it("matches the width pdf.js reads from rendered text", async () => {
|
|
const doc = h(
|
|
Document,
|
|
null,
|
|
...cases.map((c, i) =>
|
|
h(
|
|
Page,
|
|
{ key: i, size: { width: 900, height: 80 }, style: { padding: 20 } },
|
|
h(
|
|
Text,
|
|
{
|
|
style: {
|
|
fontFamily: stack("IBM Plex Sans"),
|
|
fontSize: c.size,
|
|
fontWeight: c.weight,
|
|
fontStyle: c.italic ? "italic" : "normal",
|
|
letterSpacing: c.spacing,
|
|
...(c.caps ? { textTransform: "uppercase" } : {}),
|
|
} as never,
|
|
},
|
|
// pdf.js sizes shaped Indic items from raw glyph widths (it reports 12.9 pt for a run that
|
|
// advances 15.8 pt), so those cases end in a Latin "I" and the width is read from where
|
|
// that glyph starts instead.
|
|
isIndic(c.text) ? `${c.text}I` : c.text,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
const pdf = await renderPdfText(doc);
|
|
expect(pdf.pages.length).toBe(cases.length);
|
|
|
|
let maxDev = 0;
|
|
let worst = "";
|
|
pdf.pages.forEach((page, i) => {
|
|
const c = cases[i];
|
|
const x0 = Math.min(...page.items.map((t) => t.x0));
|
|
// pdf.js leaves the trailing letter spacing of the last glyph out of an item's width.
|
|
let x1 = Math.max(...page.items.map((t) => t.x1)) + c.spacing;
|
|
if (isIndic(c.text)) {
|
|
const last = page.items[page.items.length - 1];
|
|
expect(last.str, `${c.text}: sentinel item`).toBe("I");
|
|
x1 = last.x0;
|
|
}
|
|
const measured = m.measureWidth({
|
|
text: c.caps ? c.text.toUpperCase() : c.text,
|
|
face: "IBM Plex Sans",
|
|
weight: c.weight,
|
|
italic: c.italic,
|
|
size: c.size,
|
|
letterSpacing: c.spacing,
|
|
});
|
|
const dev = Math.abs(x1 - x0 - measured);
|
|
if (dev > maxDev) {
|
|
maxDev = dev;
|
|
worst = `${c.text} @${c.size} (pdf ${(x1 - x0).toFixed(3)} vs ${measured.toFixed(3)})`;
|
|
}
|
|
expect(dev, `${c.text} @${c.size} w${c.weight}: pdf ${(x1 - x0).toFixed(3)} measured ${measured.toFixed(3)}`).toBeLessThan(0.05);
|
|
});
|
|
console.log(`measurer parity: ${cases.length} cases, max deviation ${maxDev.toFixed(4)} pt (${worst})`);
|
|
});
|
|
});
|
|
|
|
describe("measurer helpers", () => {
|
|
it("reports uncovered code points", () => {
|
|
expect(m.missing("Hello ₹ नमस्ते ಕನ್ನಡ")).toEqual([]);
|
|
expect(m.missing("A 中 B")).toEqual([0x4e2d]);
|
|
});
|
|
|
|
it("wraps greedily at spaces and hyphens", () => {
|
|
const input = { face: "IBM Plex Sans", weight: 400, size: 9.5 };
|
|
const text = "one two three four five six seven eight nine ten";
|
|
const full = m.measureWidth({ ...input, text });
|
|
const lines = m.wrapLines(text, full * 0.6, input);
|
|
expect(lines.length).toBe(2);
|
|
expect(lines.join(" ")).toBe(text);
|
|
for (const l of lines) expect(m.measureWidth({ ...input, text: l })).toBeLessThanOrEqual(full * 0.6);
|
|
expect(m.wrapLines("abcdefghijkl", 5, input)).toEqual(["abcdefghijkl"]);
|
|
expect(m.wrapLines("", 100, input)).toEqual([]);
|
|
});
|
|
|
|
it("round-trips chunkIdentifier output", () => {
|
|
const id = "anon.longsurname.voiceover@examplestudios.co.in";
|
|
expect(chunkIdentifier(id, 12).join("")).toBe(id);
|
|
});
|
|
});
|