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:
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user