Skip to content

Registered CJK fonts render dense glyphs#139

Merged
ctate merged 8 commits into
mainfrom
fix/cjk-glyph-budgets
Jul 17, 2026
Merged

Registered CJK fonts render dense glyphs#139
ctate merged 8 commits into
mainfrom
fix/cjk-glyph-budgets

Conversation

@ctate

@ctate ctate commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Fixes the reported issue: registered Japanese fonts rendered everyday kanji as notdef blocks — and restores the font registry's own doctrine that validation is loud and registration-time only.

Root cause, measured: the glyph outline budgets were sized from the bundled Latin face's maxp (128 points / 24 contours). Everyday kanji in Noto Sans JP: 鬱 = 237 points/26 contours, 驚 = 185/22, 籠 = 167/22 — every one failed FontGlyphTooComplex per glyph at render time and fell back to blocks.

  • Budgets sized from real CJK faces: maxp read from six production faces (Noto Sans JP/SC/TC/KR, Noto Serif JP, Yuji Mai — table documented at the constants; densest measured: 738 points, 87 contours) → max_glyph_points 1024, max_glyph_contours 128, with the composite budgets unchanged (measured worst is the bundled face's own 3/4). One simpleOutline frame is live at a time (composites recurse only small component-walk frames), so the parse scratch stays ~9.5 KiB stack; the reference glyph path capacity is now derived from the budgets rather than magic.
  • Registration-time maxp gate: Face.parse refuses faces whose declared maxima exceed the budgets, surfaced as a teaching that names the face's numbers against every budget — so a registered id always resolves at render time, and the per-glyph checks remain as commented backstops for under-declaring fonts.
  • Registration byte bound 2 MiB → 24 MiB: the old bound existed precisely to refuse multi-megabyte CJK fonts the pipeline wasn't sized for — a rationale this fix eliminates (Noto Sans SC is 17.8 MB). Cost is reserved address space only, documented in canvas_limits.

Proof: synthetic in-test TrueType builders cover dense simple glyphs at the exact budgets, composites at depth, over-declared refusal (all four maxima), and under-declaring backstops; a /tmp-guarded test registers the real 9.6 MB Noto Sans JP through the runtime and outlines + fills 鬱驚籠鑑 at 16/96 px (self-skips without the OFL download — no font binaries committed). Bundled-face inking is byte-identical: all 263 pinned pixel/hash goldens unchanged. Full battery, validate, examples, ts-core e2e all green.

ctate and others added 2 commits July 17, 2026 01:10
- Raise the outline budgets to 1024 points / 128 contours, sized from measured maxp maxima of production TrueType builds (Noto Sans JP/SC/TC/KR, Noto Serif JP, brush-style Yuji Mai; worst measured 738 points / 87 contours), so dense kanji ink as outlines instead of block fallbacks
- Face.parse now refuses any face whose maxp declares maxima beyond the budgets (loud, registration-time), leaving the per-glyph checks as backstops for under-declaring fonts; declaredGlyphMaxima exposes a refused face's numbers for teaching callers
- Derive the reference renderer's glyph path capacity from the parser budgets and prove both gate sides with in-test synthetic TrueType fixtures (dense rings, budget-edge composites, over- and under-declaring maxp)

Co-authored-by: WhiteHades <44260523+WhiteHades@users.noreply.github.com>
Co-authored-by: IFTC-XLKJ <151902522+IFTC-XLKJ@users.noreply.github.com>
- registerCanvasFont maps the parser's glyph-budget refusal to error.FontExceedsGlyphBudgets, and UiApp's fonts teaching names the face's declared maxima against the budgets, restoring "a registered id always resolves at render time"
- Raise the per-font registration bound to 24 MiB (matched by the gpu-surface side-channel bound): measured Google Fonts TrueType builds run 9.6 MB (Noto Sans JP) to 17.8 MB (Noto Sans SC); memory stays fixed address space with pages touched only as fonts register
- Cover the gate at the registry and UiApp seams with a maxp-patched fixture, plus a /tmp-guarded end-to-end test registering the real Noto Sans JP and outlining dense kanji (skips where the download is absent)

Co-authored-by: WhiteHades <44260523+WhiteHades@users.noreply.github.com>
Co-authored-by: IFTC-XLKJ <151902522+IFTC-XLKJ@users.noreply.github.com>
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
native-sdk Ready Ready Preview, Comment Jul 17, 2026 5:59pm

ctate added 2 commits July 17, 2026 01:58
…iB pool

- Runtime.canvas_font_bytes (8 x 24 MiB inline after the CJK bound raise) becomes an exact-size heap copy from the new Options.allocator at registration; zero fonts = zero bytes, freed only by the new Runtime.deinit (registration is permanent, so ownership is one alloc and one free)
- Measured on the docs wasm preview host (one Runtime per component tile, linear memory never overcommits): per-instance bytes 313.5 MB -> 121.5 MB (pre-branch base: 137.5 MB), linear memory with one active tile 528.9 MB -> 144.9 MB (base: 272.9 MB)
- TestHarness routes font bytes through the leak-checked test allocator and deinits on destroy; a FailingAllocator test pins that a fresh runtime performs zero font-byte allocations until a registration happens, and that the first allocation is exactly the file's size
…use at registration

- Face.parse and GlyphMaxima/declaredGlyphMaxima/withinBudgets now read maxp.maxCompositePoints/maxCompositeContours (offsets 10/12): a face whose composites flatten past the path capacity was passing the simple-only gate and silently blocking at render, the exact failure the gate exists to prevent
- Composite budgets equal the simple budgets (1024 points / 128 contours): a composite flattens through the same path sink and per-glyph capacity, so densest-renderable-glyph is one answer; measured ground truth (Geist 89/5, Geist Mono 104/10, Yuji Mai 198/5, Noto CJK 0/0) sits 5-12x below, and the reference glyph path capacity derives from max(simple, composite) maxima
- Registration teachings (parseFailureReason and the UiApp fonts warn) name the composite numbers; tests cover over-budget composite declarations refused at parse and at the registry, an at-budget composite flattening denser than any simple glyph rendering within the derived capacity, and the bundled faces' pinned composite maxima
- UiAppHost.destroy freed the host without returning the runtime's heap-owned font copies, leaking every registered font per host recreation cycle; MobileHostApp.destroy already deinits — the UI-host sibling now matches.
ctate added 2 commits July 17, 2026 11:45
…cycle

- EmbeddedApp.deinit (idempotent) returns the embedded runtime's heap-owned registered-font bytes; the documented lifecycle now ends at deinit, not stop, and the docs show the defer embedded.deinit() idiom
- MobileHostApp.destroy and UiAppHost.destroy route teardown through embedded.deinit — one lifecycle owner — so native_sdk_app_destroy reaches the same deinit through its host
- Leak-checked create-register-destroy cycle tests through the Options.allocator seam cover the direct embedder, idempotence, and the C ABI destroy path
- vector.zig grows a GlyphRasterizer whose edge/crossing capacities are derived from the font gate's outline budgets (quads <= 1024+128, edges <= quads*16+128 = 18,560, crossings <= edges), lockstep-pinned against font_ttf's constants, so a truthfully-declared budget-maximal glyph never trips VectorPathTooComplex
- Tolerance-exact flattening has no finite bound (nothing bounds text size), and even the generic 48-segment cap costs 1.06 MiB of edges, so glyph fills clamp at 16 segments per curve: binds only above ~128-px ems, error stays under em/512 (0.2%), goldens byte-identical
- The reference renderer inks glyphs through a per-thread heap GlyphRasterizer (lazy_tls, ~508 KiB, only on threads that ink glyphs); zigzag adversarial tests prove a gate-admitted 1024-point contour rasterizes at the max raster size on both text paths instead of degrading to the block fallback
… identity that made them

- Runtime captures Options.allocator into owned_allocator at initAt: options is public and mutable, so an embedder swapping options.allocator between registration and deinit split the alloc/free identity (silent wrong-allocator free); registration allocs, refusal-path errdefer frees, and the deinit frees now all resolve through the frozen field
- The field is general (every runtime-lifetime heap ownership, not font-specific) so future on-demand owners share the same frozen identity
- New test registers through a counting allocator, mutates options.allocator to a poisoned one, and deinits: counts balance on the original and the mutated allocator sees zero activity; existing tests that retargeted options.allocator post-init now re-freeze owned_allocator before anything is owned
@ctate
ctate merged commit 2c02809 into main Jul 17, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant