Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
df865ec
Document registering fonts for scripts beyond bundled coverage
ctate Jul 17, 2026
72774fc
Teach the register-a-font path where the tofu guard fires
ctate Jul 17, 2026
58f36e6
Prove registered CJK faces render Chinese text on Windows in CI
ctate Jul 17, 2026
a800c1b
Scope the fonts page's claims to what each tier delivers
ctate Jul 18, 2026
8106697
Pin the Chinese receipt against the registered face's own tofu
ctate Jul 18, 2026
eaeb37d
Name the one render-time refusal and add the release fragment
ctate Jul 18, 2026
2b51209
Evict a re-registered font id's cached NSFonts in the AppKit host
ctate Jul 18, 2026
d22e591
Rebuild every installed surface when a font registers late
ctate Jul 18, 2026
a6b77c7
Split the matrix's text row so mobile text is not denied
ctate Jul 18, 2026
b58c951
Invalidate the AppKit measured-width cache when a font id re-registers
ctate Jul 18, 2026
368f180
Scope the fonts page's late-registration and no-cascade promises to t…
ctate Jul 18, 2026
4482dca
Return the host-side font registration at Runtime.deinit
ctate Jul 19, 2026
cc1b210
Adopt the late-font rebuild count only after the rebuild succeeds
ctate Jul 19, 2026
3475720
Scope the matrix footnote's text-parity claim to the reference path
ctate Jul 19, 2026
47be31d
Key AppKit width-cache invalidation on a process-global font token
ctate Jul 19, 2026
b2e9f51
Return host font registrations through the owner captured at registra…
ctate Jul 19, 2026
f182b3f
Guard host font unregistration with a per-registration ownership token
ctate Jul 19, 2026
b85727f
Snapshot font token and face in one AppKit critical section
ctate Jul 19, 2026
0b4090a
Clear the AppKit measured-width cache when a font registration is tor…
ctate Jul 19, 2026
3d7995c
Cover the PR's user-visible fixes and API break in a changelog fragment
ctate Jul 19, 2026
cfe4aa8
Recheck the registration token before caching a measured width
ctate Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ jobs:
# include path, or a conformance error in the embedded layer — is a
# compile failure here, not a silent WebViewNotFound at runtime.
- run: zig build test-webview-system-link -Dplatform=windows
# The registered-font receipt, natively on Windows: runs the
# font-registry suite — registration validation, the glyph-budget
# gate, present/reference pixel parity, and the Chinese-receipt
# test that registers the committed subsetted Noto Sans SC through
# the app-fonts seam and proves the rendered string is real glyphs,
# not tofu — on real Windows, not under emulation. The font
# pipeline is platform-neutral Zig, so this lane pins that neutral
# truth on a Windows host (the Linux run lives in the Zig Core
# lane's `zig build test`).
- run: zig build test-canvas-fonts

cef-platform-tooling:
name: CEF Platform Tooling
Expand Down
103 changes: 103 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,100 @@ pub fn build(b: *std.Build) void {
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkItalicSansFont(base)" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkItalicSansFont(NativeSdkWeightedSansFont(@[ @\"Geist-Bold\", @\"Geist Bold\" ], base, NSFontWeightBold, YES, size))" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-registered-font-cache-eviction", "Verify the AppKit host invalidates BOTH per-process registered-font caches at registration (the caches are per-process while font-id permanence is per-runtime, so a new runtime re-registering an id must never resolve the previous runtime's face or its measured widths): the NSFont size cache by prefix eviction and the measured-width NSCache by a process-global registration token in its key", &.{
// No SDK test tier links appkit_host.m (only managed app builds
// compile it), so the eviction wiring is pinned textually like
// the other AppKit host contracts: the shared accessor both the
// resolve and registration paths use, the prefix eviction inside
// register_font, and the honest lifetime comment.
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSMutableDictionary<NSString *, NSFont *> *NativeSdkRegisteredFontSizeCache(void)" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSString *stalePrefix = [NSString stringWithFormat:@\"%llu/\", (unsigned long long)font_id];" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "if ([cachedKey hasPrefix:stalePrefix]) [sizeCache removeObjectForKey:cachedKey];" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "an id is only permanent within" },
// The width-cache half: NSCache cannot enumerate keys, so the
// measured-width cache is invalidated by a registration token
// drawn from one process-global monotonic counter (tokens never
// repeat, which is what lets unregister DELETE an id's record
// instead of retaining a bumped one per retired id) — the token
// table, the fresh stamp inside register_font, and the
// token-carrying key inside measure_text.
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSMutableDictionary<NSNumber *, NSNumber *> *NativeSdkRegisteredFontTokens(void)" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "unsigned long long token = ++NativeSdkRegisteredFontTokenCounter;" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NativeSdkRegisteredFontTokens()[@(font_id)] = @(token);" },
// The stamp is also the registration's OWNERSHIP token: register
// reports it to the caller (`*out_token`), the runtime stores it
// beside the captured unregister owner, and unregister removes
// the id's state only while the id's current registration still
// carries it — an older runtime's deinit must never tear down a
// newer runtime's live face under a shared id (ids are
// per-runtime, host font state is per-process, last wins).
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "*out_token = token;" },
// The token and the registered face reach measure_text from ONE
// critical section (the snapshot helper): separate acquisitions
// let a registration land between the token read and the face
// resolution, pairing token 0 with the new registered face and
// caching registered widths under the reusable token-0 key —
// stale registered widths served after teardown. The snapshot
// signature and its measure_text call site are pinned so the
// two-acquisition shape cannot quietly return.
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSFont *NativeSdkRegisteredFontSnapshot(unsigned long long value, CGFloat size, unsigned long long *out_token)" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSFont *registered = NativeSdkRegisteredFontSnapshot((unsigned long long)font_id, clamped, &token);" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSFont *font = registered ?: NativeSdkBuiltInFontForFontId(font_id, clamped);" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NSString stringWithFormat:@\"%llu/%llu/%.3f/%@\", (unsigned long long)font_id, token, (double)clamped, value]" },
// The width-cache WRITE is token-rechecked. Shaping runs outside
// the guard, so an unregister can land inside the shaping window:
// it clears the whole width cache (its only possible eviction),
// and an unconditional post-shape write would then repopulate the
// cleared cache with a retired-token entry — unreachable for
// serving (tokens never repeat) but resident until memory
// pressure, breaking the zero-retained-state teardown the clear
// exists to guarantee. measure_text therefore re-enters the
// descriptor guard after shaping and writes only while the id's
// current token still equals the snapshotted one; token 0
// (built-in resolution — never registered, so never cleared)
// caches without the recheck. Both branches are pinned so the
// unconditional-write shape cannot quietly return.
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (token == 0) {" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSNumber *currentToken = NativeSdkRegisteredFontTokens()[@(font_id)];" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (currentToken && currentToken.unsignedLongLongValue == token) {" },
// The teardown half: Runtime.deinit returns the host-side
// registration through the unregister owner each font entry
// captured at registration time (never live `options.platform`,
// which is publicly mutable), and the ObjC removal drops the
// descriptor, the size-cache entries, AND the token record —
// zero retained state per retired id. Pinned like the
// registration half (appkit_host.m has no SDK test tier); the
// capture and the deinit call site are pinned too so the seam
// can never silently lose its one caller or regress to the live
// read, and the embed cycle and platform-swap tests assert both
// behaviorally against null platform recorders.
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "int native_sdk_appkit_unregister_font(uint64_t font_id, uint64_t token) {" },
// The token-match guard itself: reverting removal to id-keyed
// (deleting whatever the id currently holds, whoever registered
// it) must fail here — the embed suite's survives-teardown test
// pins the same guard behaviorally against the null platform's
// host-font mirror.
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "if (!current || current.unsignedLongLongValue != token) return 1;" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NativeSdkRegisteredFontTokens() removeObjectForKey:@(font_id)];" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "[table removeObjectForKey:@(font_id)];" },
// Teardown must also CLEAR the measured-width NSCache: a
// retiring token's entries can never be served again (tokens
// never repeat) but they stay resident until memory pressure,
// and NSCache cannot enumerate keys, so the whole-cache clear on
// the token-matched path is the only release. Pinned textually
// because no behavioral tier can observe it — only managed app
// builds compile appkit_host.m, so no SDK test can watch the
// ObjC cache empty. The shared accessor and its measure_text
// call site are pinned with the clear so the cache cannot
// quietly retreat to a function-local static the unregister
// path has no way to reach.
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "static NSCache<NSString *, NSNumber *> *NativeSdkMeasuredWidthCache(void)" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "[NativeSdkMeasuredWidthCache() removeAllObjects];" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "NSCache<NSString *, NSNumber *> *widthCache = NativeSdkMeasuredWidthCache();" },
.{ .path = "src/runtime/canvas_fonts.zig", .pattern = ".host_unregister_fn = services.unregister_gpu_surface_font_fn," },
.{ .path = "src/runtime/canvas_fonts.zig", .pattern = ".host_registration_token = host_token," },
.{ .path = "src/runtime/core.zig", .pattern = "host_unregister_fn(entry.host_unregister_context, entry.id, entry.host_registration_token) catch {};" },
});
addFileContainsCheckStep(b, file_contains_checker, test_step, "test-appkit-gpu-widget-cursor-bridge", "Verify AppKit GPU widgets apply retained cursor intent", &.{
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "native_sdk_appkit_set_view_cursor" },
.{ .path = "src/platform/macos/appkit_host.m", .pattern = "resetCursorRects" },
Expand Down Expand Up @@ -916,6 +1010,15 @@ pub fn build(b: *std.Build) void {
for (desktop_test_shard_specs, desktop_test_shards) |spec, shard_tests| {
addTestStep(b, b.fmt("test-desktop-{s}", .{spec.name}), spec.description, shard_tests);
}
// The font-registry suite as its own step so CI lanes on real
// Windows hardware can run it natively: the whole font pipeline
// (TrueType parsing, glyph rasterization, the reference renderer)
// is platform-neutral Zig, and this suite's Chinese-receipt test
// registers a committed CJK face through the app-fonts seam and
// proves the rendered string is real glyphs, not tofu — running it
// on a Windows runner makes that a Windows-native receipt. The same
// tests also run inside `zig build test` via the canvas-frame shard.
addTestStep(b, "test-canvas-fonts", "Run the runtime font-registry tests (includes the registered-CJK Chinese receipt)", filteredTestArtifact(b, desktop_mod, "canvas-fonts-tests", &.{"runtime.canvas_font_tests.test"}));
addTestStep(b, "test-automation-protocol", "Run automation protocol tests", automation_protocol_tests);
addTestStep(b, "test-automation-cli", "Run native automate CLI tests", automation_cli_tests);
addTestStep(b, "test-markup-cli", "Run native markup CLI tests", markup_cli_tests);
Expand Down
3 changes: 3 additions & 0 deletions changelog.d/registered-fonts-docs-and-receipt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
improvement: **The tofu guard teaches font registration**: the font-coverage teachings — the `native markup check` error, the Debug view-build diagnostic, and the CLI usage text — now name registering a covering face (`UiApp.Options.fonts`) behind a model binding as the first remedy for text beyond bundled coverage, alongside vector icons and plain words.
- **A fonts page in the docs**: `/fonts` documents registering faces for scripts beyond bundled coverage — the `Options.fonts` scaffold shape, every registration-time validation error by name, ownership and lifecycle, how text resolves faces through the typography tokens, and per-platform truth including the unverified mobile seam.
- **The Chinese receipt runs natively on Windows in CI**: a new `zig build test-canvas-fonts` step runs the font-registry suite on the Windows runner, including the receipt test that registers a committed subsetted Noto Sans SC (OFL, license alongside) through the app-fonts seam and proves the rendered string is real ideograph outlines — compared against both the bundled face's rendering and the same registered face's own uncovered-string fallback, so tofu from any face fails the receipt.
3 changes: 3 additions & 0 deletions changelog.d/registered-fonts-late-rebuild-and-teardown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fix: **Late-registered fonts re-measure open surfaces**: registering a face after views are installed (`runtime.registerCanvasFont` on a live runtime) now rebuilds every installed `UiApp` surface — the main canvas and declared windows — on the next presented frame, so text laid out before the face joined re-measures with the registered face instead of keeping its pre-registration widths under a repaint.
- **macOS host font state ends with its runtime**: `Runtime.deinit` now returns each registered id's host-side registration — the CoreText descriptor and its measurement caches, including the measured-width cache the host previously retained until memory pressure — so embedders that cycle runtimes no longer accumulate per-process font state; removal is ownership-token guarded, so an older runtime's teardown never removes a newer runtime's live face under a shared id.
- **Breaking**: `PlatformServices.registerGpuSurfaceFont` now returns the host's ownership token for the registration (`u64`; 0 from hosts that retain no per-id state), and the new optional `unregisterGpuSurfaceFont(id, token)` service returns that state at teardown — a deliberate break while the toolkit is pre-1.0, so host font lifetime has an owner. Embedders implementing a custom platform change `register_gpu_surface_font_fn` to return a `u64` token (0 is fine for a stateless accept) and may supply `unregister_gpu_surface_font_fn` to release per-id host state when the registering runtime deinits.
7 changes: 7 additions & 0 deletions docs/src/app/fonts/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { pageMetadata } from "@/lib/page-metadata";

export const metadata = pageMetadata("fonts");

export default function FontsLayout({ children }: { children: React.ReactNode }) {
return children;
}
Loading
Loading