Skip to content

feat: shape base-plus-combining-mark graphemes in raster output - #285

Draft
Alexey Zhokhov (donbeave) wants to merge 17 commits into
microsoft:mainfrom
tailrocks:goal/u05-graphemes
Draft

Alexey Zhokhov (donbeave) wants to merge 17 commits into
microsoft:mainfrom
tailrocks:goal/u05-graphemes

Conversation

@donbeave

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

Copy link
Copy Markdown

Title: feat: shape base-plus-combining-mark graphemes in raster output

Depends-On: #284
Incremental diff vs #284 head:
d5b1469...ee633fa

Summary

Teaches the offline raster renderer to shape grapheme clusters made of a
base character plus combining marks (accents, CGJ, variation sequences),
so they draw as one cluster instead of dropping marks or misplacing them.
Shaping covers cells and cursor repaints on both font backends; titles stay
pinned-only. Emoji-presentation sequences keep the previous behavior.

New since the original PR head:

  • Advance-only glyphs are retained: the pen moves for every shaped entry
    while only drawable ones paint (fixes blank output for space-plus-mark).
  • Coverage checks no longer reject default-ignorable controls the shaper
    handles; genuine holes (placeholder glyph on a visible scalar) still fail.
  • One clip mask is reused per pixmap size instead of per cell (a large
    combining render runs in about 0.3s with a single allocation).
  • New optional dependencies are behind recording-raster; recordings keep
    input strings, not glyph data.

Head: ee633fa5146110b5a792c3ebaa651f1a66d50af3

Testing

Focused suites at head 48276ae, run locally with cargo test:

  • --features recording-font-jetbrains-mono-styles --test graphemes:
    19 tests run, 19 passed, 0 failed (includes new synthetic_style_fallback)
  • Same features, --test system_graphemes: 7 passed, 0 failed

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>
MIT, edition 2021, newest crates.io release (2024-11-12, stable
1.82 era, no declared rust-version; pure Rust with pre-1.90
dependencies). Uses ttf-parser 0.25, shared with the workspace, so
no second parser enters the tree. Optional under recording-raster;
the no-default-features build is unchanged.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
One OFL-1.1 face with only U+6771 plus .notdef (3,884 bytes),
subset at development time from google/fonts NotoSansJP[wght].ttf;
SOURCE records the commit, digests, and subset command. Pinned
tests read these bytes directly; nothing fetches fonts at test
time.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
…itles

Replace scalar-count rejection with rustybuzz shaping: each cell
grapheme shapes with one face selected per grapheme in fallback
order, so a combining sequence never divides across faces. Cells
and cursor repaint share draw_cell_text; titles shape per cluster
from the shaper's own indices with ligatures off. Single scalars
draw verbatim through the pinned cache; shaped runs map their
advance onto the cell width and clip to the permitted region.
Glyph evidence flows to RenderEvidence.shaped_graphemes.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Each P02 Unicode case renders with pinned repo bytes (JetBrains
Mono plus a vendored Noto Sans JP subset for CJK); cell and cursor
repaint keep shaped positions; wide clipping stays in the permitted
region; missing glyphs fail without image success; verbatim cells
record no shaping. Structural ink assertions plus decoded image
evidence; never digest-only acceptance.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
Ordinary offline rendering, live screenshots, and recordings rejected
every multi-scalar grapheme (scalar-count check) even when a catalog
face covered it, while pinned rendering shaped the same sequence. Cells
and cursor repaint now share one shaping path on both backends with
distinct selection policies: pinned keeps its set order and typed
errors; the system backend shapes base-plus-combining-mark sequences
with the first catalog candidate covering the full grapheme (host
fallback order) and collects an undrawable sequence for the
end-of-render failure. Single scalars keep the legacy verbatim path, so
recorded pixels do not change; non-combining sequences (ZWJ emoji,
regional indicators) keep the recorded report instead of a misrender,
and shaped evidence stays pinned-only.

unicode-properties 0.1.4 for General Category Mark detection: MIT/Apache-2.0,
edition 2021, no_std, dependency-free, no declared rust-version; already
in the tree via rustybuzz 0.20.1, so no new crate enters the lock.
Optional under recording-raster; the no-default-features build is
unchanged.

Tests: ordinary offline, live screenshot, and recording coverage for
e plus U+0301 and a plus U+0308 (ink in the cell, mark changes the
image, live cell holds the two-scalar grapheme, recording exports
animation frames).

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
…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>
…ip mask

P2 advance-only: shaping kept outline-less glyph advances in the run
total but stored only outlined glyphs, so the draw loop never moved the
pen for them. A width-1 cell holding space plus U+0301 rendered a
successful blank PNG: the space advance was dropped and the mark landed
outside the clip. Both backend loops now retain advance-only entries
(Option outline) and the draw loop moves the pen for every entry while
painting only drawable ones.

P2 CGJ preflight: the cmap preflight required every scalar to map a
standalone glyph, rejecting default-ignorables the shaper handles
(a+CGJ failed as MissingGlyph although rustybuzz shapes base+CGJ).
Coverage is now required only where visible glyphs are needed; the
controls ride along in the shaping buffer and shaped output is
inspected for genuine (.notdef on a visible scalar) holes.

PERF: each shaped cell allocated a full-canvas clip mask (~12.4GB
cumulative at 160x48/10x21: 7680 cells x 1600x1008 bytes). The
renderer now keeps one mask per pixmap size and clears it per cell. A
representative release workload (160x48 all-combining render) runs in
~0.3s with one mask allocation.

Scope and evidence notes: shaping covers base-plus-combining-mark
sequences only (ZWJ emoji and regional indicators keep the legacy
report; no general emoji claim). Single-scalar cells keep the verbatim
path on both backends, so single-scalar CJK was never rejected for
continuation reasons. System cell and cursor repaint share the combining
 path; title shaping stays pinned-only. The two new dependencies
(rustybuzz, unicode-properties) are optional under recording-raster,
and shaped_graphemes records the input strings, not glyph IDs,
positions, or faces.

U05-H covers space-plus-mark and the noncomposable multi-glyph cluster
with positional assertions (cell ink, cursor repaint, pinned titles);
U05-I covers CGJ, variation sequences, and the unsupported-visible
scalar negative control; the system backend gains an offline CGJ case.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
@donbeave Alexey Zhokhov (donbeave) changed the title Render combined and wide cell text correctly feat: shape base-plus-combining-mark graphemes in raster output Oct 26, 2026
Move the synthetic-style fallback into PinnedFontSystem::style_positions
so the single-scalar, grapheme, and title paths select faces the same way:
exact style-slot matches first in fallback order, every face in fallback
order when the slot is empty and synthetic styles are selected, and empty
otherwise. Previously a regular-only set with synthetic styles enabled
failed bold combining text and titles with MissingFace.

Cover the fallback with the synthetic_style_fallback graphemes test:
regular-only face renders bold-italic combining text, block-cursor repaint,
and a visible title, while the same render without synthetic styles fails
as a missing face.

Signed-off-by: Alexey Zhokhov <alexey@zhokhov.com>
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