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:
2026-10-04 04:48:19 +05:30
parent 92ba3d9590
commit 40aa3e05d9
22 changed files with 1375 additions and 34 deletions
+15
View File
@@ -0,0 +1,15 @@
import { createContext, useContext, type ReactNode } from "react";
const Ctx = createContext<number | null>(null);
/** Publishes the content width in pt of the box its children are laid out in. */
export function AvailableWidthProvider({ width, children }: { width: number; children?: ReactNode }) {
return <Ctx.Provider value={width}>{children}</Ctx.Provider>;
}
/** Content width (box width minus its padding and border) available to the calling component. */
export function useAvailableWidth(): number {
const w = useContext(Ctx);
if (w === null) throw new Error("useAvailableWidth outside an AvailableWidthProvider (the page frame provides the root)");
return w;
}
+80
View File
@@ -0,0 +1,80 @@
import { getMeasurer, measureToken } from "../engine/measure";
import { minSizeOf, resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
import { Txt, type TxtProps } from "./Txt";
export interface FitInfo {
size: number;
fits: boolean;
width: number;
}
export interface FitTextProps extends Omit<TxtProps, "children" | "role" | "maxLines" | "width"> {
role: RoleName | TypeToken;
/** The width the single line must fit, pt. */
width: number;
/** Smallest size to shrink to; defaults to the role's minimum size. */
minSize?: number;
children: string;
/** For tests and audits only. */
onFit?: (info: FitInfo) => void;
}
const STEP = 0.25;
/** Size at which `text` fits `width` on one line, shrinking from the role size in 0.25 pt steps. */
export function fitSize(
role: RoleName | TypeToken,
text: string,
width: number,
minSize?: number,
opts: { weight?: number; italic?: boolean; family?: string } = {},
): FitInfo {
const token = roleToken(role);
const m = getMeasurer();
const floor = Math.min(token.size, minSize ?? minSizeOf(token));
const family = opts.family ?? resolveFace(token.face);
let size = token.size;
for (;;) {
const w = measureToken(m, token, text, { ...opts, size, family });
if (w <= width + 1e-6) return { size, fits: true, width: w };
if (size <= floor + 1e-9) return { size, fits: false, width: w };
size = Math.max(floor, Math.round((size - STEP) * 100) / 100);
}
}
/** The longest prefix of `text` that, followed by an ellipsis, fits `width` at `size`. */
function truncateToFit(token: TypeToken, text: string, width: number, size: number, opts: { weight?: number; italic?: boolean; family: string }): string {
const m = getMeasurer();
const chars = Array.from(text);
const widthOf = (n: number) => measureToken(m, token, chars.slice(0, n).join("").trimEnd() + "…", { ...opts, size });
let lo = 0;
let hi = chars.length;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (widthOf(mid) <= width + 1e-6) lo = mid;
else hi = mid - 1;
}
return chars.slice(0, lo).join("").trimEnd() + "…";
}
/**
* Single-line text that shrinks to fit. If it still does not fit at `minSize` it is cut to the longest
* prefix plus an ellipsis at that size. This is done here and not with maxLines: textkit never breaks
* inside a word, so a single over-long token stays on its one line, overflows the box and gets no
* ellipsis (verified: a 60-character e-mail in a 150 pt box ran to 257 pt). maxLines stays on as a
* backstop for multi-word text.
*/
export function FitText({ role, width, minSize, children, onFit, ...rest }: FitTextProps) {
const token = roleToken(role);
const family = resolveFace(token.face, rest.faceOverrides);
const info = fitSize(token, children, width, minSize, { weight: rest.weight, italic: rest.italic, family });
onFit?.(info);
// Leading stays the role's: only the glyph size shrinks.
const sized: TypeToken = { ...token, size: info.size };
const text = info.fits ? children : truncateToFit(token, children, width, info.size, { weight: rest.weight, italic: rest.italic, family });
return (
<Txt {...rest} role={sized} width={width} maxLines={info.fits ? undefined : 1}>
{text}
</Txt>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { View } from "@react-pdf/renderer";
import { getMeasurer, measureToken } from "../engine/measure";
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
import { chunkIdentifier } from "../model/text";
import { useAvailableWidth } from "./AvailableWidth";
import { fitSize, type FitInfo } from "./FitText";
import { Txt, type TxtProps } from "./Txt";
export interface IdTextInfo extends FitInfo {
mode: "single" | "chunked";
chunks: string[];
}
export interface IdTextProps extends Omit<TxtProps, "children" | "role" | "maxLines" | "width" | "grow"> {
role: RoleName | TypeToken;
children: string;
/** Width to fit; defaults to the available width of the enclosing cell. */
width?: number;
minSize?: number;
/** For tests and audits only. */
onFit?: (info: IdTextInfo) => void;
}
/**
* Text for identifiers (e-mail, URL, GSTIN, phone). textkit never breaks inside a word, so a long
* value would overflow silently. This shrinks it to the role's minimum size; if it is still too wide
* it is split at "@ . / - _" into a flex-wrap row of adjacent Text chunks (no gaps, so extraction
* joins them back into the original string).
*/
export function IdText({ role, children, width, minSize, onFit, ...rest }: IdTextProps) {
const avail = useAvailableWidth();
const target = width ?? avail;
const token = roleToken(role);
const fit = fitSize(token, children, target, minSize, { weight: rest.weight, italic: rest.italic });
if (fit.fits) {
onFit?.({ ...fit, mode: "single", chunks: [children] });
return <Txt {...rest} role={{ ...token, size: fit.size }}>{children}</Txt>;
}
const sized: TypeToken = { ...token, size: fit.size };
const m = getMeasurer();
const family = resolveFace(token.face, rest.faceOverrides);
const widthOf = (s: string) =>
measureToken(m, sized, s, { weight: rest.weight, italic: rest.italic, family });
let chunks = chunkIdentifier(children, 1);
for (let max = children.length; max >= 1; max--) {
const candidate = chunkIdentifier(children, max);
if (candidate.every((c) => widthOf(c) <= target + 1e-6)) {
chunks = candidate;
break;
}
}
onFit?.({ ...fit, mode: "chunked", chunks });
return (
<View style={{ flexDirection: "row", flexWrap: "wrap", width: target, minWidth: target, maxWidth: target } as never}>
{chunks.map((c, i) => (
<Txt key={i} {...rest} align={undefined} role={sized}>
{c}
</Txt>
))}
</View>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { Text } from "@react-pdf/renderer";
import { FILL, pinned } from "../engine/columns";
import { roleToken, type RoleName, type TypeToken } from "../fonts/roles";
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
import { assertNoLineHeight, baseTextStyle, type TxtStyleProps } from "./Txt";
export interface RenderTxtProps extends TxtStyleProps, PdfFlagsInput {
/** Receives { pageNumber, totalPages, subPageNumber, ... } from react-pdf. */
render: (info: { pageNumber: number; totalPages: number; subPageNumber: number; subPageTotalPages: number }) => string;
role: RoleName | TypeToken;
width?: number;
grow?: boolean;
style?: Record<string, unknown>;
}
/**
* Text drawn from a render prop (page numbers, continuation band). It sets NO lineHeight, and no
* ancestor may set one either: react-pdf multiplies a render-prop lineHeight again on every relayout
* ('10pt' ends up as 5120 and nothing draws). The box is auto-height; reserve its space from font
* metrics, never a fixed height. No maxLines: a clamped render-prop Text draws nothing.
* `totalPages` is undefined on the first layout pass, so the render function must cope with that.
*/
export function RenderTxt({ role, render, color, align, weight, italic, width, grow, style, faceOverrides, fixed, wrap, minPresenceAhead }: RenderTxtProps) {
const token = roleToken(role);
assertNoLineHeight(style, "RenderTxt");
const merged = {
...baseTextStyle(token, { color, align, weight, italic, faceOverrides }),
...(width !== undefined ? pinned(width) : {}),
...(grow ? FILL : {}),
...style,
};
return <Text style={merged as never} render={render as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })} />;
}
+103
View File
@@ -0,0 +1,103 @@
import { View } from "@react-pdf/renderer";
import { Children, type ReactNode } from "react";
import { colsDetailed, FILL, pinned, type ColSpec } from "../engine/columns";
import { AvailableWidthProvider, useAvailableWidth } from "./AvailableWidth";
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
export interface RowProps extends PdfFlagsInput {
/** One spec per child, in order. */
widths: ColSpec[];
gap?: number;
/** Stack the children as a column when the row is narrower than this. */
stackBelow?: number;
/** Cross-axis alignment of the cells; never flex-end around multi-line text. */
align?: "flex-start" | "center" | "stretch";
/** Overrides the width from AvailableWidth. */
width?: number;
style?: Record<string, unknown>;
children?: ReactNode;
}
/**
* A flex row whose children each sit in a cell of explicit width: fixed and fractional cells are pinned
* (width = minWidth = maxWidth) and the fill cell uses {flexGrow: 1, flexBasis: 0}. Descendants read the
* cell's content width through useAvailableWidth(). When a column falls under its `min` (or the row is
* narrower than `stackBelow`), the children are stacked at full width instead.
*/
export function Row({ widths, gap = 0, stackBelow, align = "flex-start", width, style, children, fixed, wrap, minPresenceAhead }: RowProps) {
const inherited = useAvailableWidth();
const total = width ?? inherited;
const kids = Children.toArray(children);
if (kids.length !== widths.length) throw new Error(`Row: ${kids.length} children for ${widths.length} column specs`);
const { widths: w, ok } = colsDetailed(total, widths, gap);
const stacked = !ok || (stackBelow !== undefined && total < stackBelow);
const flags = pdfFlags({ fixed, wrap, minPresenceAhead });
if (stacked) {
return (
<View style={{ flexDirection: "column", alignItems: "flex-start", ...style } as never} {...flags}>
{kids.map((child, i) => (
<View key={i} style={{ ...pinned(total), alignItems: "flex-start", ...(gap && i > 0 ? { marginTop: gap } : {}) } as never}>
<AvailableWidthProvider width={total}>{child}</AvailableWidthProvider>
</View>
))}
</View>
);
}
return (
<View style={{ flexDirection: "row", alignItems: align, ...style } as never} {...flags}>
{kids.map((child, i) => {
const isFill = "fill" in widths[i];
const box = isFill ? FILL : pinned(w[i]);
return (
<View
key={i}
style={{ ...box, alignItems: "flex-start", ...(i > 0 && gap ? { marginLeft: gap } : {}) } as never}
>
<AvailableWidthProvider width={w[i]}>{child}</AvailableWidthProvider>
</View>
);
})}
</View>
);
}
export interface CellProps {
/** Padding in pt: one number for all sides, or per side. */
padding?: number | { top?: number; right?: number; bottom?: number; left?: number };
borderLeft?: number;
borderRight?: number;
style?: Record<string, unknown>;
children?: ReactNode;
}
/**
* A padded/bordered box inside a Row column. It fills the column's width and republishes the content
* width (column width minus its own padding and border) to its descendants.
*/
export function Cell({ padding = 0, borderLeft = 0, borderRight = 0, style, children }: CellProps) {
const full = useAvailableWidth();
const p = typeof padding === "number" ? { top: padding, right: padding, bottom: padding, left: padding } : padding;
const inner = Math.max(0, full - (p.left ?? 0) - (p.right ?? 0) - borderLeft - borderRight);
return (
<View
style={
{
...pinned(full),
alignItems: "flex-start",
paddingTop: p.top ?? 0,
paddingRight: p.right ?? 0,
paddingBottom: p.bottom ?? 0,
paddingLeft: p.left ?? 0,
...(borderLeft ? { borderLeftWidth: borderLeft } : {}),
...(borderRight ? { borderRightWidth: borderRight } : {}),
...style,
} as never
}
>
<AvailableWidthProvider width={inner}>{children}</AvailableWidthProvider>
</View>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { roleToken } from "../fonts/roles";
import { Txt, type TxtProps } from "./Txt";
export interface TrackedProps extends Omit<TxtProps, "align"> {
align: "right" | "center";
}
/**
* textkit adds letterSpacing after every glyph, the last one included, so right- and centre-aligned
* tracked text sits one tracking step too far left. This cancels it with a negative margin: the full
* step on the right edge for `right`, half a step each side for `center` (shifting the box right by
* half a step).
*
* HAZARD: the Text box now overhangs its parent by up to one tracking step. Overflow and safe-zone
* audits must measure ink (glyph extents), not boxes, or they flag it.
*/
export function Tracked({ align, style, ...rest }: TrackedProps) {
const token = roleToken(rest.role);
const step = (token.tracking ?? 0) * token.size;
const margin =
align === "right"
? { marginRight: -step }
: { marginLeft: step / 2, marginRight: -step / 2 };
return <Txt {...rest} align={align} style={{ ...margin, ...style }} />;
}
+72
View File
@@ -0,0 +1,72 @@
import { Text } from "@react-pdf/renderer";
import type { ReactNode } from "react";
import { FILL, pinned } from "../engine/columns";
import { stack } from "../fonts/register";
import { pdfFlags, type PdfFlagsInput } from "./pdfProps";
import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles";
export type TxtChild = string | number | Array<string | number>;
type Align = "left" | "right" | "center" | "justify";
export interface TxtStyleProps {
color?: string;
align?: Align;
weight?: TypeToken["weight"];
italic?: boolean;
/** Font family overrides (e.g. a user-imported font replacing a template face). */
faceOverrides?: Readonly<Record<string, string>>;
}
export interface TxtProps extends TxtStyleProps, PdfFlagsInput {
role: RoleName | TypeToken;
/** Clamp to this many lines and end with an ellipsis. */
maxLines?: number;
/** Pin the box to this width in pt (width = minWidth = maxWidth). */
width?: number;
/** Take the remaining row width ({flexGrow: 1, flexBasis: 0}). */
grow?: boolean;
/** Extra style. Must not contain lineHeight: leading comes from the role. */
style?: Record<string, unknown>;
children?: TxtChild;
}
/** Style fields shared by Txt and RenderTxt: everything except lineHeight. */
export function baseTextStyle(token: TypeToken, o: TxtStyleProps) {
const size = token.size;
const weight = o.weight ?? token.weight;
return {
fontFamily: stack(resolveFace(token.face, o.faceOverrides)),
fontSize: size,
fontWeight: weight,
fontStyle: (o.italic ?? token.italic) ? "italic" : "normal",
...(token.tracking ? { letterSpacing: token.tracking * size } : {}),
...(token.caps ? { textTransform: "uppercase" } : {}),
...(o.color ? { color: o.color } : {}),
...(o.align ? { textAlign: o.align } : {}),
} as const;
}
export function assertNoLineHeight(style: Record<string, unknown> | undefined, who: string): void {
if (style && "lineHeight" in style && process.env.NODE_ENV !== "production") {
throw new Error(`${who}: style must not contain lineHeight; the role sets the leading`);
}
}
/**
* Static text. Always sets family, size, weight, style and an absolute `lineHeight: "Npt"` on the
* same node; never put a lineHeight on an ancestor View.
*/
export function Txt({ role, color, align, weight, italic, maxLines, width, grow, style, faceOverrides, children, fixed, wrap, minPresenceAhead }: TxtProps) {
const token = roleToken(role);
if (token.leading === undefined) throw new Error("Txt needs a role with a leading; use RenderTxt for render-prop text");
assertNoLineHeight(style, "Txt");
const merged = {
...baseTextStyle(token, { color, align, weight, italic, faceOverrides }),
lineHeight: `${token.leading}pt`,
...(width !== undefined ? pinned(width) : {}),
...(grow ? FILL : {}),
...(maxLines !== undefined ? { maxLines, textOverflow: "ellipsis" } : {}),
...style,
};
return <Text style={merged as never} {...pdfFlags({ fixed, wrap, minPresenceAhead })}>{children as ReactNode}</Text>;
}
+314
View File
@@ -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);
});
});
+24
View File
@@ -0,0 +1,24 @@
export interface PdfFlagsInput {
fixed?: boolean;
wrap?: boolean;
minPresenceAhead?: number;
}
/**
* react-pdf checks `fixed`, `wrap` and `minPresenceAhead` by key presence: `fixed={false}` still
* disables breaking and `minPresenceAhead={undefined}` changes behaviour too. Every primitive spreads
* this result instead of passing the props directly (lint enforces it).
*/
export function pdfFlags({ fixed, wrap, minPresenceAhead }: PdfFlagsInput = {}): {
fixed?: true;
wrap?: false;
minPresenceAhead?: number;
} {
return {
...(fixed ? { fixed: true as const } : {}),
...(wrap === false ? { wrap: false as const } : {}),
...(typeof minPresenceAhead === "number" && Number.isFinite(minPresenceAhead) && minPresenceAhead > 0
? { minPresenceAhead }
: {}),
};
}