Fix glyph cache poisoning across renders; add CLAUDE.md and handoff.md
fontkit caches glyphs per font for the whole process and pdfkit embeds composite components with no code points, so setting ':' before '.' in Poppins corrupted every later render in the same process (text layer showed '1,200;00', line breaks shifted). primeFontGlyphs() now creates the component glyphs with their cmap code point at font registration, awaited by renderCore and browser init. Regression test and Quirk 13 added; the warm-up workarounds in the template tests are removed.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Voiced is an offline-first GST invoicing desktop app (Tauri v2, React 19, IBM Carbon, `@react-pdf/renderer` 4.9.0, SQLite via rusqlite) built by Bongbetic for vendor Arun P. Targets: Void Linux (xbps, WebKitGTK 2.50) and Windows (NSIS, WebView2). `handoff.md` holds the current project state and next steps; the approved plan is `/home/soubarna/.claude/plans/take-a-look-at-logical-sunbeam.md`. The README's "Project layout" section is out of date; trust this file and the code.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run build # tsc --noEmit && vite build (the type-check gate)
|
||||
npm test # vitest run, ~7 min; run on a quiet machine (load starves the sweep tests)
|
||||
npx vitest run src/pdf/templates/monolith # one folder
|
||||
npx vitest run path/to/file.test.tsx -t "name" # one test
|
||||
npm run fonts:verify # bundled fonts match public/fonts/manifest.json (regenerate with fonts:manifest)
|
||||
npm run logo:measure # regenerate src/pdf/templates/slots.generated.ts from the Canva PDFs; must be idempotent
|
||||
npm run thumbnails # regenerate public/templates/*.png (unchanged templates must stay byte-identical)
|
||||
npm run templates:compare # Canva vs Voiced side-by-side PNGs (scripts/templates/compare.mjs, output under /tmp)
|
||||
cargo test --manifest-path src-tauri/Cargo.toml # Rust unit tests (gst, db migrations, logo, commands)
|
||||
npm run app:dev # tauri dev (served from localhost:1420; does NOT exercise the tauri:// protocol)
|
||||
npm run app:build # production build, custom-protocol
|
||||
npm run app:build:windows # NSIS via cargo-xwin cross-compile (see README)
|
||||
./packaging/void/build-xbps.sh # Void .xbps into build/void/repo
|
||||
```
|
||||
|
||||
Real-webview self-test (the only check that runs the production build): build with `npm run tauri -- build --debug --no-bundle --features custom-protocol`, then `bash /tmp/run-selftest.sh`. The app is inert unless `VOICED_SELFTEST_OUT` is set; the script runs the debug binary with temp XDG dirs and the report lands in `/tmp/voiced-e2e/report.json` (`ok: true` expected). `/tmp` is not durable: if the script is gone, recreate it with that env var, `XDG_DATA_HOME`/`XDG_CONFIG_HOME` temp dirs and a `timeout 180`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Rust backend (`src-tauri/src`)
|
||||
- `db.rs` owns the schema as numbered migrations (rusqlite_migration, M1–M5); it copies the DB aside before migrating. Add schema changes as new migrations, never edit old ones. Issued invoices carry snapshots (vendor, client, GST, layout fingerprint) and are immutable.
|
||||
- `gst.rs` does all money and tax maths in integer paise. Tax heads (CGST+SGST vs IGST vs UTGST) derive from (supplier state, place of supply); a mismatch with the chosen head is rejected. An unregistered vendor gets a plain "Invoice" with no tax. GSTIN is checked with the mod-36 checksum.
|
||||
- `logo.rs` trims, quantises and measures logos (aspect, density, wordmark/mark/tall, knockout variant for dark surfaces).
|
||||
- `commands/*.rs` are the Tauri commands; `raw.rs` carries binary payloads over raw-body IPC (ASCII headers, ArrayBuffer response). `archive.rs` stores each issued PDF content-addressed under `app_local_data_dir/archive/<sha256>.pdf`.
|
||||
- `selftest.rs` is the unattended end-to-end hook described above.
|
||||
|
||||
### Frontend (`src`)
|
||||
`views/` and `components/` are the Carbon UI; `lib/api.ts` is the typed wrapper over the Tauri commands; `lib/exportFlow.ts` and `hooks/useInvoiceExport` drive export (searchable or flattened) and archiving.
|
||||
|
||||
### PDF engine (`src/pdf`) — the part that needs several files to understand
|
||||
Pipeline: `model/build` (`buildRenderModel`) → `engine/geometry` (`computeFrame`: A4/Letter plus margin presets) → a template `Layout` → `InvoicePdf`/`InvoicePage` → `render/core` (`renderCore`: bytes, normalized layout tree, `auditLayout` issues, layout fingerprint). Rendering runs in a module Web Worker through a latest-wins `render/client` (FIFO assemble lane, watchdog, main-thread fallback). Preview uses pdf.js; flatten rasterises pages at 300 DPI and re-assembles them as an image-only PDF.
|
||||
|
||||
- **Templates** live in `templates/<family>/` and are registered in `templates/registry.ts` (picker order) and `catalog.ts` (labels, thumbnails). Each has `tokens.ts`, `plan.ts`, `Layout.tsx` and a test. They implement `templates/contract.ts`. 14 exist: Classic plus the 13 Canva templates, specified in `docs/templates/*.md` (measured from the Canva PDFs in `templates/`, which is untracked on purpose: never stage `templates/` or `.commandcode/`).
|
||||
- **Logo placement** is slot-based (`engine/logo.ts`, `templates/slots.*`): the user's logo fills the Canva placeholder box, contained, with a shrink-only weight factor. Template placeholder logos are never reproduced. Some templates use opt-in slots (`slots.optin.ts`), default off.
|
||||
- **Tests that guard layout**: template harness and sweep tests (`harness*.ts`, `harness.sweep*.test.tsx`: 1–45 rows, long text, A4/Letter, no overlap), layout lint, fingerprint goldens (`goldens.test.ts`) and `quirks.test.tsx`. The Node goldens must equal the fingerprints the real webview reports.
|
||||
- **Glyph priming**: fontkit caches glyphs process-wide and pdfkit embeds composite components with no code points, which corrupted later renders (":" before "." gave "₹1,200;00"). `primeFontGlyphs()` in `fonts/register.ts` fixes it and runs in `renderCore` and browser init. Do not widen it to every cmap glyph (breaks Inter's contextual hyphen); it uses fontkit private APIs, so re-check on fontkit upgrades.
|
||||
- **Fonts**: TTF only, bundled in `public/fonts/<family>/` with a generated manifest; every family needs normal and italic sources. WOFF2 embeds blank glyphs. Roles/type tokens are in `fonts/roles.ts`.
|
||||
|
||||
### react-pdf 4.9.0 rules (all pinned by `quirks.test.tsx`; break them and layouts hang or overlap)
|
||||
- Text lines are cached at first measure, so give flex children pinned widths (`width=minWidth=maxWidth`) with a `{flexGrow:1, flexBasis:0}` fill child.
|
||||
- No `lineHeight` on Page/View/render-prop ancestors; set an absolute `'Npt'` lineHeight on each static `Text`. Hyphenation is off.
|
||||
- `fixed`, `wrap`, `minPresenceAhead` are key-presence checks: spread them only when meaningful (`blocks/pdfProps`).
|
||||
- A non-fixed page-level absolute `Svg`/`Image` hangs pagination. Render props must return `Text`/`View` only.
|
||||
- `maxLines` does not clip a single over-long word (use `FitText`). Trailing `letterSpacing` needs `Tracked` compensation.
|
||||
- A fixed table header as the first child of the table `View` repeats on each page; use `TableGuard`/`BottomSpacer` with `minPresenceAhead`.
|
||||
|
||||
## Conventions
|
||||
- Commit only after the gates pass (`npm run build`, full `npm test`, `fonts:verify`, and the self-test for engine/template changes). Work is on stacked phase branches (`phase-0-hotfix` → `phase-a-foundation` → `phase-b-engine` → `phase-c-templates`); create the next phase branch from the previous one.
|
||||
- Windows/WebView2 has never been run on a real machine; the app window UI has only been exercised headless.
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
# Voiced: handoff
|
||||
|
||||
Written 2026-10-04. Voiced is the invoicing app built by Bongbetic for vendor Arun P (Tauri v2, React 19, IBM Carbon, @react-pdf/renderer 4.9.0, SQLite via rusqlite).
|
||||
|
||||
## Where things are
|
||||
|
||||
| Item | Location |
|
||||
|---|---|
|
||||
| Repository (execute from here) | `/media/Toto/Documents/bongbetic/invoice` |
|
||||
| Approved plan | `/home/soubarna/.claude/plans/take-a-look-at-logical-sunbeam.md` |
|
||||
| Working branch | `phase-c-templates` (remote `origin`, git.bongbetic.com/xavierk/Voiced) |
|
||||
| Template specs (13, measured from Canva PDFs) | `docs/templates/` (`README.md` plus `01`–`13`) |
|
||||
| Canva source PDFs (never commit) | `templates/Modern Neutral Invoice Template/1.pdf`–`13.pdf` |
|
||||
| Bongbetic logos | `/media/Toto/Documents/bongbetic/Logo/bongbetic-brand/` |
|
||||
| Full prior conversation | `/home/soubarna/.claude/projects/-media-Toto-Documents-bongbetic-invoice/1cd7ecdc-a943-4728-bbf5-efb12a36b04e.jsonl` |
|
||||
|
||||
**Continue all work from `/media/Toto/Documents/bongbetic/invoice`, on branch `phase-c-templates`.** Read the plan first. It holds the phase breakdown, quirk list, GST rules, ERPNext design, risks and the Verification section. Create new phase branches from the previous one (the existing branches are stacked: `phase-0-hotfix` → `phase-a-foundation` → `phase-b-engine` → `phase-c-templates`). Never stage `templates/` or `.commandcode/`.
|
||||
|
||||
## Progress
|
||||
|
||||
The plan totals about 108 developer-days (0: 1.5, A: 10.5, B: 33, C: 34, D: 5, E: 12, F: 10, G: 2). Phases 0, A, B and C hold about 79 of those days of scope, roughly 73 %. D to G remain, about 29 days of scope.
|
||||
|
||||
| Phase | Scope | State |
|
||||
|---|---|---|
|
||||
| 0 | Header-overlap and logo hotfix | Done, pushed (`phase-0-hotfix`) |
|
||||
| A | DB migrations M1–M5, issue/cancel lifecycle, GST derivation with integer-paise maths, drafts, shortcuts | Done, pushed (`phase-a-foundation`) |
|
||||
| B | Render engine (worker, pdf.js preview, searchable and flattened export, archive store, history re-export, unattended self-test) | Done, pushed (`phase-b-engine`) |
|
||||
| C | Logo pipeline and Branding settings, template picker, Classic plus all 13 Canva templates, glyph-cache engine fix | Done. Templates pushed through `5795087`; the glyph fix is in the commit that follows it |
|
||||
| D | Font import for paid fonts, Void font packaging | Not started |
|
||||
| E | Workflow QoL | Not started |
|
||||
| F | ERPNext integration | Not started |
|
||||
| G | Release | Not started |
|
||||
|
||||
Templates implemented: Classic, Neutral, Ledger (family), Band (family), Northline family (Purple Pop, Citrus Split), and the standalone Monolith, Cobalt Stripe, Marble and Highlighter. That is 14 in total. Each has tests, an "Implementation notes" section in its spec, and a thumbnail in `public/templates/`.
|
||||
|
||||
Other gates last run at `5795087`: `npm run fonts:verify` OK (11 families, 33 fonts); `npm run logo:measure` idempotent.
|
||||
|
||||
## Glyph-cache fix (done)
|
||||
|
||||
fontkit caches glyph objects per font for the whole process, and pdfkit creates a composite glyph's components at embed time with empty code points. Poppins Regular's ":" contains ".", so setting ":" before any "." corrupted the period for every later render in that process (amounts extracted as "₹1,200;00", some line breaks shifted). The render worker lives for the whole app session, so the app was affected, not only the tests. Every bundled face has composite glyphs, not just Poppins.
|
||||
|
||||
Fix: `primeFontGlyphs()` in `src/pdf/fonts/register.ts` loads every registered face and creates, with their cmap code point, only the glyphs that composites use as components (private-use code points skipped, lowest code point wins). It is memoised and awaited in `renderCore` (`src/pdf/render/core.ts`) and in `initBrowserRendering` (`src/pdf/render/browserInit.ts`). A first attempt that primed every cmap glyph broke Inter's contextual hyphen (the hyphen in `AP/2026-001` vanished from the text layer), so do not widen it. It uses fontkit private APIs (`_getBaseGlyph`, `_decode`, `_cmapProcessor`, `_glyphs`): re-check it on any fontkit upgrade.
|
||||
|
||||
Cost: about 620 ms once per process under Node. Per-render cost is unchanged. Worker init time was not measured separately; the whole real-webview self-test took 3.4 s.
|
||||
|
||||
Regression coverage: `src/pdf/fonts/glyphPriming.test.tsx` (new) and Quirk 13 in `src/pdf/quirks.test.tsx`. The warm-up workarounds were removed from the Highlighter test and the standalone sweep. Monolith-then-Highlighter and Highlighter-then-Monolith now give identical layout fingerprints. No golden changed.
|
||||
|
||||
Last verified gates (with the fix, 2026-10-04): `npm run build` OK; `npm test` 46 files, 1539 passed, 6 skipped, 385 s; real-webview self-test `ok: true` with both fingerprint-vs-golden checks passing.
|
||||
|
||||
## Next steps, in order
|
||||
|
||||
1. Small template fixes from the C6a/C6b reports:
|
||||
- Monolith and Cobalt Stripe: when only the closing block moves to page 2, the page-1 squeeze can fall a few points short. Marble and Highlighter already have the fix.
|
||||
- Monolith: the flow notes block can collapse to zero-height lines when it starts with less than a line of room. Marble guards against this.
|
||||
2. Phase D:
|
||||
- Font import for Now, Gotham and Open Sauce overrides: a `user_fonts` store, with WOFF2 and variable fonts refused.
|
||||
- Void xbps font install to `/usr/share/fonts/voiced` with licences, `font_dirs` plus INSTALL/REMOVE scripts that run `fc-cache`. Verify that `xbps-create` honours INSTALL/REMOVE.
|
||||
- Final visual review of all 14 templates against Canva.
|
||||
3. Phase E: clients page (structured addresses, GST category), item presets, invoice detail view, History DataTable with filters, payments with TDS, settings tabs and toasts, backup/restore (db, assets, archive, fonts, with a round-trip test), CSV and JSON export.
|
||||
4. Phase F: ERPNext (Rust `integrations/erpnext`, reqwest 0.13.5 with rustls ring, config UI, push as draft or submit, PDF attachment, idempotency, payment entries, `voiced.invoice.v1` JSON export). End-to-end test with podman frappe_docker, with and without India Compliance.
|
||||
5. Phase G: README (data paths, fonts and licences, ERPNext recipe), version bump, xbps template refresh, NSIS build within the size budget, self-test on both installed builds.
|
||||
|
||||
## Open decisions and manual checks
|
||||
|
||||
- Canva licence for re-creating the templates is unconfirmed. Ship only in this vendor's build until it is.
|
||||
- Wordmark replaces the whole logo lockup (a toggle is planned).
|
||||
- A CA should confirm the SAC and signature rules.
|
||||
- Open Sauce One is not bundled (Poppins 800 stands in). Plex and Noto are installed as identical duplicates of Void's packages.
|
||||
- Picker family label: Monolith, Cobalt Stripe, Marble and Highlighter show as "Standalone". Change if wanted.
|
||||
- Monolith shows the supplier's full address only on the last page of a multi-page invoice (as in Canva). Say if page 1 should carry it too.
|
||||
- Not yet checked: whether the app surfaces layout audit errors to the user.
|
||||
- Never tested: Windows/WebView2 (no Windows machine); the app window UI has only been exercised headless, never opened visually (Branding UI and template picker need a look); the B4/B5 manual checklists on an installed build.
|
||||
- The P2 extras are deferred (copies, watermarks, PDF/A, accent override, print-friendly mode, Wide/Custom margins, user textures, SVG logo import, PNG flatten, UTGST labels beyond the supplier-state rule, CESS, composition and SEZ).
|
||||
|
||||
## How to run things
|
||||
|
||||
- Build: `npm run build`. Tests: `npm test` (about 7 minutes; run on a quiet machine, since load can starve the sweep tests).
|
||||
- Fonts: `npm run fonts:verify`. Logo placeholders: `npm run logo:measure` (must leave `slots.generated.ts` unchanged on re-run). Thumbnails: `npm run thumbnails`.
|
||||
- Template fidelity: `scripts/templates/compare.mjs` renders Canva and Voiced side by side (default output `/tmp/invoice-c6b`).
|
||||
- Real-webview self-test: build with `npm run tauri -- build --debug --no-bundle --features custom-protocol`, then run `bash /tmp/run-selftest.sh`. The report goes to `/tmp/voiced-e2e/report.json`. If `/tmp/run-selftest.sh` is gone, recreate it from the self-test notes in the plan (it launches the built binary with the selftest config and waits for the report).
|
||||
- Void package: `packaging/void/build-xbps.sh`. Windows NSIS: cross-compile with cargo-xwin (see the earlier commit "Document the Linux -> Windows NSIS cross-compile setup").
|
||||
|
||||
## Working conventions
|
||||
|
||||
- Chat uses caveman mode. Commits, docs and this file are normal English.
|
||||
- Explore with the `code-explorer` agent, implement with `code-runner`. Use `podman`, not docker. Use `npx ctx7@latest` for library docs.
|
||||
- Commit locally after the gates pass, then push the feature branch. Do not relaunch an agent the user stopped without asking.
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Rendering must not depend on earlier renders in the same process (src/pdf/quirks.test.tsx, quirk 13).
|
||||
* This file runs in a fresh module context, so the first document below really is the first one that
|
||||
* touches Poppins Regular.
|
||||
*/
|
||||
import { createElement as h } from "react";
|
||||
import { Font, Text } from "@react-pdf/renderer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { doc } from "../testing/docs";
|
||||
import { parsePdfText } from "../testing/pdfText";
|
||||
import { renderCore } from "../render/core";
|
||||
import { setupPdfTest } from "../testing/setup";
|
||||
import { FONT_MANIFEST } from "./manifest";
|
||||
import { familySources, stack } from "./register";
|
||||
|
||||
setupPdfTest();
|
||||
|
||||
type Glyph = { id: number; codePoints: number[]; components?: Array<{ glyphID: number }> };
|
||||
type FkFont = { characterSet: number[]; glyphForCodePoint(cp: number): Glyph; getGlyph(id: number): Glyph; numGlyphs: number };
|
||||
const fontOf = (family: string, fontWeight: number, fontStyle: string) =>
|
||||
(Font as unknown as { getFont: (d: object) => { data: FkFont } }).getFont({ fontFamily: family, fontWeight, fontStyle }).data;
|
||||
|
||||
const poppins = { fontFamily: stack("Poppins"), fontSize: 10, lineHeight: "12pt", fontWeight: 400 };
|
||||
|
||||
describe("glyph priming", () => {
|
||||
it("a colon set in Poppins Regular before any period does not stop a later amount being extracted as text", async () => {
|
||||
await renderCore(doc(h(Text, { style: poppins }, "Bank : State")));
|
||||
const r = await renderCore(doc(h(Text, { style: poppins }, "₹1,200.00")));
|
||||
const pages = await parsePdfText(r.bytes);
|
||||
const text = pages.pages.flatMap((p) => p.items.map((i) => i.str)).join("");
|
||||
expect(text).toContain("₹1,200.00");
|
||||
});
|
||||
|
||||
it("the period glyph keeps its code point", () => {
|
||||
expect(fontOf("Poppins", 400, "normal").glyphForCodePoint(0x2e).codePoints).toEqual([0x2e]);
|
||||
});
|
||||
|
||||
it("every glyph that a composite uses as a component is created with a code point, in every bundled face", async () => {
|
||||
// Reads fontkit's glyph cache and cmap directly: asking for a glyph through the public API would create it with code points and hide the bug.
|
||||
type Peek = {
|
||||
numGlyphs: number;
|
||||
_glyphs: Record<number, Glyph | undefined>;
|
||||
_cmapProcessor: { lookup(cp: number): number };
|
||||
_getBaseGlyph(id: number): { _decode?(): { components?: Array<{ glyphID: number }> } | null };
|
||||
};
|
||||
const bad: string[] = [];
|
||||
let checked = 0;
|
||||
for (const fam of FONT_MANIFEST.families) {
|
||||
for (const s of familySources(fam)) {
|
||||
await Font.load({ fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle });
|
||||
const font = fontOf(fam.family, s.fontWeight, s.fontStyle);
|
||||
const peek = font as unknown as Peek;
|
||||
const mapped = new Set(font.characterSet.filter((cp) => cp < 0xe000 || cp > 0xf8ff).map((cp) => peek._cmapProcessor.lookup(cp)));
|
||||
const comps = new Set<number>();
|
||||
const cache = peek._glyphs;
|
||||
peek._glyphs = {};
|
||||
for (let id = 0; id < peek.numGlyphs; id++) for (const c of peek._getBaseGlyph(id)._decode?.()?.components ?? []) comps.add(c.glyphID);
|
||||
peek._glyphs = cache;
|
||||
for (const id of comps) {
|
||||
if (!mapped.has(id)) continue;
|
||||
checked++;
|
||||
if ((peek._glyphs[id]?.codePoints.length ?? 0) === 0) bad.push(`${fam.family} ${s.file} component glyph ${id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(checked).toBeGreaterThan(1000);
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,78 @@ export function registerBundledFonts(base: string): void {
|
||||
Font.registerHyphenationCallback((word) => [word]);
|
||||
}
|
||||
|
||||
/** The slice of a fontkit TTF font the priming touches (fontkit's types do not declare it). */
|
||||
interface FkFont {
|
||||
numGlyphs: number;
|
||||
characterSet: number[];
|
||||
glyphForCodePoint(cp: number): unknown;
|
||||
_getBaseGlyph(id: number): { _decode?(): { components?: Array<{ glyphID: number }> } | null };
|
||||
_glyphs: Record<number, unknown>;
|
||||
_cmapProcessor: { lookup(cp: number): number };
|
||||
}
|
||||
|
||||
const isPrivateUse = (cp: number) => cp >= 0xe000 && cp <= 0xf8ff;
|
||||
|
||||
/**
|
||||
* Creates, through the cmap and so with their code point, every glyph that a composite glyph uses as a
|
||||
* component. Not the whole cmap: a glyph that layout substitutes in (Inter's contextual hyphen between digits)
|
||||
* is mapped from a private-use code point, and creating it from there would give it a code point the text
|
||||
* never had. Components that only a private-use code point reaches are left alone.
|
||||
*/
|
||||
function primeComponents(font: FkFont): void {
|
||||
const components = new Set<number>();
|
||||
// _getBaseGlyph caches what it creates, with no code points; scan into a scratch cache so that does not happen.
|
||||
const cache = font._glyphs;
|
||||
font._glyphs = {};
|
||||
try {
|
||||
for (let id = 0; id < font.numGlyphs; id++) {
|
||||
const decoded = font._getBaseGlyph(id)._decode?.();
|
||||
for (const c of decoded?.components ?? []) components.add(c.glyphID);
|
||||
}
|
||||
} finally {
|
||||
font._glyphs = cache;
|
||||
}
|
||||
const lowest = new Map<number, number>();
|
||||
for (const cp of [...font.characterSet].sort((a, b) => a - b)) {
|
||||
if (isPrivateUse(cp)) continue;
|
||||
const id = font._cmapProcessor.lookup(cp);
|
||||
if (components.has(id) && !lowest.has(id)) lowest.set(id, cp);
|
||||
}
|
||||
for (const cp of lowest.values()) font.glyphForCodePoint(cp);
|
||||
}
|
||||
|
||||
let primed: Promise<void> | null = null;
|
||||
|
||||
/**
|
||||
* Loads every registered face and gives the component glyphs of its composites their code points. fontkit
|
||||
* caches glyph objects per font for the whole process, and pdfkit creates a composite's components at embed
|
||||
* time with no code points: Poppins' colon contains the period, so a document that sets ':' before any '.'
|
||||
* would leave the period glyph with codePoints [] for every later document (amounts extracted as "1,200;00",
|
||||
* textkit's line breaks slipping). Creating those glyphs here first makes a render independent of the renders
|
||||
* before it. Memoised; await it before the first render.
|
||||
*/
|
||||
export function primeFontGlyphs(): Promise<void> {
|
||||
if (!registered) return Promise.resolve();
|
||||
if (!primed) {
|
||||
primed = run().catch((err) => {
|
||||
primed = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return primed;
|
||||
|
||||
async function run(): Promise<void> {
|
||||
for (const fam of FONT_MANIFEST.families) {
|
||||
for (const s of familySources(fam)) {
|
||||
const descriptor = { fontFamily: fam.family, fontWeight: s.fontWeight, fontStyle: s.fontStyle };
|
||||
await Font.load(descriptor);
|
||||
const data = (Font as unknown as { getFont(d: object): { data: FkFont | null } }).getFont(descriptor).data;
|
||||
if (data) primeComponents(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_FONT_BYTES = 10 * 1024;
|
||||
|
||||
function hasTrueTypeMagic(b: Uint8Array): boolean {
|
||||
|
||||
+16
-4
@@ -4,7 +4,9 @@
|
||||
* fails and the design rule behind it must be revisited.
|
||||
*/
|
||||
import { createElement as h, type ReactElement, type ReactNode } from "react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { create as createFont } from "fontkit";
|
||||
import { Font, renderToBuffer, Text, View } from "@react-pdf/renderer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { auditLayout } from "./engine/audit";
|
||||
@@ -302,12 +304,22 @@ describe("react-pdf 4.9.0 quirks the engine relies on", () => {
|
||||
r.layout.forEach((p) => expect(byId(p, "label")[0].lines).toHaveLength(0));
|
||||
});
|
||||
});
|
||||
it("13. fontkit caches glyph objects per font for the whole process, and pdfkit creates a composite's component glyphs at embed time with no code points: Poppins' colon makes the period glyph keep codePoints [] (so a later document's '.' is not extracted and textkit's glyph-to-string mapping slips)", async () => {
|
||||
// Must stay the first test that sets Poppins Regular in this file: any earlier '.' in that face would create the period glyph normally.
|
||||
it("13. fontkit caches glyph objects per font for the whole process, and a subset encode (pdfkit's embed) creates a composite's component glyphs with no code points: Poppins' colon makes the period glyph keep codePoints [] unless the glyphs were created first, which primeFontGlyphs does for every registered face", async () => {
|
||||
// The mechanism, on a font object of our own so no earlier render in this file matters.
|
||||
const fresh = createFont(new Uint8Array(readFileSync(path.join(FONTS_DIR, "poppins/Poppins-Regular.ttf")))) as unknown as {
|
||||
glyphForCodePoint(cp: number): { codePoints: number[] };
|
||||
createSubset(): { includeGlyph(g: unknown): void; encode(): unknown };
|
||||
};
|
||||
const subset = fresh.createSubset();
|
||||
subset.includeGlyph(fresh.glyphForCodePoint(0x3a));
|
||||
subset.encode();
|
||||
expect(fresh.glyphForCodePoint(0x2e).codePoints).toEqual([]);
|
||||
|
||||
// The fix: rendering primes the registered faces first, so the colon in the first document leaves the period intact for the next one.
|
||||
const poppins = { fontFamily: stack("Poppins"), fontSize: 10, lineHeight: "12pt", fontWeight: 400 };
|
||||
await render(doc(h(Text, { style: poppins }, "Bank : State")));
|
||||
const data = (Font as unknown as { getFont: (d: object) => { data: { glyphForCodePoint: (cp: number) => { codePoints: number[] } } } }).getFont({ fontFamily: "Poppins", fontWeight: 400, fontStyle: "normal" }).data;
|
||||
expect(data.glyphForCodePoint(0x2e).codePoints).toEqual([]);
|
||||
// The Highlighter and Marble tests and the standalone sweep warm Poppins with a document that has amounts (period and comma) first, for this reason.
|
||||
expect(data.glyphForCodePoint(0x2e).codePoints).toEqual([0x2e]);
|
||||
// Text-layer extraction and every other face: src/pdf/fonts/glyphPriming.test.tsx.
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createMeasurer, setMeasurer } from "../engine/measure";
|
||||
import { FONT_MANIFEST } from "../fonts/manifest";
|
||||
import { preflightFonts, registerBundledFonts } from "../fonts/register";
|
||||
import { preflightFonts, primeFontGlyphs, registerBundledFonts } from "../fonts/register";
|
||||
|
||||
export interface BrowserInitOptions {
|
||||
/** URL prefix of the bundled fonts. Default "/fonts". */
|
||||
@@ -48,6 +48,7 @@ async function run({ base = "/fonts", fetchBytes = defaultFetchBytes }: BrowserI
|
||||
}
|
||||
|
||||
registerBundledFonts(base);
|
||||
await primeFontGlyphs();
|
||||
const known = new Set(FONT_MANIFEST.families.flatMap((f) => f.fonts.map((font) => `${f.folder}/${font.file}`)));
|
||||
setMeasurer(
|
||||
createMeasurer((file) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cloneElement, type ReactElement } from "react";
|
||||
import { auditLayout, type AuditIssue, type AuditOptions } from "../engine/audit";
|
||||
import { layoutFingerprint } from "../engine/fingerprint";
|
||||
import type { PageFrame } from "../engine/geometry";
|
||||
import { primeFontGlyphs } from "../fonts/register";
|
||||
import { normalizeLayout, type LayoutPage, type Rect } from "../engine/layoutTree";
|
||||
|
||||
export { normalizeLayout, type LayoutNode, type LayoutPage, type Rect } from "../engine/layoutTree";
|
||||
@@ -66,6 +67,7 @@ export async function renderCore(
|
||||
},
|
||||
});
|
||||
|
||||
await primeFontGlyphs();
|
||||
const bytes = await bytesOf(wrapped);
|
||||
if (raw === undefined) {
|
||||
throw new Error("renderCore: onRender never delivered a layout tree; the root element must be a <Document> or forward onRender to one");
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { beforeAll, expect, it } from "vitest";
|
||||
import { prefsFor, renderFixture } from "./harness";
|
||||
import { expect, it } from "vitest";
|
||||
import { describeSweep, STANDALONE_SWEEP } from "./harness.sweep";
|
||||
import { TEMPLATES } from "./registry";
|
||||
|
||||
// Monolith, Cobalt Stripe, Marble and Highlighter: the same sweep as the other templates, in a second file so the two run side by side.
|
||||
//
|
||||
// Poppins Regular is warmed with a document that has amounts first. fontkit caches glyph objects per font for the process and
|
||||
// pdfkit creates a composite's components (the period inside Poppins' colon) at embed time with no code points: Monolith, which
|
||||
// sets ':' in Poppins Regular but amounts in Medium, would leave the period unextractable for Highlighter's amounts
|
||||
// (src/pdf/quirks.test.tsx, quirk 13).
|
||||
beforeAll(async () => {
|
||||
await renderFixture({ name: "warm-up", items: 3 }, prefsFor("A4", { templateId: "highlighter" }));
|
||||
});
|
||||
|
||||
describeSweep((id) => STANDALONE_SWEEP.includes(id));
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CLOSING_ID, TABLE_HEAD_ID, TABLE_ROW_PREFIX, TOTALS_ID } from "../../engine/ids";
|
||||
import { walk, type LayoutNode } from "../../engine/layoutTree";
|
||||
import { SLOT_ID } from "../../blocks/slots";
|
||||
@@ -21,15 +21,6 @@ import { BLACK, HIGHLIGHTER_ROLES, HIGHLIGHTER_VARIANT, INDIGO, INK, YELLOW } fr
|
||||
|
||||
ensureSetup();
|
||||
|
||||
// fontkit caches glyph objects per font for the process, and pdfkit creates a composite's components (the period and comma inside
|
||||
// Poppins' colon and semicolon) at embed time with no code points (src/pdf/quirks.test.tsx, quirk 13). A first render without any
|
||||
// period or comma, such as the 3-row sample at 100.00 an hour, then makes a later wrapped client name overflow its box by 3.6 pt
|
||||
// (textkit's glyph-to-string mapping slips). The tests run in a fixed order, so warm Poppins with the harness's default invoice,
|
||||
// whose amounts have both.
|
||||
beforeAll(async () => {
|
||||
await renderFixture({ name: "warm-up", items: 3 }, prefsFor("A4", { templateId: "highlighter" }));
|
||||
});
|
||||
|
||||
const V = HIGHLIGHTER_VARIANT;
|
||||
const slot = OPTIN_SLOTS.highlighter!;
|
||||
type R = Awaited<ReturnType<typeof renderFixture>>;
|
||||
|
||||
Reference in New Issue
Block a user