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
+7 -28
View File
@@ -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<number>();
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 });
}
+65
View File
@@ -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<string, TypeToken>;
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<Record<string, string>> = {};
/** The family name to register/look up for a face: user override first, then substitute, then itself. */
export function resolveFace(face: string, overrides?: Readonly<Record<string, string>>): 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));
}
+34
View File
@@ -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;
}