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.
315 lines
12 KiB
TypeScript
315 lines
12 KiB
TypeScript
import { createElement as h, type ReactElement, type ReactNode } from "react";
|
|
import { Document, Page, View } from "@react-pdf/renderer";
|
|
import { describe, expect, it } from "vitest";
|
|
import { stack } from "../fonts/register";
|
|
import { TYPE_ROLES } from "../fonts/roles";
|
|
import { assertNoOverlap } from "../testing/asserts";
|
|
import { renderPdfText } from "../testing/pdfText";
|
|
import { setupPdfTest } from "../testing/setup";
|
|
import { AvailableWidthProvider } from "./AvailableWidth";
|
|
import { FitText, type FitInfo } from "./FitText";
|
|
import { IdText, type IdTextInfo } from "./IdText";
|
|
import { pdfFlags } from "./pdfProps";
|
|
import { RenderTxt } from "./RenderTxt";
|
|
import { Cell, Row } from "./Row";
|
|
import { Tracked } from "./Tracked";
|
|
import { Txt } from "./Txt";
|
|
|
|
setupPdfTest();
|
|
|
|
const PAD = 20;
|
|
const PAGE_W = 400;
|
|
|
|
/** A page whose content box is `width` pt wide and published through AvailableWidth. */
|
|
function frame(children: ReactNode, width = PAGE_W - 2 * PAD): ReactElement {
|
|
return h(
|
|
Document,
|
|
null,
|
|
h(
|
|
Page,
|
|
{ size: { width: PAGE_W, height: 600 }, style: { padding: PAD } },
|
|
h(AvailableWidthProvider, { width }, children),
|
|
),
|
|
);
|
|
}
|
|
|
|
type TxtLike = { props: { style: Record<string, unknown> } };
|
|
const styleOf = (el: unknown) => (el as TxtLike).props.style;
|
|
|
|
describe("pdfFlags", () => {
|
|
it("spreads only meaningful keys", () => {
|
|
expect(pdfFlags()).toEqual({});
|
|
expect(pdfFlags({ fixed: false, wrap: undefined, minPresenceAhead: undefined })).toEqual({});
|
|
expect(pdfFlags({ fixed: true })).toEqual({ fixed: true });
|
|
expect(pdfFlags({ wrap: true })).toEqual({});
|
|
expect(pdfFlags({ wrap: false })).toEqual({ wrap: false });
|
|
expect(pdfFlags({ minPresenceAhead: 0 })).toEqual({});
|
|
expect(pdfFlags({ minPresenceAhead: -3 })).toEqual({});
|
|
expect(pdfFlags({ minPresenceAhead: NaN })).toEqual({});
|
|
expect(pdfFlags({ minPresenceAhead: Infinity })).toEqual({});
|
|
expect(pdfFlags({ minPresenceAhead: 12.5 })).toEqual({ minPresenceAhead: 12.5 });
|
|
expect(pdfFlags({ fixed: true, wrap: false, minPresenceAhead: 9 })).toEqual({
|
|
fixed: true,
|
|
wrap: false,
|
|
minPresenceAhead: 9,
|
|
});
|
|
// Keys must be absent, not undefined: react-pdf checks presence.
|
|
expect(Object.keys(pdfFlags({ fixed: false }))).toEqual([]);
|
|
});
|
|
|
|
it("reaches the element only when set", () => {
|
|
const off = Txt({ role: "body", children: "x", fixed: false }) as unknown as { props: Record<string, unknown> };
|
|
expect("fixed" in off.props).toBe(false);
|
|
const on = Txt({ role: "body", children: "x", fixed: true }) as unknown as { props: Record<string, unknown> };
|
|
expect(on.props.fixed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Txt", () => {
|
|
it("sets lineHeight in pt, the family stack and the role metrics", () => {
|
|
const s = styleOf(Txt({ role: "body", children: "x" }));
|
|
expect(s.lineHeight).toBe("13.8pt");
|
|
expect(s.fontFamily).toEqual(stack("IBM Plex Sans"));
|
|
expect(s.fontSize).toBe(9.5);
|
|
expect(s.fontWeight).toBe(400);
|
|
expect(s.fontStyle).toBe("normal");
|
|
expect("letterSpacing" in s).toBe(false);
|
|
expect("textTransform" in s).toBe(false);
|
|
});
|
|
|
|
it("applies tracking, caps and overrides", () => {
|
|
const s = styleOf(Txt({ role: "title", weight: 700, italic: true, color: "#111", align: "right", children: "x" }));
|
|
expect(s.letterSpacing).toBeCloseTo(1.5, 10);
|
|
expect(s.textTransform).toBe("uppercase");
|
|
expect(s.fontWeight).toBe(700);
|
|
expect(s.fontStyle).toBe("italic");
|
|
expect(s.color).toBe("#111");
|
|
expect(s.textAlign).toBe("right");
|
|
expect(s.lineHeight).toBe("22pt");
|
|
});
|
|
|
|
it("pins width, grows and clamps lines", () => {
|
|
const pinnedStyle = styleOf(Txt({ role: "body", width: 80, children: "x" }));
|
|
expect([pinnedStyle.width, pinnedStyle.minWidth, pinnedStyle.maxWidth]).toEqual([80, 80, 80]);
|
|
const grown = styleOf(Txt({ role: "body", grow: true, children: "x" }));
|
|
expect([grown.flexGrow, grown.flexBasis]).toEqual([1, 0]);
|
|
const clamped = styleOf(Txt({ role: "body", maxLines: 2, children: "x" }));
|
|
expect([clamped.maxLines, clamped.textOverflow]).toEqual([2, "ellipsis"]);
|
|
});
|
|
|
|
it("throws on a lineHeight in the extra style and on a role without leading", () => {
|
|
expect(() => Txt({ role: "body", style: { lineHeight: 2 }, children: "x" })).toThrow(/lineHeight/);
|
|
expect(() => Txt({ role: "footer", children: "x" })).toThrow(/leading/);
|
|
});
|
|
|
|
it("every leaded role has a positive leading and footer has none", () => {
|
|
for (const [name, t] of Object.entries(TYPE_ROLES)) {
|
|
if (name === "footer") expect("leading" in t).toBe(false);
|
|
else expect((t as { leading: number }).leading).toBeGreaterThan(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("RenderTxt", () => {
|
|
it("never sets lineHeight and rejects one", () => {
|
|
const el = RenderTxt({ role: "footer", render: () => "x" }) as unknown as {
|
|
props: { style: Record<string, unknown>; render: unknown };
|
|
};
|
|
expect("lineHeight" in el.props.style).toBe(false);
|
|
expect(typeof el.props.render).toBe("function");
|
|
expect(() => RenderTxt({ role: "footer", render: () => "x", style: { lineHeight: 1 } })).toThrow(/lineHeight/);
|
|
});
|
|
|
|
it("draws page numbers on every page", async () => {
|
|
const pdf = await renderPdfText(
|
|
h(
|
|
Document,
|
|
null,
|
|
h(
|
|
Page,
|
|
{ size: { width: 300, height: 120 }, style: { padding: 20 } },
|
|
h(View, { fixed: true }, h(RenderTxt, { role: "footer", render: ({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages ?? "?"}` })),
|
|
h(View, { style: { height: 200 } }),
|
|
),
|
|
),
|
|
);
|
|
expect(pdf.pages.length).toBeGreaterThan(1);
|
|
pdf.pages.forEach((p, i) => {
|
|
expect(p.items.map((t) => t.str)).toEqual([`Page ${i + 1} of ${pdf.pages.length}`]);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Row", () => {
|
|
const tops = (pdf: Awaited<ReturnType<typeof renderPdfText>>, str: string) =>
|
|
pdf.pages[0].items.find((t) => t.str.trim() === str)!;
|
|
|
|
it("lays out side by side when the fill column keeps its minimum", async () => {
|
|
const pdf = await renderPdfText(
|
|
frame(
|
|
h(Row, { widths: [{ fixed: 100 }, { fill: true, min: 100 }], gap: 10 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
|
),
|
|
);
|
|
const l = tops(pdf, "Left");
|
|
const r = tops(pdf, "Right");
|
|
expect(l.x0).toBeCloseTo(PAD, 1);
|
|
expect(r.x0).toBeCloseTo(PAD + 110, 1);
|
|
expect(Math.abs(l.top - r.top)).toBeLessThan(0.5);
|
|
});
|
|
|
|
it("stacks when the fill column would drop under its min", async () => {
|
|
const pdf = await renderPdfText(
|
|
frame(
|
|
h(Row, { widths: [{ fixed: 250 }, { fill: true, min: 160 }], gap: 10 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
|
),
|
|
);
|
|
const l = tops(pdf, "Left");
|
|
const r = tops(pdf, "Right");
|
|
expect(r.x0).toBeCloseTo(PAD, 1);
|
|
expect(r.top).toBeGreaterThan(l.bottom - 0.5);
|
|
});
|
|
|
|
it("stacks below stackBelow", async () => {
|
|
const pdf = await renderPdfText(
|
|
frame(
|
|
h(Row, { widths: [{ fixed: 50 }, { fill: true }], stackBelow: 500 }, h(Txt, { role: "body" }, "Left"), h(Txt, { role: "body" }, "Right")),
|
|
),
|
|
);
|
|
expect(tops(pdf, "Right").top).toBeGreaterThan(tops(pdf, "Left").bottom - 0.5);
|
|
});
|
|
|
|
it("rejects a children/spec mismatch", async () => {
|
|
await expect(
|
|
renderPdfText(frame(<Row widths={[{ fixed: 10 }, { fill: true }]}><Txt role="body">x</Txt></Row>)),
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it("keeps a 300-character address inside its cell and clear of its neighbours", async () => {
|
|
const address = Array.from({ length: 30 }, (_, i) => `Block${i} Road`).join(", ").slice(0, 300);
|
|
expect(address.length).toBe(300);
|
|
const pdf = await renderPdfText(
|
|
frame(
|
|
<Row widths={[{ fill: true, min: 100 }, { fixed: 120 }]} gap={16}>
|
|
<Cell padding={{ right: 6 }}>
|
|
<Txt role="body">{address}</Txt>
|
|
</Cell>
|
|
<Txt role="body">INVOICE No. 42</Txt>
|
|
</Row>,
|
|
),
|
|
);
|
|
assertNoOverlap(pdf.pages);
|
|
const fillWidth = PAGE_W - 2 * PAD - 120 - 16;
|
|
const cellRight = PAD + fillWidth - 6;
|
|
const items = pdf.pages[0].items;
|
|
for (const it of items) {
|
|
if (!it.str.includes("INVOICE")) expect(it.x1, it.str).toBeLessThanOrEqual(cellRight + 0.5);
|
|
}
|
|
expect(items.find((t) => t.str.includes("INVOICE"))!.x0).toBeGreaterThanOrEqual(PAD + fillWidth + 16 - 0.5);
|
|
expect(items.length).toBeGreaterThan(5);
|
|
});
|
|
});
|
|
|
|
describe("FitText and IdText", () => {
|
|
const EMAIL60 = "anon.longsurname.voiceover.studio.department@examplemedia.co.in".slice(0, 60);
|
|
|
|
it("shrinks a medium e-mail to fit and keeps it on one line", async () => {
|
|
const email = "anon.longsurname.voiceover@examplestudios.co.in";
|
|
let info!: FitInfo;
|
|
const pdf = await renderPdfText(
|
|
frame(
|
|
<FitText role="body" width={215} onFit={(i) => (info = i)}>
|
|
{email}
|
|
</FitText>,
|
|
),
|
|
);
|
|
expect(info.fits).toBe(true);
|
|
expect(info.size).toBeLessThan(9.5);
|
|
expect(info.size).toBeGreaterThanOrEqual(8.5);
|
|
const items = pdf.pages[0].items;
|
|
expect(items.map((t) => t.str).join("")).toBe(email);
|
|
expect(Math.max(...items.map((t) => t.x1))).toBeLessThanOrEqual(PAD + 215 + 0.5);
|
|
});
|
|
|
|
it("clips with an ellipsis when it cannot fit at the minimum size", async () => {
|
|
let info!: FitInfo;
|
|
const pdf = await renderPdfText(
|
|
frame(
|
|
<FitText role="body" width={150} onFit={(i) => (info = i)}>
|
|
{EMAIL60}
|
|
</FitText>,
|
|
),
|
|
);
|
|
expect(info.fits).toBe(false);
|
|
expect(info.size).toBeCloseTo(8.55, 5);
|
|
const items = pdf.pages[0].items;
|
|
expect(items.length).toBe(1);
|
|
expect(items[0].str.endsWith("…")).toBe(true);
|
|
expect(EMAIL60.startsWith(items[0].str.slice(0, -1))).toBe(true);
|
|
expect(items[0].x1).toBeLessThanOrEqual(PAD + 150 + 0.5);
|
|
});
|
|
|
|
it("splits a 60-character e-mail into chunks inside a 150 pt cell", async () => {
|
|
let info!: IdTextInfo;
|
|
const pdf = await renderPdfText(
|
|
frame(
|
|
<Row widths={[{ fixed: 150 }, { fill: true }]}>
|
|
<IdText role="body" onFit={(i) => (info = i)}>
|
|
{EMAIL60}
|
|
</IdText>
|
|
<Txt role="body">Next</Txt>
|
|
</Row>,
|
|
),
|
|
);
|
|
expect(info.mode).toBe("chunked");
|
|
expect(info.chunks.join("")).toBe(EMAIL60);
|
|
const idItems = pdf.pages[0].items.filter((t) => t.str !== "Next");
|
|
// Extraction joins the chunks with no inserted characters.
|
|
expect(idItems.map((t) => t.str).join("")).toBe(EMAIL60);
|
|
for (const t of idItems) expect(t.x1, t.str).toBeLessThanOrEqual(PAD + 150 + 0.5);
|
|
assertNoOverlap(pdf.pages);
|
|
const next = pdf.pages[0].items.find((t) => t.str === "Next")!;
|
|
expect(next.x0).toBeGreaterThanOrEqual(PAD + 150 - 0.5);
|
|
});
|
|
|
|
it("keeps an identifier on one line when it already fits", async () => {
|
|
let info!: IdTextInfo;
|
|
await renderPdfText(
|
|
frame(
|
|
<Row widths={[{ fixed: 150 }, { fill: true }]}>
|
|
<IdText role="body" onFit={(i) => (info = i)}>
|
|
29ABCDE1234F1Z5
|
|
</IdText>
|
|
<Txt role="body">x</Txt>
|
|
</Row>,
|
|
),
|
|
);
|
|
expect(info.mode).toBe("single");
|
|
expect(info.size).toBe(9.5);
|
|
});
|
|
});
|
|
|
|
describe("Tracked", () => {
|
|
const inkRight = async (el: ReactElement) => {
|
|
const pdf = await renderPdfText(frame(h(View, { style: { width: 200 } }, el)));
|
|
return pdf.pages[0].items[0];
|
|
};
|
|
|
|
it("puts the right ink edge on the untracked edge", async () => {
|
|
const plain = await inkRight(h(Txt, { role: { ...TYPE_ROLES.title, tracking: 0 }, align: "right" }, "Invoice"));
|
|
const tracked = await inkRight(h(Tracked, { role: "title", align: "right" }, "Invoice"));
|
|
const naive = await inkRight(h(Txt, { role: "title", align: "right" }, "Invoice"));
|
|
// pdf.js x1 is the end of the last glyph, without the trailing letter spacing.
|
|
expect(plain.x1).toBeCloseTo(PAD + 200, 1);
|
|
expect(Math.abs(tracked.x1 - plain.x1)).toBeLessThanOrEqual(0.5);
|
|
// Without compensation the ink ends one tracking step early; the test would otherwise prove nothing.
|
|
expect(plain.x1 - naive.x1).toBeGreaterThan(1.4);
|
|
});
|
|
|
|
it("centres the ink", async () => {
|
|
const t = await inkRight(h(Tracked, { role: "title", align: "center" }, "Invoice"));
|
|
expect((t.x0 + t.x1) / 2).toBeCloseTo(PAD + 100, 0);
|
|
const naive = await inkRight(h(Txt, { role: "title", align: "center" }, "Invoice"));
|
|
expect(Math.abs((t.x0 + t.x1) / 2 - (PAD + 100))).toBeLessThan(Math.abs((naive.x0 + naive.x1) / 2 - (PAD + 100)) + 1e-6);
|
|
});
|
|
});
|