From 40aa3e05d962914cd96af01f1d08b1253ecc3750 Mon Sep 17 00:00:00 2001 From: Xavier Karma Date: Sun, 4 Oct 2026 04:48:19 +0530 Subject: [PATCH] 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). --- package-lock.json | 2 +- package.json | 2 +- src/pdf/InvoiceDocument.test.tsx | 7 +- src/pdf/blocks/AvailableWidth.tsx | 15 ++ src/pdf/blocks/FitText.tsx | 80 ++++++++ src/pdf/blocks/IdText.tsx | 64 ++++++ src/pdf/blocks/RenderTxt.tsx | 33 ++++ src/pdf/blocks/Row.tsx | 103 ++++++++++ src/pdf/blocks/Tracked.tsx | 25 +++ src/pdf/blocks/Txt.tsx | 72 +++++++ src/pdf/blocks/blocks.test.tsx | 314 ++++++++++++++++++++++++++++++ src/pdf/blocks/pdfProps.ts | 24 +++ src/pdf/engine/fontkit.d.ts | 16 ++ src/pdf/engine/measure.test.tsx | 136 +++++++++++++ src/pdf/engine/measure.ts | 175 +++++++++++++++++ src/pdf/fonts/register.ts | 35 +--- src/pdf/fonts/roles.ts | 65 +++++++ src/pdf/fonts/stack.ts | 34 ++++ src/pdf/lint.test.ts | 80 ++++++++ src/pdf/testing/asserts.ts | 10 + src/pdf/testing/layoutLint.ts | 103 ++++++++++ src/pdf/testing/setup.ts | 14 ++ 22 files changed, 1375 insertions(+), 34 deletions(-) create mode 100644 src/pdf/blocks/AvailableWidth.tsx create mode 100644 src/pdf/blocks/FitText.tsx create mode 100644 src/pdf/blocks/IdText.tsx create mode 100644 src/pdf/blocks/RenderTxt.tsx create mode 100644 src/pdf/blocks/Row.tsx create mode 100644 src/pdf/blocks/Tracked.tsx create mode 100644 src/pdf/blocks/Txt.tsx create mode 100644 src/pdf/blocks/blocks.test.tsx create mode 100644 src/pdf/blocks/pdfProps.ts create mode 100644 src/pdf/engine/fontkit.d.ts create mode 100644 src/pdf/engine/measure.test.tsx create mode 100644 src/pdf/engine/measure.ts create mode 100644 src/pdf/fonts/roles.ts create mode 100644 src/pdf/fonts/stack.ts create mode 100644 src/pdf/lint.test.ts create mode 100644 src/pdf/testing/asserts.ts create mode 100644 src/pdf/testing/layoutLint.ts create mode 100644 src/pdf/testing/setup.ts diff --git a/package-lock.json b/package-lock.json index f5dc1ea..47a8f77 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@tauri-apps/api": "^2.12.1", "@tauri-apps/plugin-dialog": "^2.8.1", "@tauri-apps/plugin-opener": "^2.7.0", + "fontkit": "2.0.4", "react": "^19.3.0", "react-dom": "^19.3.0", "react-is": "^19.3.0" @@ -26,7 +27,6 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.2.0", - "fontkit": "2.0.4", "pdfjs-dist": "6.4.299", "sass": "^1.105.1", "typescript": "^5.9.3", diff --git a/package.json b/package.json index 90026bf..61b196b 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@tauri-apps/api": "^2.12.1", "@tauri-apps/plugin-dialog": "^2.8.1", "@tauri-apps/plugin-opener": "^2.7.0", + "fontkit": "2.0.4", "react": "^19.3.0", "react-dom": "^19.3.0", "react-is": "^19.3.0" @@ -37,7 +38,6 @@ "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.2.0", - "fontkit": "2.0.4", "pdfjs-dist": "6.4.299", "sass": "^1.105.1", "typescript": "^5.9.3", diff --git a/src/pdf/InvoiceDocument.test.tsx b/src/pdf/InvoiceDocument.test.tsx index 8f908ad..44305f1 100644 --- a/src/pdf/InvoiceDocument.test.tsx +++ b/src/pdf/InvoiceDocument.test.tsx @@ -4,7 +4,8 @@ import path from "node:path"; import { beforeAll, describe, expect, it } from "vitest"; import { InvoiceDocument, type InvoicePdfProps } from "./InvoiceDocument"; import { registerPdfFonts } from "./fonts"; -import { describeBox, findOverlaps, renderPdfText, type RenderedPdf, type TextBox } from "./testing/pdfText"; +import { assertNoOverlap } from "./testing/asserts"; +import { describeBox, renderPdfText, type RenderedPdf, type TextBox } from "./testing/pdfText"; import type { BankAccount, InvoiceItem, Settings } from "../lib/types"; registerPdfFonts(path.resolve(process.cwd(), "public/fonts")); @@ -146,12 +147,10 @@ const textOf = (items: TextBox[]) => items.map((t) => t.str).join(" "); function checkCommon(pdf: RenderedPdf) { const n = pdf.pages.length; expect(pdf.raw).not.toContain("/BaseFont /Helvetica"); + assertNoOverlap(pdf.pages); pdf.pages.forEach((page, idx) => { const k = idx + 1; - const overlaps = findOverlaps(page).map(([a, b]) => `${describeBox(a)} x ${describeBox(b)}`); - expect(overlaps, `page ${k} overlaps`).toEqual([]); - for (const t of page.items) { const isFooter = t.top > 780; expect(t.x0, `page ${k} ${describeBox(t)} left`).toBeGreaterThanOrEqual(MARGIN_L); diff --git a/src/pdf/blocks/AvailableWidth.tsx b/src/pdf/blocks/AvailableWidth.tsx new file mode 100644 index 0000000..7acd7f8 --- /dev/null +++ b/src/pdf/blocks/AvailableWidth.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext, type ReactNode } from "react"; + +const Ctx = createContext(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 {children}; +} + +/** 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; +} diff --git a/src/pdf/blocks/FitText.tsx b/src/pdf/blocks/FitText.tsx new file mode 100644 index 0000000..6a61057 --- /dev/null +++ b/src/pdf/blocks/FitText.tsx @@ -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 { + 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 ( + + {text} + + ); +} diff --git a/src/pdf/blocks/IdText.tsx b/src/pdf/blocks/IdText.tsx new file mode 100644 index 0000000..92df9ed --- /dev/null +++ b/src/pdf/blocks/IdText.tsx @@ -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 { + 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 {children}; + } + + 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 ( + + {chunks.map((c, i) => ( + + {c} + + ))} + + ); +} diff --git a/src/pdf/blocks/RenderTxt.tsx b/src/pdf/blocks/RenderTxt.tsx new file mode 100644 index 0000000..0e370cd --- /dev/null +++ b/src/pdf/blocks/RenderTxt.tsx @@ -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; +} + +/** + * 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 ; +} diff --git a/src/pdf/blocks/Row.tsx b/src/pdf/blocks/Row.tsx new file mode 100644 index 0000000..63a6233 --- /dev/null +++ b/src/pdf/blocks/Row.tsx @@ -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; + 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 ( + + {kids.map((child, i) => ( + 0 ? { marginTop: gap } : {}) } as never}> + {child} + + ))} + + ); + } + + return ( + + {kids.map((child, i) => { + const isFill = "fill" in widths[i]; + const box = isFill ? FILL : pinned(w[i]); + return ( + 0 && gap ? { marginLeft: gap } : {}) } as never} + > + {child} + + ); + })} + + ); +} + +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; + 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 ( + + {children} + + ); +} diff --git a/src/pdf/blocks/Tracked.tsx b/src/pdf/blocks/Tracked.tsx new file mode 100644 index 0000000..170c1e3 --- /dev/null +++ b/src/pdf/blocks/Tracked.tsx @@ -0,0 +1,25 @@ +import { roleToken } from "../fonts/roles"; +import { Txt, type TxtProps } from "./Txt"; + +export interface TrackedProps extends Omit { + 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 ; +} diff --git a/src/pdf/blocks/Txt.tsx b/src/pdf/blocks/Txt.tsx new file mode 100644 index 0000000..088a7cd --- /dev/null +++ b/src/pdf/blocks/Txt.tsx @@ -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; +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>; +} + +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; + 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 | 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 {children as ReactNode}; +} diff --git a/src/pdf/blocks/blocks.test.tsx b/src/pdf/blocks/blocks.test.tsx new file mode 100644 index 0000000..b6f48bb --- /dev/null +++ b/src/pdf/blocks/blocks.test.tsx @@ -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 } }; +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 }; + expect("fixed" in off.props).toBe(false); + const on = Txt({ role: "body", children: "x", fixed: true }) as unknown as { props: Record }; + 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; 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>, 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(x)), + ).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( + + + {address} + + INVOICE No. 42 + , + ), + ); + 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( + (info = i)}> + {email} + , + ), + ); + 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( + (info = i)}> + {EMAIL60} + , + ), + ); + 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( + + (info = i)}> + {EMAIL60} + + Next + , + ), + ); + 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( + + (info = i)}> + 29ABCDE1234F1Z5 + + x + , + ), + ); + 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); + }); +}); diff --git a/src/pdf/blocks/pdfProps.ts b/src/pdf/blocks/pdfProps.ts new file mode 100644 index 0000000..a945bbb --- /dev/null +++ b/src/pdf/blocks/pdfProps.ts @@ -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 } + : {}), + }; +} diff --git a/src/pdf/engine/fontkit.d.ts b/src/pdf/engine/fontkit.d.ts new file mode 100644 index 0000000..41d3cde --- /dev/null +++ b/src/pdf/engine/fontkit.d.ts @@ -0,0 +1,16 @@ +// Minimal typings for the parts of fontkit 2.0.4 the measurer uses (the package ships none). +declare module "fontkit" { + export interface GlyphPosition { + xAdvance: number; + } + export interface GlyphRun { + glyphs: unknown[]; + positions: GlyphPosition[]; + } + export interface Font { + unitsPerEm: number; + hasGlyphForCodePoint(codePoint: number): boolean; + layout(text: string, features?: unknown, script?: string, language?: string, direction?: string): GlyphRun; + } + export function create(data: Uint8Array): Font; +} diff --git a/src/pdf/engine/measure.test.tsx b/src/pdf/engine/measure.test.tsx new file mode 100644 index 0000000..f5d48ad --- /dev/null +++ b/src/pdf/engine/measure.test.tsx @@ -0,0 +1,136 @@ +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", + "vendor.name@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 = "vendor.name@examplestudios.co.in"; + expect(chunkIdentifier(id, 12).join("")).toBe(id); + }); +}); diff --git a/src/pdf/engine/measure.ts b/src/pdf/engine/measure.ts new file mode 100644 index 0000000..8ebe1a0 --- /dev/null +++ b/src/pdf/engine/measure.ts @@ -0,0 +1,175 @@ +import { create, type Font } from "fontkit"; +import { FONT_MANIFEST } from "../fonts/manifest"; +import { familySources, FONT_STACK, stack, type FamilySource } from "../fonts/stack"; +import { resolveFace, roleToken, type RoleName, type TypeToken } from "../fonts/roles"; + +export interface MeasureInput { + text: string; + /** A registered family name (after resolveFace). */ + face: string; + weight: number; + italic?: boolean; + size: number; + /** pt added after every glyph, including the last one (textkit does the same). */ + letterSpacing?: number; +} + +export type WrapInput = Omit; + +export interface Measurer { + /** Advance width of a single line in pt, with kerning and font fallback as textkit applies them. */ + measureWidth(input: MeasureInput): number; + /** + * Greedy word-wrap that predicts textkit's line breaks. Estimate for planning only: textkit's + * Knuth-Plass breaker lets spaces shrink on an overfull line, so it can fit a word this routine + * pushes to the next line (and the other way round for very narrow boxes). + */ + wrapLines(text: string, width: number, input: WrapInput): string[]; + /** Code points that no family in the stack covers (they would render as .notdef). */ + missing(text: string, face?: string): number[]; +} + +/** react-pdf's FontFamily.resolve, so the measurer reads the same file the renderer embeds. */ +function resolveSource(sources: FamilySource[], weight: number, style: "normal" | "italic"): FamilySource | undefined { + const styled = sources.filter((s) => s.fontStyle === style); + const exact = styled.find((s) => s.fontWeight === weight); + if (exact) return exact; + const byWeight = (a: FamilySource, b: FamilySource) => a.fontWeight - b.fontWeight; + const lt = styled.filter((s) => s.fontWeight < weight).sort(byWeight); + const gt = styled.filter((s) => s.fontWeight > weight).sort(byWeight); + if (weight >= 400 && weight <= 500) { + const left = styled.filter((s) => s.fontWeight <= weight); + const right = styled.filter((s) => s.fontWeight > 500); + const fit = styled.filter((s) => s.fontWeight >= weight && s.fontWeight <= 500); + return fit[0] ?? left[left.length - 1] ?? right[0]; + } + if (weight < 400) return lt[lt.length - 1] ?? gt[0]; + return gt[0] ?? lt[lt.length - 1]; +} + +const SOFT_HYPHEN = 0xad; + +/** + * `loadBytes` receives a path relative to the fonts folder ("ibm-plex-sans/IBMPlexSans-Regular.ttf") + * and must be synchronous; in a worker, preload the files first and serve them from memory. + */ +export function createMeasurer(loadBytes: (file: string) => Uint8Array): Measurer { + const fonts = new Map(); + + function load(familyName: string, weight: number, italic: boolean): Font | null { + const fam = FONT_MANIFEST.families.find((f) => f.family === familyName); + if (!fam) return null; + const src = resolveSource(familySources(fam), weight, italic ? "italic" : "normal"); + if (!src) return null; + const file = `${fam.folder}/${src.file}`; + let font = fonts.get(file); + if (!font) { + font = create(loadBytes(file)); + fonts.set(file, font); + } + return font; + } + + function stackFonts(face: string, weight: number, italic: boolean): Font[] { + return stack(face) + .map((f) => load(f, weight, italic)) + .filter((f): f is Font => f !== null); + } + + // Mirrors textkit's fontSubstitution: the first stack font with the glyph wins, and the last stack + // font takes code points nobody covers. + function runsOf(text: string, fontStack: Font[]): Array<{ font: Font; text: string }> { + const runs: Array<{ font: Font; text: string }> = []; + let last: Font | null = null; + for (const ch of text) { + const cp = ch.codePointAt(0) as number; + let font: Font | undefined; + if (cp === SOFT_HYPHEN && last) font = last; + else font = [...fontStack, ...(last ? [last] : [])].find((f) => f.hasGlyphForCodePoint(cp)); + font ??= fontStack[fontStack.length - 1]; + const tail = runs[runs.length - 1]; + if (tail && tail.font === font) tail.text += ch; + else runs.push({ font, text: ch }); + last = font; + } + return runs; + } + + function measureWidth({ text, face, weight, italic = false, size, letterSpacing = 0 }: MeasureInput): number { + if (!text) return 0; + const fontStack = stackFonts(face, weight, italic); + let width = 0; + for (const run of runsOf(text, fontStack)) { + // 'ltr' as textkit passes it, so fontkit does not reverse anything. + const laid = run.font.layout(run.text, undefined, undefined, undefined, "ltr"); + const scale = size / run.font.unitsPerEm; + for (const pos of laid.positions) width += pos.xAdvance * scale + letterSpacing; + } + return width; + } + + function wrapLines(text: string, width: number, input: WrapInput): string[] { + const lines: string[] = []; + // Break opportunities: after a space run, and after a hyphen that is followed by more text. + const tokens = text.match(/[^ -]*-(?=[^ ])|[^ ]+ *|^ +/g) ?? []; + let line = ""; + for (const token of tokens) { + const candidate = line + token; + if (!line || measureWidth({ ...input, text: candidate.trimEnd() }) <= width + 1e-6) { + line = candidate; + } else { + lines.push(line.trimEnd()); + line = token; + } + } + if (line) lines.push(line.trimEnd()); + return lines; + } + + function missing(text: string, face = FONT_STACK[0]): number[] { + const fontStack = stackFonts(face, 400, false); + const out = new Set(); + for (const ch of text) { + const cp = ch.codePointAt(0) as number; + if (cp === SOFT_HYPHEN || cp === 0x200c || cp === 0x200d) continue; + if (!fontStack.some((f) => f.hasGlyphForCodePoint(cp))) out.add(cp); + } + return [...out]; + } + + return { measureWidth, wrapLines, missing }; +} + +let defaultMeasurer: Measurer | null = null; + +/** The render core installs one measurer (after preloading font bytes); primitives read it. */ +export function setMeasurer(m: Measurer | null): void { + defaultMeasurer = m; +} + +export function getMeasurer(): Measurer { + if (!defaultMeasurer) throw new Error("No text measurer installed; call setMeasurer(createMeasurer(...)) first"); + return defaultMeasurer; +} + +/** The text as it will be drawn: caps applied. */ +export const drawnText = (token: TypeToken, text: string) => (token.caps ? text.toUpperCase() : text); + +/** Width of `text` set in a role/token at an optional size override, tracking and caps included. */ +export function measureToken( + m: Measurer, + role: RoleName | TypeToken, + text: string, + opts: { size?: number; weight?: number; italic?: boolean; family?: string } = {}, +): number { + const t = roleToken(role); + const size = opts.size ?? t.size; + return m.measureWidth({ + text: drawnText(t, text), + face: opts.family ?? resolveFace(t.face), + weight: opts.weight ?? t.weight, + italic: opts.italic ?? t.italic ?? false, + size, + letterSpacing: (t.tracking ?? 0) * size, + }); +} diff --git a/src/pdf/fonts/register.ts b/src/pdf/fonts/register.ts index 67cc9bb..28f223f 100644 --- a/src/pdf/fonts/register.ts +++ b/src/pdf/fonts/register.ts @@ -1,17 +1,8 @@ import { Font } from "@react-pdf/renderer"; import { FONT_MANIFEST } from "./manifest"; +import { familySources } from "./stack"; -/** Every stack ends here: Latin and the rupee sign from Plex, then the Indic scripts. */ -export const FONT_STACK: readonly string[] = [ - "IBM Plex Sans", - "Noto Sans Devanagari", - "Noto Sans Kannada", -]; - -/** `[primary, ...tail]` without duplicates, for a style's fontFamily. */ -export function stack(primary: string): string[] { - return [primary, ...FONT_STACK.filter((f) => f !== primary)]; -} +export { FONT_STACK, familySources, stack, type FamilySource } from "./stack"; let registered = false; @@ -28,23 +19,11 @@ export function registerBundledFonts(base: string): void { registered = true; for (const fam of FONT_MANIFEST.families) { - const sources: { src: string; fontWeight: number; fontStyle: "normal" | "italic" }[] = []; - const weights = new Set(); - for (const font of fam.fonts) { - weights.add(font.weight); - sources.push({ - src: `${base}/${fam.folder}/${font.file}`, - fontWeight: font.weight, - fontStyle: font.style, - }); - } - // react-pdf resolves every family in a fallback stack with the node's own style and throws for - // a family that has no italic, so any weight without a real italic reuses its upright file. - for (const weight of weights) { - if (sources.some((s) => s.fontWeight === weight && s.fontStyle === "italic")) continue; - const upright = sources.find((s) => s.fontWeight === weight && s.fontStyle === "normal"); - if (upright) sources.push({ ...upright, fontStyle: "italic" }); - } + const sources = familySources(fam).map((s) => ({ + src: `${base}/${fam.folder}/${s.file}`, + fontWeight: s.fontWeight, + fontStyle: s.fontStyle, + })); Font.register({ family: fam.family, fonts: sources }); } diff --git a/src/pdf/fonts/roles.ts b/src/pdf/fonts/roles.ts new file mode 100644 index 0000000..79ba8fb --- /dev/null +++ b/src/pdf/fonts/roles.ts @@ -0,0 +1,65 @@ +/** + * Typography roles. `leading` is an absolute line height in pt: Txt turns it into `lineHeight: "Npt"` + * on the same node as fontSize. Roles used by render-prop text (page numbers) have no leading, because + * react-pdf multiplies a render-prop lineHeight again on every relayout. + * + * These values are the Classic template's; other templates will pass their own token objects. + */ +export type FaceKey = "IBM Plex Sans" | "IBM Plex Mono"; + +export interface TypeToken { + face: FaceKey; + weight: 300 | 400 | 500 | 600 | 700 | 800; + italic?: boolean; + size: number; + /** pt, absolute. Optional only for tokens used by RenderTxt. */ + leading?: number; + /** em; applied as letterSpacing = tracking * size. */ + tracking?: number; + caps?: boolean; + /** Intent only: textkit exposes no OpenType feature switch, so this does not change rendering yet. */ + tnum?: boolean; + /** Smallest size IdText/FitText may shrink to. */ + minSize?: number; +} + +const SANS: FaceKey = "IBM Plex Sans"; + +export const TYPE_ROLES = { + display: { face: SANS, weight: 600, size: 26, leading: 30 }, + title: { face: SANS, weight: 600, size: 18, leading: 22, tracking: 1.5 / 18, caps: true }, + h1: { face: SANS, weight: 600, size: 18, leading: 22 }, + h2: { face: SANS, weight: 600, size: 13, leading: 17 }, + body: { face: SANS, weight: 400, size: 9.5, leading: 13.8 }, + bodyStrong: { face: SANS, weight: 600, size: 9.5, leading: 13.8 }, + small: { face: SANS, weight: 400, size: 8.5, leading: 12 }, + fine: { face: SANS, weight: 400, size: 7.5, leading: 10.5 }, + tableHead: { face: SANS, weight: 400, size: 7.5, leading: 10, tracking: 0.8 / 7.5, caps: true }, + tableBody: { face: SANS, weight: 400, size: 9.5, leading: 13.8 }, + money: { face: SANS, weight: 400, size: 9.5, leading: 13.8, tnum: true }, + moneyStrong: { face: SANS, weight: 600, size: 9.5, leading: 13.8, tnum: true }, + footer: { face: SANS, weight: 400, size: 7.5 }, + label: { face: SANS, weight: 400, size: 7.5, leading: 10, tracking: 1 / 7.5, caps: true }, +} as const satisfies Record; + +export type RoleName = keyof typeof TYPE_ROLES; + +/** + * Licensed template faces (Now, Gotham, Open Sauce One, ...) map to the OFL look-alike that is + * actually bundled. Empty until those families are registered. + */ +export const SUBSTITUTES: Readonly> = {}; + +/** The family name to register/look up for a face: user override first, then substitute, then itself. */ +export function resolveFace(face: string, overrides?: Readonly>): string { + return overrides?.[face] ?? SUBSTITUTES[face] ?? face; +} + +export function roleToken(role: RoleName | TypeToken): TypeToken { + return typeof role === "string" ? TYPE_ROLES[role] : role; +} + +/** Smallest shrink size for a token: its minSize, else 0.9x the size but never below 7 pt (or the size itself). */ +export function minSizeOf(token: TypeToken): number { + return token.minSize ?? Math.min(token.size, Math.max(7, token.size * 0.9)); +} diff --git a/src/pdf/fonts/stack.ts b/src/pdf/fonts/stack.ts new file mode 100644 index 0000000..3cbc66f --- /dev/null +++ b/src/pdf/fonts/stack.ts @@ -0,0 +1,34 @@ +import type { FontFamilyEntry } from "./manifest"; + +/** Every stack ends here: Latin and the rupee sign from Plex, then the Indic scripts. */ +export const FONT_STACK: readonly string[] = [ + "IBM Plex Sans", + "Noto Sans Devanagari", + "Noto Sans Kannada", +]; + +/** `[primary, ...tail]` without duplicates, for a style's fontFamily. */ +export function stack(primary: string): string[] { + return [primary, ...FONT_STACK.filter((f) => f !== primary)]; +} + +export interface FamilySource { + file: string; + fontWeight: number; + fontStyle: "normal" | "italic"; +} + +/** + * The sources registered for a family: every manifest file, plus the upright file reused as italic for + * any weight without a real italic. react-pdf resolves every family in a fallback stack with the node's + * own style and throws for a family that has no italic. The measurer reads the same list. + */ +export function familySources(fam: FontFamilyEntry): FamilySource[] { + const sources: FamilySource[] = fam.fonts.map((f) => ({ file: f.file, fontWeight: f.weight, fontStyle: f.style })); + for (const weight of new Set(sources.map((s) => s.fontWeight))) { + if (sources.some((s) => s.fontWeight === weight && s.fontStyle === "italic")) continue; + const upright = sources.find((s) => s.fontWeight === weight && s.fontStyle === "normal"); + if (upright) sources.push({ ...upright, fontStyle: "italic" }); + } + return sources; +} diff --git a/src/pdf/lint.test.ts b/src/pdf/lint.test.ts new file mode 100644 index 0000000..a241c2e --- /dev/null +++ b/src/pdf/lint.test.ts @@ -0,0 +1,80 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { formatViolation, lintSource } from "./testing/layoutLint"; + +const ROOT = process.cwd(); +const PDF_DIR = path.join(ROOT, "src/pdf"); + +// TODO: remove this exclusion when the legacy InvoiceDocument.tsx is deleted (it predates the primitives). +const EXCLUDED = new Set(["src/pdf/InvoiceDocument.tsx"]); + +function sources(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const full = path.join(dir, name); + if (statSync(full).isDirectory()) return sources(full); + const rel = path.relative(ROOT, full).split(path.sep).join("/"); + if (!/\.(ts|tsx)$/.test(name) || /\.test\.|\.d\.ts$|\.generated\./.test(name) || EXCLUDED.has(rel)) return []; + return [rel]; + }); +} + +describe("react-pdf layout rules (src/pdf)", () => { + it("scans a non-trivial set of files", () => { + expect(sources(PDF_DIR).length).toBeGreaterThan(10); + }); + + it("has no violations", () => { + const violations = sources(PDF_DIR).flatMap((f) => lintSource(f, readFileSync(path.join(ROOT, f), "utf8"))); + expect(violations.map(formatViolation)).toEqual([]); + }); +}); + +describe("lint rules detect what they should", () => { + const rules = (file: string, code: string) => lintSource(file, code).map((v) => v.rule); + + it("line-height", () => { + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { lineHeight: 1.2 };")).toEqual(["line-height"]); + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { 'lineHeight': '10pt' };")).toEqual(["line-height"]); + expect(rules("src/pdf/blocks/Foo.tsx", "const lineHeight = 1; const s = { lineHeight };")).toEqual(["line-height"]); + expect(rules("src/pdf/blocks/Txt.tsx", "const s = { lineHeight: '10pt' };")).toEqual([]); + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { leading: 10 };")).toEqual([]); + }); + + it("pdf flags", () => { + expect(rules("src/pdf/blocks/Foo.tsx", "const a = ;")).toEqual(["pdf-flag"]); + expect(rules("src/pdf/blocks/Foo.tsx", "const a = ;")).toEqual(["pdf-flag"]); + expect(rules("src/pdf/blocks/Foo.tsx", "const a = ;")).toEqual(["pdf-flag"]); + expect(rules("src/pdf/blocks/Foo.tsx", "const a = ;")).toEqual(["pdf-flag"]); + expect(rules("src/pdf/blocks/Foo.tsx", "const a = ;")).toEqual(["pdf-flag"]); + expect(rules("src/pdf/blocks/Foo.tsx", "const a = ;")).toEqual([]); + expect(rules("src/pdf/blocks/Foo.tsx", "const a = ;")).toEqual([]); + expect(rules("src/pdf/blocks/Foo.tsx", "const a = ;")).toEqual([]); + }); + + it("flex grow", () => { + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { flexGrow: 1 };")).toEqual(["flex-grow"]); + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { flexGrow: g };")).toEqual(["flex-grow"]); + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { flexGrow: 1, flexBasis: 0 };")).toEqual([]); + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { flexGrow: 1, flex: 1 };")).toEqual([]); + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { flexGrow: 0 };")).toEqual([]); + }); + + it("absolute", () => { + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { position: 'absolute' };")).toEqual(["absolute"]); + expect(rules("src/pdf/decor/Band.tsx", "const s = { position: 'absolute' };")).toEqual([]); + expect(rules("src/pdf/blocks/PageChrome.tsx", "const s = { position: 'absolute' };")).toEqual([]); + expect(rules("src/pdf/blocks/FirstPageHeader.tsx", "const s = { position: 'absolute' };")).toEqual([]); + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { position: 'relative' };")).toEqual([]); + }); + + it("align flex-end", () => { + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { alignItems: 'flex-end' };")).toEqual(["align-flex-end"]); + expect(rules("src/pdf/blocks/Foo.tsx", "const s = { alignItems: 'flex-start' };")).toEqual([]); + }); + + it("reports file:line", () => { + const [v] = lintSource("src/pdf/blocks/Foo.tsx", "\n\nconst s = { alignItems: 'flex-end' };"); + expect(formatViolation(v)).toMatch(/^src\/pdf\/blocks\/Foo\.tsx:3 \[align-flex-end\]/); + }); +}); diff --git a/src/pdf/testing/asserts.ts b/src/pdf/testing/asserts.ts new file mode 100644 index 0000000..d85fa91 --- /dev/null +++ b/src/pdf/testing/asserts.ts @@ -0,0 +1,10 @@ +import { expect } from "vitest"; +import { describeBox, findOverlaps, type PageText } from "./pdfText"; + +/** Fails (listing every colliding pair) when any two text boxes on any page overlap. */ +export function assertNoOverlap(pages: PageText[]): void { + pages.forEach((page, idx) => { + const overlaps = findOverlaps(page).map(([a, b]) => `${describeBox(a)} x ${describeBox(b)}`); + expect(overlaps, `page ${idx + 1} overlaps`).toEqual([]); + }); +} diff --git a/src/pdf/testing/layoutLint.ts b/src/pdf/testing/layoutLint.ts new file mode 100644 index 0000000..24e2d91 --- /dev/null +++ b/src/pdf/testing/layoutLint.ts @@ -0,0 +1,103 @@ +import ts from "typescript"; + +export interface LintViolation { + file: string; + line: number; + rule: string; + message: string; +} + +/** Paths (relative to the repo root, posix) where each otherwise-banned construct may appear. */ +export const LINT_ALLOW = { + leadingProp: ["src/pdf/blocks/Txt.tsx", "src/pdf/fonts/roles.ts"], + absolute: ["src/pdf/decor/", "src/pdf/blocks/PageChrome.tsx", "src/pdf/blocks/FirstPageHeader.tsx"], + rawFlags: ["src/pdf/blocks/pdfProps.ts"], +}; + +const FLAG_PROPS = new Set(["fixed", "wrap", "minPresenceAhead"]); + +const propName = (n: ts.PropertyName): string | undefined => + ts.isIdentifier(n) || ts.isStringLiteral(n) ? n.text : undefined; + +const stringValue = (e: ts.Expression): string | undefined => + ts.isStringLiteralLike(e) ? e.text : undefined; + +const allowed = (file: string, list: string[]) => list.some((p) => (p.endsWith("/") ? file.startsWith(p) : file === p)); + +/** + * Static layout-rule checks for react-pdf code. `file` is the repo-relative posix path. + * + * Not checked: a pinned numeric `height` on an element that holds text. That cannot be decided from + * the syntax (the text may be several components away), so it stays a review rule: textkit cuts + * lines at the box height and appends "…" even without maxLines. Use minHeight for text containers. + */ +export function lintSource(file: string, text: string): LintViolation[] { + const sf = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, file.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS); + const out: LintViolation[] = []; + const lineOf = (n: ts.Node) => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1; + const add = (n: ts.Node, rule: string, message: string) => out.push({ file, line: lineOf(n), rule, message }); + const usesPdfFlags = /\bpdfFlags\b/.test(text); + + const visit = (node: ts.Node) => { + if (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node)) { + const name = ts.isPropertyAssignment(node) ? propName(node.name) : node.name.text; + + if (name === "lineHeight" && !allowed(file, LINT_ALLOW.leadingProp)) { + add(node, "line-height", "lineHeight is set only by Txt and the roles; use a Txt role (an ancestor lineHeight breaks render-prop text)"); + } + if (ts.isPropertyAssignment(node)) { + const v = stringValue(node.initializer); + if (name === "position" && v === "absolute" && !allowed(file, LINT_ALLOW.absolute)) { + add(node, "absolute", "position: 'absolute' is allowed only in src/pdf/decor/**, PageChrome and FirstPageHeader"); + } + if (name === "alignItems" && v === "flex-end") { + add(node, "align-flex-end", "alignItems: 'flex-end' misplaces multi-line text; use flex-start or align inside the cell"); + } + } + } + + if (ts.isObjectLiteralExpression(node)) { + const props = new Map(); + for (const p of node.properties) { + if (ts.isPropertyAssignment(p)) { + const n = propName(p.name); + if (n) props.set(n, p); + } + } + const grow = props.get("flexGrow"); + if (grow) { + const init = grow.initializer; + // A non-literal value may be positive, so it counts. + const positive = ts.isNumericLiteral(init) ? Number(init.text) > 0 : true; + if (positive && !props.has("flexBasis") && !props.has("flex")) { + add(grow, "flex-grow", "flexGrow > 0 needs flexBasis: 0 (or flex:); use FILL from engine/columns"); + } + } + } + + if (ts.isJsxAttribute(node)) { + const name = ts.isIdentifier(node.name) ? node.name.text : undefined; + if (name === "lineHeight" && !allowed(file, LINT_ALLOW.leadingProp)) { + add(node, "line-height", "lineHeight is set only by Txt and the roles"); + } + if (name && FLAG_PROPS.has(name) && !allowed(file, LINT_ALLOW.rawFlags)) { + const init = node.initializer; + const expr = init && ts.isJsxExpression(init) ? init.expression : undefined; + let ok: boolean; + if (!init) ok = name === "fixed"; // bare `fixed` + else if (!expr) ok = false; + else if (name === "fixed") ok = expr.kind === ts.SyntaxKind.TrueKeyword; + else if (name === "wrap") ok = expr.kind === ts.SyntaxKind.FalseKeyword; + else ok = ts.isNumericLiteral(expr) && Number(expr.text) > 0; + if (!ok && !usesPdfFlags) { + add(node, "pdf-flag", `${name} is checked by key presence in react-pdf; pass only a literal (${name === "fixed" ? "true" : name === "wrap" ? "false" : "a positive number"}) or spread pdfFlags()`); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return out; +} + +export const formatViolation = (v: LintViolation) => `${v.file}:${v.line} [${v.rule}] ${v.message}`; diff --git a/src/pdf/testing/setup.ts b/src/pdf/testing/setup.ts new file mode 100644 index 0000000..6d1b4ec --- /dev/null +++ b/src/pdf/testing/setup.ts @@ -0,0 +1,14 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { createMeasurer, setMeasurer, type Measurer } from "../engine/measure"; +import { registerBundledFonts } from "../fonts/register"; + +export const FONTS_DIR = path.resolve(process.cwd(), "public/fonts"); + +/** Registers the bundled fonts for react-pdf and installs a Node-backed measurer. */ +export function setupPdfTest(): Measurer { + registerBundledFonts(FONTS_DIR); + const m = createMeasurer((file) => new Uint8Array(readFileSync(path.join(FONTS_DIR, file)))); + setMeasurer(m); + return m; +}