Skip to content

feat: pin fonts for reproducible offline rendering - #284

Draft
Alexey Zhokhov (donbeave) wants to merge 14 commits into
microsoft:mainfrom
tailrocks:goal/u04-pinned-fonts
Draft

Alexey Zhokhov (donbeave) wants to merge 14 commits into
microsoft:mainfrom
tailrocks:goal/u04-pinned-fonts

Conversation

@donbeave

@donbeave Alexey Zhokhov (donbeave) commented Oct 25, 2026 •

Copy link
Copy Markdown

Title: feat: pin fonts for reproducible offline rendering

Depends-On: #280
Incremental diff vs #280 head:
a6adbcc...d5b1469

Summary

Adds a pinned-font policy for offline raster rendering: an explicit set of
font faces (with synthetic bold/italic fallback) and a cache key covering
every output setting, so the same snapshot always renders the same pixels.

New since the original PR head:

  • Face identity digests now encode the length as fixed-width, so identical
    font bytes give the same identity on 32- and 64-bit hosts (with a known
    digest test vector).
  • Opting into synthetic styling satisfies a missing style slot by drawing a
    sibling face emboldened or slanted; without it, a missing face is still an
    error.
  • The render contract names the configuration, not the contents: snapshots
    with different text or titles can share a contract, and strict gates
    compare decoded pixels.
  • Merges the offline cursor-color, subpixel-cell, and fractional-size fixes
    from feat: add offline SVG/PNG snapshot rendering #280; both PNG entry points share the pinned renderer.

Head: d5b14695458b651986bd6916df433487b6e94d13

Testing

Focused suites, run locally with nextest:

  • --features recording-font-jetbrains-mono-styles --test pinned_fonts:
    9 tests run, 9 passed, 0 skipped
  • Same features, --test offline_render: 19 passed, 0 skipped

Known limitations

Live capture reads grid, cursor, dimensions, title, and resolved
palette under one terminal-state lock. Default capture records raw
visual data; highlighted capture needs an explicit option.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Add ResolvedLayout in render/svg.rs: live screenshots and recordings
resolve their Style to it, offline snapshots resolve SnapshotRenderOptions
to it, and both draw through render_svg_with_layout and
GridRenderer::with_layout. RenderState gains from_parts so snapshots can
supply resolved colors without a live emulator; encode gains
encode_png_bytes shared by file and memory PNG output. render_svg,
GridRenderer, and encode_png keep their previous behavior.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Public render_snapshot_svg/render_snapshot_png (+ path wrappers) draw a
TerminalSnapshot through the same renderer as live screenshots, with
SnapshotRenderOptions carrying explicit cell metrics, scale, padding, and
chrome switches. Hidden and clipped cursors stay hidden and are recorded
in RenderEvidence with dimensions and title. Tests cover U03-A through
U03-E and U03-G through U03-I.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Add explicit PinnedFontSet (font bytes, face indices, fallback order,
style selection) extending SnapshotRenderOptions. Under the pinned
policy the rasterizer loads only the specified bytes: no system fonts
and no ambient font-selection variables. Missing font, style face, or
glyph returns a typed PinnedFontError; no host fallback, replacement
glyph, or unselected synthetic style. Each strict PNG carries a
versioned render contract (renderer and raster policy versions, font
digests, geometry, scale, palette digest, cursor phase with blink
intent).

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
U04-A re-renders C06 content under a rogue fontconfig path and family
selection; U04-B checks digest sensitivity; U04-C checks typed
missing-font, missing-face, and missing-glyph failures; U04-D checks
system cache operations cannot move strict output; U04-E checks equal
pinned inputs decode to equal pixels; U04-F checks the recorded
system-font behavior is unchanged.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
R14: snapshot_colors passed default_cursor() into RenderState, so a captured red block cursor rendered green. Pass snapshot.cursor().color() instead, matching live RenderState::capture (emu.color(Cursor)). No construction change: the two agree on fresh live captures and may legitimately differ in hand-built snapshots.

R15: 0.25px cells at 40x scale produced viewBox 0 0 0 0 against 40x40 PNG (SVG evidence said 10x10): ceil-then-scale vs scale-then-ceil vs unscaled viewBox rounding disagreed. Like the live Style (too small to draw a cell), offline validate now rejects logical cells under 1px at any scale via RenderError::CellTooSmall, before allocation. SVG/PNG regression tests with distinct cursor colors; fractional-metrics rejection tests at 1x and 40x.
…settings in contract

R5: write_snapshot_png unconditionally built the system-font renderer,
so bold text with a regular-only pinned set wrote a PNG while
render_snapshot_png returned typed MissingFace. Both entry points now
build the shared snapshot_renderer, and the path API maps staged-write
failures through map_pinned_error, so both report the same typed
pinned-font failures and decode to equal pixels.

R6: RenderContract copied only part of PinnedCacheKey, so a padding
change (10x21 vs 30x41 images) and the synthetic-style selection left
the contract equal while pixels differed. The contract is now at
schema version 2 and records the synthetic-style selection plus every
normalized output setting (title chrome, content and canvas padding,
backgrounds, window and border chrome, shadow, exact-size switch), each
with an accessor; RenderContract::system takes the background override
and exact-size flag alongside the layout.

U04-G checks path/bytes parity for the MissingFace failure and the
success pixels; U04-H checks the returned contract changes after each
output-affecting option.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
A 3x2 grid with 10.1x20.1 cells at 2x produced three sizes (SVG doc
60.6x80.4 over a rounded 30x40 viewBox, evidence 61x81, PNG 62x82):
PNG sizing ceiled before scaling while SVG/evidence used different
arithmetic. Round once at the pixel-allocation boundary
(ResolvedLayout::output_pixel_size) and use it for PNG pixels, SVG
width/height, evidence dims, and an exact unscaled viewBox. Integer
layouts format byte-identically to before.
P2: PinnedFace::digest hashed the byte count as usize, so identical
font bytes gave different identities on 32- vs 64-bit hosts. Encode
the length as a fixed-width u64, with a known digest vector test.

with_synthetic_style(true) now satisfies a missing style slot: an
empty slot falls back to the set's other faces in fallback order,
drawing their outlines emboldened or slanted, matching the advertised
fallback. Without the selection the slot still needs an explicit face
and the missing-face error stands.

The render contract names the configuration, not the contents:
snapshots with different text or titles can share a contract, and
strict gates compare decoded pixels.
@donbeave Alexey Zhokhov (donbeave) changed the title Add explicit pinned-font rendering feat: pin fonts for reproducible offline rendering Oct 26, 2026
Alexey Zhokhov (donbeave) added a commit to tailrocks/tuiscotti that referenced this pull request Oct 27, 2026
Migrate tuiscotti onto the tui-test engine as its terminal runtime.

Upstream (`tui-test`) owns the terminal engine: child processes,
emulation, sessions, input, waiting, rendering, fonts, and pixel
comparison. Tuiscotti keeps the application testing layer: pure-view
capture, versioned samples, frozen baseline checks, explicit approval,
static reports, and the offline CLI (`check` / `accept` / `render` /
`report`, exit codes 0/1/2/3). The previous built-in live engine,
renderer, recorder, and second approval engine are removed, leaving the
two shipping crates (`tuiscotti`, `tuiscotti-cli`).

## Provisional fork dependency

The new `tui-test` APIs this PR needs are still unmerged upstream, so
this branch temporarily depends on a pinned fork via `[patch]`:

- `donbeave/tui-test`, branch `goal/qual-stack-r5`, pinned at
`81da26fa9dc29664a897dc802be816a4432c0618`

The pin is provisional. Once the upstream PRs below merge and a registry
release contains the new APIs, the `[patch]` is removed in favor of the
registry version.

## Upstream PRs (all 11, all open)

| PR | Change | Head SHA | Status |
|----|--------|----------|--------|
|
[microsoft/tui-test#276](microsoft/tui-test#276)
| Explicit read-only snapshot policy |
`fc0150160c25ea83532b18f9507f9f1a155b91e4` | Reworked + verified |
|
[microsoft/tui-test#277](microsoft/tui-test#277)
| RGBA image comparison for pixel equality |
`4fb316aa56c65cc659b4a43ee4921c45e33cecc0` | Approved as-is;
dependency-only, no rework |
|
[microsoft/tui-test#278](microsoft/tui-test#278)
| Owned terminal snapshot API |
`334c474d1c621c373333e9dc24e549623a133570` | Reworked + verified |
|
[microsoft/tui-test#279](microsoft/tui-test#279)
| Raw byte input for Rust sessions |
`3b1439c313e88d0a8867dfbcdadabb28624dc5b5` | Reworked + verified |
|
[microsoft/tui-test#280](microsoft/tui-test#280)
| Public rendering for offline snapshots |
`378036846a4e85174104e0f9c153b0a0f8f84161` | Reworked + verified |
|
[microsoft/tui-test#281](microsoft/tui-test#281)
| Configurable quiet waits and request cancellation |
`103d296494f251921f8cd61d3f6ce2f7e152690d` | Reworked + verified |
|
[microsoft/tui-test#282](microsoft/tui-test#282)
| Mode-aware programmatic input |
`212ba1db26987a45537708e98c82db77c90bb734` | Reworked + verified |
|
[microsoft/tui-test#283](microsoft/tui-test#283)
| Explicit underline colors in rendering |
`fff55c122ae210efbe9a68d1ac8683b1a8856d` | Reworked + verified |
|
[microsoft/tui-test#284](microsoft/tui-test#284)
| Explicit pinned-font rendering |
`2bdaa3f8d3692514a6cb9c1ab844e880ca8d0f23` | Reworked + verified |
|
[microsoft/tui-test#285](microsoft/tui-test#285)
| Combined and wide cell text rendering |
`edf2b525d6f8a5710cd56440545437ff4f146727` | Reworked + verified |
|
[microsoft/tui-test#286](microsoft/tui-test#286)
| Explicit child launch settings |
`d5d9bbab9ac4e8a5e0562175497661b7b1a4b158` | Reworked + verified |

"Reworked + verified" means the PR went through a rework round after
independent review, and its per-task suites plus the integrated stack
re-ran green. #277 needed no changes — its head is unchanged since
review — so it is listed as a pure dependency.

Upstream merge order: #278 before #280 and #281; #280 before #283 and
#284; #284 before #285. #276, #277, #279, #282, and #286 are independent
and can merge in any order.

## Verification (at PR head `a056f8f60bae1bf81429b575076c86aed3e6717b`)

- Tests: 209/209 passing in both the default-features and
no-default-features lanes.
- `cargo fmt --check` clean; `cargo clippy --all-targets -D warnings`
clean (default and no-default features).
- Downstream CI: [run
40408516574](https://github.com/tailrocks/tuiscotti/actions/runs/40408516574)
(in progress, at `a056f8f`); prior run
[40390626472](https://github.com/tailrocks/tuiscotti/actions/runs/40390626472)
success at `9ae97ff`.

## Known limitations

- Windows/macOS runner coverage is still pending; platform-specific
behavior (for example raw-byte PTY paths) is unverified there.
- The upstream PRs currently report only CLA checks; Rust CI on the
upstream side still needs maintainer workflow approval, so upstream-side
proof rests on the recorded per-task suites plus this downstream
integration.
- No crates.io release contains the new APIs yet, so registry adoption
is blocked until the upstream PRs merge and release. The provisional
fork pin above supports merging this PR only; it is not a release path.

## Depends-on / merge order

This PR depends on all eleven upstream PRs merging (in the order noted
above) plus a registry release; only then does the provisional `[patch]`
come out. Nothing in this PR can ship to crates.io before that. Upstream
maintainers: the stack is additive-only, and the pinned fork branch
tracks the exact heads listed above.

---------

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Signed-off-by: Muse Code <muse-code@meta.com>
Signed-off-by: Muse Code <muse@example.local>
Co-authored-by: Muse Code <muse-code@meta.com>
Co-authored-by: Muse Code <muse@example.local>

This branch has not been deployed

No deployments
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