/** * Pins the react-pdf 4.9.0 behaviour the engine is built on (layout 5.2.0, textkit 7.0.1, fontkit 2.0.4, * pdfkit 0.20.1). Each title states the verified fact; when an upgrade changes one of them the test * fails and the design rule behind it must be revisited. */ import { createElement as h, type ReactElement, type ReactNode } from "react"; import { readFileSync } from "node:fs"; import path from "node:path"; import { create as createFont } from "fontkit"; import { Font, renderToBuffer, Text, View } from "@react-pdf/renderer"; import { describe, expect, it } from "vitest"; import { auditLayout } from "./engine/audit"; import { TABLE_HEAD_ID, TABLE_ROW_PREFIX } from "./engine/ids"; import type { LayoutPage } from "./engine/layoutTree"; import { getMeasurer } from "./engine/measure"; import { AvailableWidthProvider } from "./blocks/AvailableWidth"; import { BottomSpacer } from "./blocks/BottomSpacer"; import { planItemsTable, TableRows } from "./blocks/ItemsTable"; import { TYPE_ROLES } from "./fonts/roles"; import { RenderTxt } from "./blocks/RenderTxt"; import { TableGuard, guardPresence } from "./blocks/TableGuard"; import { renderCore } from "./render/core"; import { loadCanvas, inkPixels } from "./testing/rasterize"; import { byId, doc, FAMILY, find, frameFor, PAD, PAGE_W, rawLayout, txt } from "./testing/docs"; import { FONTS_DIR, setupPdfTest } from "./testing/setup"; import { stack } from "./fonts/register"; setupPdfTest(); const render = (el: ReactElement) => renderCore(el); const LONG = "Aurora Industrial Fasteners and Precision Components Private Limited, Plot 14, Phase 2, Peenya Industrial Area, Bengaluru, Karnataka"; describe("react-pdf 4.9.0 quirks the engine relies on", () => { it("1. flexGrow without flexBasis 0 leaves stale line breaks: the text is wider than its final box; with flexBasis 0 it is not", async () => { const row = (fill: Record) => doc( h( View, { style: { flexDirection: "row", width: 360 } }, h(Text, { id: "fill", style: txt({ ...fill }) }, LONG), h(View, { style: { width: 180, minWidth: 180, maxWidth: 180 } }, h(Text, { style: txt() }, "side")), ), ); const widest = async (fill: Record) => { const r = await render(row(fill)); const t = byId(r.layout[0], "fill")[0]; return { box: t.box.w, ink: Math.max(...t.lines!.map((l) => l.w)) }; }; const stale = await widest({ flexGrow: 1 }); expect(stale.ink).toBeGreaterThan(stale.box + 0.5); const fixed = await widest({ flexGrow: 1, flexBasis: 0 }); expect(fixed.ink).toBeLessThanOrEqual(fixed.box + 0.5); }); it("2. a static Text with lineHeight '13.8pt' ends with style.lineHeight 13800 (after the relayout a dynamic page forces) while its line boxes are 13.8 tall", async () => { const dynamic = h(RenderTxt, { key: "d", role: { face: "IBM Plex Sans", weight: 400, size: 8 }, fixed: true, render: ({ pageNumber }) => `p${pageNumber}` }); const statik = () => h(Text, { key: "t", id: "t", style: txt({ lineHeight: "13.8pt" }) }, "Hello"); const raw = await rawLayout(doc([statik(), dynamic])); const text = raw.children[0].children[0]; expect(text.type).toBe("TEXT"); expect(text.style.lineHeight).toBe(13800); expect(text.lines[0].box.height).toBeCloseTo(13.8, 5); const r = await render(doc([statik(), dynamic])); expect(byId(r.layout[0], "t")[0].lines![0].h).toBeCloseTo(13.8, 5); // Without a dynamic node the page is laid out once and the unit is still wrong (13.8pt -> 138). const single = await rawLayout(doc(statik())); expect(single.children[0].children[0].style.lineHeight).toBe(138); }); describe("3. fixed, wrap and minPresenceAhead are checked by key presence", () => { // Four 60 pt rows fill 20..260 of the 20..280 wrap area; the fifth straddles the page foot. const rows = (fifth: Record) => doc( [0, 1, 2, 3, 4].map((i) => h(View, { key: i, id: `r${i}`, style: { height: 60 }, ...(i === 4 ? fifth : { wrap: false }) }, h(Text, { style: txt() }, `Row ${i}`)), ), ); const place = async (fifth: Record) => { const r = await render(rows(fifth)); return r.layout.map((p) => byId(p, "r4").map((n) => ({ y: n.box.y, h: n.box.h }))); }; it("wrap={false} without `fixed` moves the whole row to the next page", async () => { const where = await place({ wrap: false }); expect(where[0]).toEqual([]); expect(where[1]).toHaveLength(1); expect(where[1][0].h).toBe(60); }); it("wrap={false} with fixed={false} splits the row instead (fixed is key presence)", async () => { const where = await place({ wrap: false, fixed: false }); expect(where[0].length).toBeGreaterThan(0); expect(where[0][0].h).toBeLessThan(60); }); it("wrap={undefined} cannot wrap, as if wrap={false}; omitting `wrap` splits a straddling row", async () => { const omitted = await place({}); expect(omitted[0][0].h).toBeLessThan(60); const undef = await place({ wrap: undefined }); expect(undef[0]).toEqual([]); }); it("minPresenceAhead={undefined} never triggers; a number moves the sentinel (and what follows) to the next page", async () => { const sentinel = (extra: Record) => doc([ h(View, { key: "fill", id: "fill", style: { height: 200 } }), h(View, { key: "s", id: "s", style: { height: 0 }, ...extra }), h(View, { key: "t", id: "t", style: { height: 100 }, wrap: false }, h(Text, { style: txt() }, "tail")), ]); const pageOf = async (extra: Record) => { const r = await render(sentinel(extra)); return r.layout.findIndex((p) => byId(p, "s").length > 0) + 1; }; expect(await pageOf({ minPresenceAhead: 100 })).toBe(2); expect(await pageOf({ minPresenceAhead: undefined })).toBe(1); expect(await pageOf({})).toBe(1); }); }); it("4. a family registered upright-only throws 'Could not resolve font ... italic' in a fallback stack; the aliased registration does not", async () => { const file = path.join(FONTS_DIR, "ibm-plex-sans/IBMPlexSans-Regular.ttf"); Font.register({ family: "Quirk Upright Only", fonts: [{ src: file, fontWeight: 400, fontStyle: "normal" }] }); Font.register({ family: "Quirk Aliased", fonts: [ { src: file, fontWeight: 400, fontStyle: "normal" }, { src: file, fontWeight: 400, fontStyle: "italic" }, ], }); const italic = (family: string[]) => doc(h(Text, { style: { fontFamily: family, fontStyle: "italic", fontSize: 10 } }, "Italic text")); await expect(render(italic(["IBM Plex Sans", "Quirk Upright Only"]))).rejects.toThrow(/Could not resolve font.*italic/i); await expect(render(italic(["IBM Plex Sans", "Quirk Aliased"]))).resolves.toBeTruthy(); // The bundled stack (Noto faces have no real italic) is aliased by register.ts. await expect(render(italic(FAMILY))).resolves.toBeTruthy(); }); it("5. a WOFF2 font embeds glyphs with no outlines: the text area stays white, a TTF control has ink", async () => { const canvasLib = await loadCanvas(); if (!canvasLib) { console.warn("quirk 5 skipped: @napi-rs/canvas (optional dependency) is not installed"); return; } Font.register({ family: "Quirk Woff2", src: path.join(FONTS_DIR, "IBMPlexSans-Regular.woff2") }); Font.register({ family: "Quirk Ttf", src: path.join(FONTS_DIR, "ibm-plex-sans/IBMPlexSans-Regular.ttf") }); const sample = (family: string) => renderToBuffer( doc(h(Text, { style: { fontFamily: family, fontSize: 40, color: "#000000" } }, "HWMN"), { width: 300, height: 100 }) as never, ); const area = { x0: 10, y0: 10, x1: 290, y1: 90 }; const woff = await inkPixels(canvasLib, new Uint8Array(await sample("Quirk Woff2")), area); const ttf = await inkPixels(canvasLib, new Uint8Array(await sample("Quirk Ttf")), area); expect(ttf).toBeGreaterThan(500); expect(woff).toBe(0); }); it("6. a newline or tab inside a Text pulls an unembedded Helvetica into the PDF", async () => { const baseFonts = async (s: string) => (await renderToBuffer(doc(h(Text, { style: txt() }, s)) as never)).toString("latin1").match(/\/BaseFont \/[A-Za-z+]*Helvetica[A-Za-z-]*/g) ?? []; expect(await baseFonts("plain text")).toEqual([]); expect((await baseFonts("two\nlines")).length).toBeGreaterThan(0); expect((await baseFonts("a\tb")).length).toBeGreaterThan(0); }); it("7. a pinned height on a text container truncates with an ellipsis (lines lost); minHeight does not", async () => { const name = "Aurora Industrial Fasteners and Precision Components Private Limited of Peenya"; const box = (sizing: Record) => doc(h(View, { style: { width: 100, ...sizing } }, h(Text, { id: "name", style: txt() }, name))); const pinned = (await render(box({ height: 33 }))).layout[0]; const t = byId(pinned, "name")[0]; expect(t.lines!.length).toBe(2); expect(t.lines!.map((l) => l.text).join(" ").endsWith("…")).toBe(true); const frame = frameFor(); expect(auditLayout({ pages: [pinned] }, frame).map((i) => i.code)).toContain("text-truncated"); const free = (await render(box({ minHeight: 33 }))).layout[0]; const f = byId(free, "name")[0]; expect(f.lines!.length).toBeGreaterThan(3); // Line strings keep their trailing space, so they concatenate back to the source. expect(f.lines!.map((l) => l.text).join("")).toBe(name); expect(auditLayout({ pages: [free] }, frame).map((i) => i.code)).not.toContain("text-truncated"); }); describe("8. table header and TableGuard", () => { const WIDTH = 360; const columns = [{ fill: true as const }, { fixed: 60 }]; const roles = { head: TYPE_ROLES.tableHead, body: TYPE_ROLES.tableBody }; const body = Array.from({ length: 80 }, (_, i) => [`Item ${i + 1}`, `${i + 1}`]); const plan = planItemsTable({ columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, measurer: getMeasurer(), roles, padY: 4, border: 0.5 }); const frame = frameFor(); const table = () => h(TableRows, { columns, gap: 8, width: WIDTH, header: ["Item", "Qty"], rows: body, roles, padY: 4, border: 0.5 }); const withFiller = (fillerH: number, guarded: boolean) => doc( h( AvailableWidthProvider, { width: WIDTH }, h(View, { id: "filler", style: { height: fillerH } }), guarded ? h(TableGuard, { minPresenceAhead: guardPresence(plan.headH, plan.firstRowH, TYPE_ROLES.tableBody.leading) }) : null, table(), ), ); const pageStats = (l: LayoutPage) => ({ head: find(l, (n) => n.id === TABLE_HEAD_ID).length, rows: find(l, (n) => !!n.id?.startsWith(TABLE_ROW_PREFIX)).length, }); it("a `fixed` first child repeats the header on every page of an 80-row table", async () => { const r = await render(withFiller(0, true)); expect(r.pages).toBeGreaterThan(2); r.layout.forEach((p) => expect(pageStats(p).head).toBe(1)); expect(r.layout.reduce((n, p) => n + pageStats(p).rows, 0)).toBe(80); }); it("without a guard, a header alone can sit at the page foot (header-only-page); TableGuard prevents it", async () => { // Leave room for the header and a few pt: less than header + first row. const filler = frame.contentHeight - plan.headH - 4; const bad = await render(withFiller(filler, false)); expect(pageStats(bad.layout[0])).toEqual({ head: 1, rows: 0 }); expect(auditLayout({ pages: bad.layout }, frame).map((i) => i.code)).toContain("header-only-page"); const good = await render(withFiller(filler, true)); // The guard moves the whole table: page 1 keeps only the filler. expect(pageStats(good.layout[0])).toEqual({ head: 0, rows: 0 }); good.layout.slice(1).forEach((p) => expect(pageStats(p).rows).toBeGreaterThan(0)); expect(auditLayout({ pages: good.layout }, frame).map((i) => i.code)).not.toContain("header-only-page"); }); }); describe("9. BottomSpacer", () => { // 60 rows of 16 pt in a 490 pt wrap area: 30 per page, so pages 1 and 2 are full (10 pt spare, less // than the closing block) and the closing block must start page 3. const CLOSING_H = 80; const build = (spacer: ReactNode) => doc( [ ...Array.from({ length: 60 }, (_, i) => h(View, { key: i, style: { height: 16 } }, h(Text, { style: txt({ fontSize: 8, lineHeight: "10pt" }) }, `Row ${i + 1}`))), spacer, h(View, { key: "closing", id: "closing", style: { height: CLOSING_H }, wrap: false }, h(Text, { style: txt() }, "Amount in words")), ], { height: 530 }, ); const closing = async (spacer: ReactNode) => { const r = await render(build(spacer)); const page = r.layout.findIndex((p) => byId(p, "closing").length > 0); return { pages: r.pages, page: page + 1, node: byId(r.layout[page], "closing")[0] }; }; it("with minPresenceAhead = closing height the closing block sits at the foot of page 3; without it, at the top", async () => { const wrapBottom = 530 - PAD; const withGuard = await closing(h(BottomSpacer, { key: "sp", closingH: CLOSING_H })); expect(withGuard.page).toBe(3); expect(withGuard.pages).toBe(3); expect(withGuard.node.box.y + withGuard.node.box.h).toBeCloseTo(wrapBottom, 0); const bare = await closing(h(View, { key: "sp", style: { flexGrow: 1, flexBasis: 0 } })); expect(bare.page).toBe(3); expect(bare.node.box.y).toBeCloseTo(PAD, 0); }); }); it("10. fixed chrome placed AFTER the flow repeats on every page (legacy paginator)", async () => { const rows = Array.from({ length: 40 }, (_, i) => h(View, { key: i, style: { height: 20 } }, h(Text, { style: txt() }, `Row ${i}`))); const footer = h(View, { key: "footer", id: "footer", fixed: true, style: { position: "absolute", bottom: 4, left: PAD } }, h(Text, { style: txt() }, "Footer")); const r = await render(doc([...rows, footer])); expect(r.pages).toBeGreaterThan(2); r.layout.forEach((p) => expect(byId(p, "footer")).toHaveLength(1)); }); it("11. letterSpacing is added after the last glyph too (the Tracked assumption)", async () => { const m = getMeasurer(); const base = m.measureWidth({ text: "AB", face: "IBM Plex Sans", weight: 400, size: 10 }); const r = await render(doc(h(Text, { id: "t", style: txt({ letterSpacing: 5, textAlign: "right" }) }, "AB"))); const t = byId(r.layout[0], "t")[0]; const line = t.lines![0]; expect(line.w).toBeCloseTo(base + 2 * 5, 2); // The advance reaches the box edge, so the last glyph's ink ends one step short of it. expect(line.x + line.w).toBeCloseTo(t.box.x + t.box.w, 2); }); describe("12. render-prop text height", () => { const label = (pageNumber: number, totalPages: number) => `Page ${pageNumber} of ${totalPages}`; const flow = Array.from({ length: 30 }, (_, i) => h(View, { key: i, style: { height: 20 } }, h(Text, { style: txt() }, `Row ${i}`))); const chrome = (viewStyle: Record) => h( View, { key: "chrome", id: "chrome", fixed: true, style: { position: "absolute", bottom: 4, left: PAD, width: 200, ...viewStyle } }, h(RenderTxt, { id: "label", role: { face: "IBM Plex Sans", weight: 400, size: 8 }, render: ({ pageNumber, totalPages }) => label(pageNumber, totalPages) }), ); it("inside an auto-height fixed View it draws 'Page k of N' on every page", async () => { const r = await render(doc([...flow, chrome({})])); expect(r.pages).toBeGreaterThan(1); r.layout.forEach((p, i) => { const t = byId(p, "label")[0]; expect(t.lines!.map((l) => l.text)).toEqual([label(i + 1, r.pages)]); }); }); it("inside a 10 pt fixed-height View at 8 pt Plex (10.4 pt line) it draws 0 lines", async () => { const r = await render(doc([...flow, chrome({ height: 10 })])); r.layout.forEach((p) => expect(byId(p, "label")[0].lines).toHaveLength(0)); }); }); it("13. fontkit caches glyph objects per font for the whole process, and a subset encode (pdfkit's embed) creates a composite's component glyphs with no code points: Poppins' colon makes the period glyph keep codePoints [] unless the glyphs were created first, which primeFontGlyphs does for every registered face", async () => { // The mechanism, on a font object of our own so no earlier render in this file matters. const fresh = createFont(new Uint8Array(readFileSync(path.join(FONTS_DIR, "poppins/Poppins-Regular.ttf")))) as unknown as { glyphForCodePoint(cp: number): { codePoints: number[] }; createSubset(): { includeGlyph(g: unknown): void; encode(): unknown }; }; const subset = fresh.createSubset(); subset.includeGlyph(fresh.glyphForCodePoint(0x3a)); subset.encode(); expect(fresh.glyphForCodePoint(0x2e).codePoints).toEqual([]); // The fix: rendering primes the registered faces first, so the colon in the first document leaves the period intact for the next one. const poppins = { fontFamily: stack("Poppins"), fontSize: 10, lineHeight: "12pt", fontWeight: 400 }; await render(doc(h(Text, { style: poppins }, "Bank : State"))); const data = (Font as unknown as { getFont: (d: object) => { data: { glyphForCodePoint: (cp: number) => { codePoints: number[] } } } }).getFont({ fontFamily: "Poppins", fontWeight: 400, fontStyle: "normal" }).data; expect(data.glyphForCodePoint(0x2e).codePoints).toEqual([0x2e]); // Text-layer extraction and every other face: src/pdf/fonts/glyphPriming.test.tsx. }); });