Skip to content

v0.12.0

Choose a tag to compare

@github-actions github-actions released this 23 May 00:28
· 620 commits to main since this release

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.initWithOptions accepts a cvt_headroom knob 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 to 0, preserving
    strict spec behaviour. Headroom is allocated once per SizeState; the hint
    path remains free of per-op allocation.
  • HintRejectReason.exec_failed plus a safety-net catch in
    computeMissingGlyph that converts any tt_exec execution 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 of resetForAtlas): 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 on ensureText-style atlas growth. Migration:
    ctx.resetForAtlas(new) → ctx.rebindAtlas(new).
  • TextBlobBundle and BlobInProgress: value-driven blob construction.
    A bundle owns a set of TextBlobs sharing a TextAtlas under a single
    lifetime. Streaming construction via bundle.startBlob() returns a
    BlobInProgress (terminate with finish(key) or abort()); bulk
    construction via bundle.buildBlob(key, []TextAppend, ?[]TextAppendResult).
    Additional operations: rebindAtlas, freeze/unfreeze/isFrozen,
    blobCount, currentGeneration. The bundle exists alongside
    TextBlobBuilder for now; in-repo consumers (demos, benches, tests,
    api_tests, renderer_tests) have been migrated to the bundle.
    TextBlobBuilder remains 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: SnailTextBlobBundle and SnailBlobInProgress mirror the Zig
    bundle. New exports cover the full streaming lifecycle plus
    freeze/unfreeze/is_frozen, blob_count, generation, and
    rebind_atlas. SnailTextBlob handles returned by
    snail_blob_in_progress_finish are bundle-owned; their storage is
    reclaimed by the bundle and snail_text_blob_deinit skips the inner
    deinit on those handles. A generation counter on the bundle lets the C
    side detect use-after-reset on SnailBlobInProgress handles.

Removed (breaking)

  • snail.TextBlobBuilder is no longer part of the public Zig API.
    Migrate to snail.TextBlobBundle:
    // 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) ...
    The builder remains as a private implementation detail of the bundle;
    external code cannot reach it.
  • C API: SnailTextBlobBuilder handle and all
    snail_text_blob_builder_* exports removed. Migrate to
    SnailTextBlobBundle + SnailBlobInProgress
    (snail_text_blob_bundle_init/start_blob and the
    snail_blob_in_progress_append_*/finish calls).

Changed (breaking)

  • PreparedHintRun and PreparedBestEffortHintRun collapse into a single
    PreparedHintRun whose glyphs carry a per-glyph source union
    (.hint | .fallback). Stats gains hinted_count and
    fallback_count. Strict callers check stats.fallback_count == 0 to
    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: Range removed. Prepare is always whole-run;
    hint values are already memoized by (face, ppem, glyph_id).
  • TextBlobBuilder.appendPreparedHintedRun and
    appendPreparedBestEffortHintRun collapse into a single
    builder.appendPreparedHintRun(*const PreparedHintRun, placement, color)
    that handles fallback glyphs via the unhinted path. With the new
    TextAppend.source union below, this method is itself folded into
    builder.append(.{ .source = .{ .hinted = run.glyphs }, ... }).
  • TextAppend.source becomes a tagged union over caller-owned slices:
    union(enum) { shaped: []const ShapedText.Glyph, hinted: []const PreparedHintRun.Glyph }. TextAppend.glyphs: Range is removed; sub-
    selection is done via slice notation (shaped.glyphs[a..b]). The
    hinted arm requires a solid Paint; 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 } }).
  • TextBatchAppend likewise: .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
    own x_offset/y_offset. The rare shaper-offset-on-first-glyph case
    is now the caller's responsibility (adjust baseline as needed).
  • SnailTrueTypeHintRunStats gains hinted_count and fallback_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. The prepare_run C
    function drops its glyphs: SnailRange parameter.
  • TextDraw.glyphs and PathDraw.shapes removed — draws apply to the
    whole blob/picture. Sub-selection happens at composition time: build
    smaller blobs into the same TextBlobBundle (the bundle amortises
    allocation across them) or build smaller PathPictures. C API
    SnailPathPictureDraw.range / .has_range removed accordingly.
  • snail.Range is no longer used by any draw-time field. It remains as
    a value type returned by PathPictureBuilder.rangeFrom /
    rangeBetween (composition aids that track shape offsets within a
    picture); the helpers textBlobRangeGpuInstanceBudget and
    shapedAdvanceForRange are removed.
  • TextBlob shape change: drops allocator, atlas, atlas_identity
    fields and its init / deinit / rebound methods. All blobs are
    bundle-owned by construction — the blob struct lives in
    bundle.gpa for pointer stability, its glyph and paint content lives
    in the bundle's ArenaAllocator. Access the atlas via blob.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 to new_atlas. C API snail_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 internal TextBlobBuilder type is gone; its
    accumulator state is now PendingBlob inside the bundle.
  • ShapedText.advance_x and advance_y fields 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_y exports are unchanged.

Added (typography layer)

  • snail.Cluster / snail.ClusterIterator / snail.clusters(&shaped)
    walk a ShapedText by HarfBuzz cluster — maximal runs of glyphs
    sharing a source_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_count indices 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), and snail.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's x_advance so subsequent advance summation stays correct.
    C API: snail_shaped_text_track / _shift_baseline / _space_words
    / _snap_advances mirror the Zig signatures and mutate the handle
    in place.
  • snail.ShapeOptions and snail.OpenTypeFeature (with optional
    snail.SourceRange) provide shape-time inputs distinct from
    post-shape transforms. New TextAtlas.shapeTextOpts(allocator, style, text, opts) forwards feature requests to HarfBuzz; the existing
    shapeText is 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 (with has_range bool 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
    TextBlobBundle shares one hint pool, uploaded once per bundle
    regardless of how many blobs reference it. New
    ResourceManifest.TextHintEntry (also returned as the new
    TextResourceKeys.hint field, derived from the atlas key so all
    blobs in one bundle resolve to the same manifest entry).
    TextBlob.Glyph.hint_record_texel changed 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_base threads 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 at transform.tx, once at
    transform.tx + face.synthetic.embolden. Stems end up embolden
    pixels wider than the hint program anticipated but stay grid-aligned,
    strictly better than rejecting into fully-unhinted geometry. The
    synthetic_embolden rejection reason remains in the enum (ABI) but
    is no longer produced; appendHintedGlyphRef now threads the face's
    synthetic embolden through the glyph record.
  • PreparedHintRun snaps each .fallback glyph'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_disabled is the font author's explicit
    "do not grid-fit at this PPEM" instruction — those glyphs keep
    pass-through advances. Internal helper snapEmAdvanceToPixels( em_advance, ppem) lives in hint_context.zig. Closes the visible
    gap from topology_changed rejections — 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 the GlyphTopologyCache. The cache stores GlyphTopology
    values inside a std.AutoHashMap; recursive cache.get calls during
    component walking could rehash and relocate the parent's value,
    dangling the *const CompoundGlyph pointer that executeCompoundGlyph
    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-existing cvt_headroom knob 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).