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