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:
Vendored
+16
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<MeasureInput, "text">;
|
||||
|
||||
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<string, Font>();
|
||||
|
||||
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<number>();
|
||||
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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user