Add typography roles, text primitives, fontkit measurer and layout lint
Txt always sets an absolute line height; RenderTxt never does. Row pins every cell and stacks when the fill column would be too narrow. IdText and FitText shrink then chunk or truncate. The fontkit measurer matches react-pdf text widths exactly. A TypeScript-AST lint test enforces the layout rules (no lineHeight outside Txt, no non-literal fixed/wrap/minPresenceAhead, flexGrow needs flexBasis, no stray absolute positioning).
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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 = "Sample.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 = "vendor.name@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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user