Repository navigation
v0.12.0
A pre-1.0 consolidation of the text construction surface. Several
breaking changes; consumers should expect mechanical migration. See the
migration recipes below each entry.
Added
TrueTypeHintContext.initWithOptionsaccepts acvt_headroomknob that
appends zeroed scratch slots to each cached size's CVT, letting fonts that
write past their declared CVT length (tolerated by FreeType/Skia/CoreText)
hint successfully instead of being rejected. Defaults to0, preserving
strict spec behaviour. Headroom is allocated once perSizeState; the hint
path remains free of per-op allocation.HintRejectReason.exec_failedplus a safety-net catch in
computeMissingGlyphthat converts anytt_execexecution error into a
per-glyph rejection, so best-effort hint runs fall back per glyph instead of
aborting the entire run on a single malformed program.TrueTypeHintContext.rebindAtlas(rename ofresetForAtlas): preserves
cached hint values, face programs, and size states when the new atlas
snapshot is prefix-compatible with the old (canRebindFrom). Eliminates the
warmup rehint storm onensureText-style atlas growth. Migration:
ctx.resetForAtlas(new)→ctx.rebindAtlas(new).TextBlobBundleandBlobInProgress: value-driven blob construction.
A bundle owns a set ofTextBlobs sharing aTextAtlasunder a single
lifetime. Streaming construction viabundle.startBlob()returns a
BlobInProgress(terminate withfinish(key)orabort()); bulk
construction viabundle.buildBlob(key, []TextAppend, ?[]TextAppendResult).
Additional operations:rebindAtlas,freeze/unfreeze/isFrozen,
blobCount,currentGeneration. The bundle exists alongside
TextBlobBuilderfor now; in-repo consumers (demos, benches, tests,
api_tests, renderer_tests) have been migrated to the bundle.
TextBlobBuilderremains as the lower-level primitive until the
TextBlob storage model collapses to the bundle's arena (planned for a
follow-up pre-1.0 pass).- C API:
SnailTextBlobBundleandSnailBlobInProgressmirror the Zig
bundle. New exports cover the full streaming lifecycle plus
freeze/unfreeze/is_frozen,blob_count,generation, and
rebind_atlas.SnailTextBlobhandles returned by
snail_blob_in_progress_finishare bundle-owned; their storage is
reclaimed by the bundle andsnail_text_blob_deinitskips the inner
deinit on those handles. A generation counter on the bundle lets the C
side detect use-after-reset onSnailBlobInProgresshandles.
Removed (breaking)
snail.TextBlobBuilderis no longer part of the public Zig API.
Migrate tosnail.TextBlobBundle:The builder remains as a private implementation detail of the bundle;// before var builder = snail.TextBlobBuilder.init(allocator, &atlas); defer builder.deinit(); _ = try builder.append(text_append); var blob = try builder.finish(); defer blob.deinit(); // ... use &blob ... // after var bundle = snail.TextBlobBundle.init(allocator, &atlas); defer bundle.deinit(); var bip = try bundle.startBlob(); errdefer bip.abort(); _ = try bip.append(text_append); const blob = try bip.finish(snail.ResourceKey.named("my_blob")); // ... use blob (a *const TextBlob owned by bundle) ...
external code cannot reach it.- C API:
SnailTextBlobBuilderhandle and all
snail_text_blob_builder_*exports removed. Migrate to
SnailTextBlobBundle+SnailBlobInProgress
(snail_text_blob_bundle_init/start_bloband the
snail_blob_in_progress_append_*/finishcalls).
Changed (breaking)
PreparedHintRunandPreparedBestEffortHintRuncollapse into a single
PreparedHintRunwhoseglyphscarry a per-glyphsourceunion
(.hint|.fallback).Statsgainshinted_countand
fallback_count. Strict callers checkstats.fallback_count == 0to
detect partial hinting instead of calling a separate prepare path.
Migration:context.prepareRun(alloc, .{...})always returns the
unified type; check stats;context.prepareBestEffortRun(...)is gone.PrepareRunOptions.glyphs: Rangeremoved. Prepare is always whole-run;
hint values are already memoized by(face, ppem, glyph_id).TextBlobBuilder.appendPreparedHintedRunand
appendPreparedBestEffortHintRuncollapse into a single
builder.appendPreparedHintRun(*const PreparedHintRun, placement, color)
that handles fallback glyphs via the unhinted path. With the new
TextAppend.sourceunion below, this method is itself folded into
builder.append(.{ .source = .{ .hinted = run.glyphs }, ... }).TextAppend.sourcebecomes a tagged union over caller-owned slices:
union(enum) { shaped: []const ShapedText.Glyph, hinted: []const PreparedHintRun.Glyph }.TextAppend.glyphs: Rangeis removed; sub-
selection is done via slice notation (shaped.glyphs[a..b]). The
hinted arm requires a solidPaint; non-solid returns
error.HintedAppendRequiresSolidFill. Migration:
.shaped = &shaped, .glyphs = .{ .start = a, .count = c }→
.source = .{ .shaped = shaped.glyphs[a..a+c] }. For hinted:
.appendPreparedHintRun(&run, placement, color)→
.append(.{ .source = .{ .hinted = run.glyphs }, .placement, .fill = .{ .solid = color } }).TextBatchAppendlikewise:.shaped: *const ShapedText, .glyphs: Range
→.glyphs: []const ShapedText.Glyph. Glyph positioning becomes
slice-relative: the first glyph in the slice lands exactly at
placement.baseline; subsequent glyphs offset from there using their
ownx_offset/y_offset. The rare shaper-offset-on-first-glyph case
is now the caller's responsibility (adjustbaselineas needed).SnailTrueTypeHintRunStatsgainshinted_countandfallback_count
fields. C ABI is breaking.- C API:
snail_true_type_hint_context_reset_for_atlas→
snail_true_type_hint_context_rebind_atlas,
snail_text_blob_builder_append_prepared_hinted_run→
snail_text_blob_builder_append_prepared_hint_run, and
snail_text_blob_init_from_prepared_hinted_run→
snail_text_blob_init_from_prepared_hint_run. Theprepare_runC
function drops itsglyphs: SnailRangeparameter. TextDraw.glyphsandPathDraw.shapesremoved — draws apply to the
whole blob/picture. Sub-selection happens at composition time: build
smaller blobs into the sameTextBlobBundle(the bundle amortises
allocation across them) or build smallerPathPictures. C API
SnailPathPictureDraw.range/.has_rangeremoved accordingly.snail.Rangeis no longer used by any draw-time field. It remains as
a value type returned byPathPictureBuilder.rangeFrom/
rangeBetween(composition aids that track shape offsets within a
picture); the helperstextBlobRangeGpuInstanceBudgetand
shapedAdvanceForRangeare removed.TextBlobshape change: dropsallocator,atlas,atlas_identity
fields and itsinit/deinit/reboundmethods. All blobs are
bundle-owned by construction — the blob struct lives in
bundle.gpafor pointer stability, its glyph and paint content lives
in the bundle'sArenaAllocator. Access the atlas viablob.atlas()
/blob.atlasIdentity()(accessor methods that follow the bundle
pointer). Rebinding a blob to a new atlas is now
target_bundle.rebound(key, src, new_atlas); the target bundle must
already be bound tonew_atlas. C APIsnail_text_blob_rebound
internally creates an owned bundle bound to the new atlas and copies
the source blob into it — the C-side handle continues to work the
same way. The internalTextBlobBuildertype is gone; its
accumulator state is nowPendingBlobinside the bundle.ShapedText.advance_xandadvance_yfields removed; replaced by
advanceX()/advanceY()methods derived from the glyph stream.
The glyph slice is now the single source of truth for advance — no
field/stream desync is representable. Migration:shaped.advance_x
→shaped.advanceX(). C API:snail_shaped_text_advance_x/
_advance_yexports are unchanged.
Added (typography layer)
snail.Cluster/snail.ClusterIterator/snail.clusters(&shaped)
walk aShapedTextby HarfBuzz cluster — maximal runs of glyphs
sharing asource_start. A cluster is the caller-visible "atom" of
shaped output: a ligature, a composed grapheme, or a reorder block.
Post-shape transforms (see below) iterate by cluster to avoid pulling
ligature components apart. C API:SnailCluster,
SnailClusterIterator(stack-allocated), with
snail_shaped_text_cluster_iterator/
snail_cluster_iterator_next. The C-side cluster carries
glyph_start/glyph_countindices into the shaped text rather
than borrowing glyph pointers.- Free-function post-shape transforms over
*ShapedText:
snail.track(&shaped, em)(letter-spacing between clusters),
snail.shiftBaseline(&shaped, em)(super/subscript shift),
snail.spaceWords(&shaped, source, em)(extra space after ASCII
whitespace clusters), andsnail.snapAdvances(&shaped, em_step)
(terminal-style cell snapping). All mutate the glyph stream in place;
no god-struct, no allocator. Cluster-aware: ligature internals are
preserved, and per-cluster deltas are baked into the cluster's last
glyph'sx_advanceso subsequent advance summation stays correct.
C API:snail_shaped_text_track/_shift_baseline/_space_words
/_snap_advancesmirror the Zig signatures and mutate the handle
in place. snail.ShapeOptionsandsnail.OpenTypeFeature(with optional
snail.SourceRange) provide shape-time inputs distinct from
post-shape transforms. NewTextAtlas.shapeTextOpts(allocator, style, text, opts)forwards feature requests to HarfBuzz; the existing
shapeTextis preserved as the zero-options shorthand. Features may
be global or scoped to a source-byte range; ranges are translated to
the local segment coordinates of each itemized run, and ranges that
fall outside a segment are silently dropped. Up to 32 concurrent
features per segment (well beyond typical usage). With HarfBuzz
disabled at build time, options are accepted but ignored — the
fallback shaper has no feature surface. C API:
SnailSourceRange,SnailOpenTypeFeature(withhas_rangebool to
encode the optional range),SnailShapeOptions, and
snail_text_atlas_shape_utf8_opts.
Changed
- Hint records are now bundle-scoped, not blob-scoped: every blob in a
TextBlobBundleshares one hint pool, uploaded once per bundle
regardless of how many blobs reference it. New
ResourceManifest.TextHintEntry(also returned as the new
TextResourceKeys.hintfield, derived from the atlas key so all
blobs in one bundle resolve to the same manifest entry).
TextBlob.Glyph.hint_record_texelchanged semantics: it's now a
bundle pool index, resolved at render time via
bundle.hintPoolTexelOffset(idx)against the shared slab. A new
PreparedTextAtlasView.hint_info_row_basethreads the bundle slab's
upload row through to hinted-glyph emit. Terminal-style workloads
(e.g. 50 lines of the same character set hinted at 12px) drop from
~17 KiB per blob to one ~17 KiB shared slab: ~50× reduction in
layer-info upload bytes. No render-output change; hint values and
per-glyph emit are identical. - TrueType hint VM hints emboldened faces (faux-bold) instead of
rejecting them. Hint instructions still operate on the un-emboldened
outline; the existing render-time double-emit (batch.zig) draws the
hinted glyph twice — once attransform.tx, once at
transform.tx + face.synthetic.embolden. Stems end upembolden
pixels wider than the hint program anticipated but stay grid-aligned,
strictly better than rejecting into fully-unhinted geometry. The
synthetic_emboldenrejection reason remains in the enum (ABI) but
is no longer produced;appendHintedGlyphRefnow threads the face's
synthetic embolden through the glyph record. PreparedHintRunsnaps each.fallbackglyph's advance to whole
pixels at the hint context's PPEM, regardless of why hinting failed
(no bytecode, topology mismatch, exec failure, missing base, color
glyph, …). Unhinted curve geometry still renders via the.fallback
path downstream, but integer-pixel advances stop adjacent glyphs
from sub-pixel shimmering and let columns of text line up cleanly.
The one opt-out:grid_fit_disabledis the font author's explicit
"do not grid-fit at this PPEM" instruction — those glyphs keep
pass-through advances. Internal helpersnapEmAdvanceToPixels( em_advance, ppem)lives inhint_context.zig. Closes the visible
gap fromtopology_changedrejections — even when our delta-encoding
can't represent the hinted outline, the advance metric is still
pixel-correct.
Fixed
- TrueType hint VM no longer segfaults on compound glyphs that recurse
through theGlyphTopologyCache. The cache storesGlyphTopology
values inside astd.AutoHashMap; recursivecache.getcalls during
component walking could rehash and relocate the parent's value,
dangling the*const CompoundGlyphpointer thatexecuteCompoundGlyph
was still holding. Slice headers (components,instructions) are
now snapshotted into locals before any recursion. Triggered immediately
on NotoSans-Regular when hinting every glyph. - TT hint VM now tolerates out-of-bounds CVT reads (return 0) and writes
(no-op), matching FreeType/Skia/CoreText behaviour. NotoSansSymbols'
prep program computes CVT indices that go negative (idx=-8), which
previously aborted ~80% of its glyph hint runs and degraded them to
unhinted curves. With lenient CVT handling the success rate goes from
20% to 99.5% on that font, with no observable change on fonts whose
CVT access stays in range. The pre-existingcvt_headroomknob still
works and is now most useful when a font writes past its declared CVT
length and later reads the value back (lenient mode alone would
silently drop both).