oxideav-scribe 0.1.8

Pure-Rust vector font shaper + layout for the oxideav framework — TrueType / OTF outline access, GSUB ligatures, GPOS kerning, mark attachment, CBDT colour bitmaps. Pixel pipeline lives in oxideav-raster.
Documentation

oxideav-scribe

Pure-Rust vector font shaper + line layout for the oxideav framework. Parses TTF / OTF tables (via oxideav-ttf

Scribe contains no pixel kernel: outline flattening, scanline AA, alpha compositing, synthetic bold and stroke dilation all live in oxideav-raster. Producing a rasterised text run is a two-step pipeline:

use oxideav_core::{Group, Node, VectorFrame};
use oxideav_raster::Renderer;
use oxideav_scribe::{Face, FaceChain, Shaper};

let bytes  = std::fs::read("DejaVuSans.ttf")?;
let face   = Face::from_ttf_bytes(bytes)?;
let chain  = FaceChain::new(face);

// 1. Shape: emit positioned vector glyph nodes.
let placed = Shaper::shape_to_paths(&chain, "Hello, world!", 16.0);

// 2. Wrap into a VectorFrame + render via oxideav-raster.
let mut root = Group::default();
for (_face_idx, glyph_node, transform) in placed {
    root.children.push(Node::Group(Group {
        transform,
        children: vec![glyph_node],
        ..Group::default()
    }));
}
let mut frame = VectorFrame::new(400.0, 80.0);
frame.root = root;
let rgba: oxideav_core::VideoFrame = Renderer::new(400, 80).render(&frame);

Capabilities

  • Outline accessFace::glyph_path(gid) returns a Y-up oxideav_core::Path (raw MoveTo / LineTo / QuadCurveTo / CubicCurveTo / Close). TT outlines decode quadratics; CFF charstrings decode cubics 1:1. Face::glyph_node(gid, size_px) bakes the Y-flip + scale into a render-ready Node::Path (or Node::Image for CBDT colour glyphs).
  • Shapercmap + GSUB type 4 (ligatures) + GPOS type 2 (pair kerning) + GPOS type 4/5/6 (mark-to-base, mark-to-mark stacking), enough for Latin / Cyrillic / Greek / basic CJK / Vietnamese / polytonic Greek.
  • GSUB feature-tag introspection (round 88)Face::gsub_features_for_script(script_tag, lang_tag) returns the four-byte feature tags the active face publishes under an OpenType script tag (in declaration order, required-feature first); Face::has_gsub_feature(script_tag, feature_tag) is the one-shot predicate variant. Useful for higher-level shaping APIs that gate behaviour on feature presence (e.g. enabling smcp small-caps only when the font ships it). Pure pass-through over oxideav-ttf's GSUB walker; OTF / GSUB-less faces return an empty vec. A GPOS introspection mirror is a known follow-up — GPOS-only features like kern are not visible through this accessor.
  • Explicit-script-tag caller-driven shaping (round 175)Face::shape_text_with_script(text, script_tag, features) -> Vec<u16> is the deterministic-resolution mirror of Face::shape_text: every requested feature is resolved against the explicit script_tag alone (no priority walk), so callers that already know the script of the run get a one-tag resolution without the cross-script collision risk the auto-probe walk has when two scripts publish the same feature tag (e.g. liga under both latn and arab). Two concrete uses: an Arabic shaper forcing liga / dlig against arab, and a CJK pipeline forcing vert / vrt2 against hani / kana / hang. An unknown script_tag yields cmap-identity. The companion auto-probe surface Face::shape_text now walks a broadened script-tag priority list — latncyrlgrekDFLTarabhebrthai / lao → Indic v1+v2 (deva / dev2, beng / bng2, taml / tml2, gujr / gjr2, guru / gur2, knda / knd2, mlym / mlm2, orya / ory2, telu / tel2, sinh) → khmrmymr / mym2hang / hani / kana — so non-Latin runs reach GSUB through the caller-driven surface for the first time. The round-15 four-tag prefix is preserved verbatim so existing Latin / Cyrillic / Greek / DFLT callers see no behaviour change.
  • Caller-driven Type-3 alternate-index selection (round 183)Face::shape_text_with_alternates(text, &[(feature_tag, alternate_index)]) and the explicit-script mirror Face::shape_text_with_script_and_alternates(text, script_tag, &[(feature_tag, alternate_index)]) let the caller name the AlternateSet entry the Type-3 (Alternate Substitution) walker picks per feature, instead of the round-156 hardcoded 0. Useful for salt / aalt / ss01..ss20 features whose AlternateSets ship more than one entry — e.g. face.shape_text_with_alternates( "Ag", &[(*b"salt", 2)]) asks for the third stylistic alternate for every salt-covered slot. Out-of-range indices fall back to cmap-identity per slot (the safe contract for callers that don't pre-probe per-font alternate counts; the underlying oxideav-ttf accessor returns None and we leave the slot unchanged). Length-preservation per OpenType §6.2.3 is invariant across indices. Non-Type-3 lookups (Type 1 / 2 / 4) dispatched by the same feature tag silently ignore the index — liga's Type-4 ligature collapse still works regardless of which alternate-index payload is attached. Two paired entry points inherit the round-89 / round-175 auto-probe vs explicit-script split: the auto-probe variant walks the broadened script-tag priority list, the explicit-script variant resolves against one named script tag for deterministic single-script resolution. Index 0 reproduces the round-156 default byte-for-byte — the round-183 surface is a strict superset.
  • Caller-driven GSUB LookupType 1 + 2 + 3 + 4 application (rounds 89 + 125 + 128 + 156)Face::shape_text(text, features) -> Vec<u16> cmap's the text, then applies every single-substitution (Type 1), multiple-substitution (Type 2), alternate-substitution (Type 3, default alternateIndex = 0), and ligature-substitution (Type 4) lookup the requested feature tags reference under latn / cyrl / grek / DFLT. OpenType §6.2.1 Format 1 (delta) + Format 2 (substitute-array), §6.2.2 Format 1 (Sequence-record splice, including the spec-legal glyphCount = 0 deletion form), §6.2.3 Format 1 (AlternateSet, first entry picked), and §6.2.4 Format 1 (LigatureSet / Ligature records, longest-match-first per the spec ordering rule) are dispatched through oxideav-ttf's gsub_apply_lookup_type_{1,2,3,4}. A Type-2 hit may change the returned glyph count (split / delete); a Type-3 hit is length-preserving (one alternate per covered slot); a Type-4 hit always shortens the run (N components → 1 ligature). ccmp "split precomposed glyph → base + combining mark" rules express through shape_text as well as the always-on shaping::general::apply_ccmp pass; liga / dlig / rlig collapse the standard / discretionary / required ligatures (e.g. fi / fl / ffi / ffl on DejaVu Sans) end-to-end on the caller- driven surface; aalt (Access All Alternates) now reshapes the slots its Type-3 lookup covers (e.g. the lowercase 'a' single-storey alternate on Inter Variable, the small set of aalt entries on DejaVu Sans). Useful for the display-toggled catalogue the round-15 ccmp / calt passes don't reach: smcp / c2sc, case, salt, aalt, frac, sups / subs / numr / dnom / ordn, ss01..ss20, cv01..cv99, zero, pnum / tnum, plus liga / dlig / rlig as of round 128. Features are applied in caller order. Lookups of other declared types (5 / 6 / 8) referenced by the requested features are silently skipped — contextual / chained-contextual / reverse-chained substitutions still flow through Shaper::shape / FaceChain::shape. Worked examples: on Inter Variable, face.shape_text("Hi", &[*b"smcp"]) returns [cmap("H"), smcp(cmap("i"))]; on DejaVu Sans, face.shape_text("fi", &[*b"liga"]) returns a single ligature-glyph id (the fi-ligature) instead of the 2-glyph cmap output; on Inter Variable, face.shape_text("a", &[*b"aalt"]) reshapes via the Type-3 alternate-0 (different glyph id from cmap('a'), length still 1) where previously the Type-3 lookup was silently skipped.
  • General-script GSUB features (round 15)shaping::general wires the OpenType required-feature ccmp (Glyph Composition / Decomposition) as a pre-ligature pass and calt (Contextual Alternates) as a post-ligature pass into shape_run_with_font for every Latin / Cyrillic / Greek / DFLT run. Lookups are dispatched per their declared GSUB type — types 1 / 2 / 3 / 4 / 5 / 6 / 8 are all routed via the appropriate Font::gsub_apply_lookup_type_N entry points (previously only type 4 ligatures were touched on Latin runs, and types 1 / 5 / 6 only via the per-script Indic / Arabic dispatchers). Concrete win against DejaVu Sans: chain.shape("i\u{0307}") now substitutes the dotless-i variant before the combining-above mark (matching the font's published ccmp rule). Coverage tables decide per-glyph whether each lookup fires — fonts without a ccmp / calt feature, or runs whose glyphs aren't in the lookup's coverage, are a no-op.
  • Arabic contextual joining (round 7)shaping::arabic picks isol / init / medi / fina per character via the Unicode joining-class state machine; FaceChain::shape rewrites Arabic letters into their Presentation Forms-B equivalents (U+FE70..U+FEFF) before cmap so cmap-only fonts render the visually-correct contextual shapes (including LAM-ALEF ligatures via the existing GSUB pass).
  • Indic + Brahmic non-Indic complex-script shaping (rounds 8 + 10 + 11 + 12 + 13)shaping::indic classifies Devanagari (U+0900..U+097F), Bengali (U+0980..U+09FF), Tamil (U+0B80..U+0BFF), Gurmukhi (U+0A00..U+0A7F), Gujarati (U+0A80..U+0AFF), Telugu (U+0C00..U+0C7F), Kannada (U+0C80..U+0CFF), Malayalam (U+0D00..U+0D7F), Oriya (U+0B00..U+0B7F), Sinhala (U+0D80..U+0DFF), Khmer (U+1780..U+17FF), Thai (U+0E00..U+0E7F), Lao (U+0E80..U+0EFF), and Myanmar / Burmese (U+1000..U+109F) codepoints, segments runs into orthographic clusters, applies per-script pre-base matra reorder, and identifies reph where applicable (Tamil + Malayalam + Sinhala + Khmer + Thai + Lao are reph-disabled; Burmese identifies a kinzi (NGA + Asat + Virama + Cons) instead via RephKind::BurmeseKinzi on BURMESE_RULES). When the active face publishes a rphf GSUB lookup for the active script, the leading RA glyph is rewritten to its reph form via Font::gsub_apply_lookup_type_1 and the halant glyph is dropped (round 10). Round 11 wires cluster-position-aware GSUB features: half for non-final consonants in conjuncts; pref / blwf / abvf / pstf (cascaded — first that returns a substitute wins) for post-halant consonants; and the presentation- pass features pres / psts / abvs / blws over every glyph in the cluster. Round 12 generalises the cluster machine over Khmer (where U+17D2 COENG plays the halant role and stacks subjoined consonants 2-3 deep in Pali borrowings), Sinhala (Indic-shaped halant with U+0DCA AL-LAKUNA), and Thai (no halant — pre-base vowels U+0E40..U+0E44 already in storage order). Round 13 adds Lao (structural twin of Thai) and Burmese (Asat U+103A killer + Virama U+1039 halant + medials U+103B..U+103E + pre-base sign-e U+1031 + kinzi reph-equivalent), plus a multi-glyph context-aware GSUB pass (apply_cluster_context_substitutions) that dispatches locl / nukt / akhn / cjct / init / haln via Font::gsub_apply_lookup_type_5 (Contextual) + gsub_apply_lookup_type_6 (Chained Context) at every position in every cluster. Coverage misses pass through unchanged so a font without a given lookup degrades gracefully. Per-script reorder rules are exposed as DEVANAGARI_RULES / BENGALI_RULES / TAMIL_RULES / GURMUKHI_RULES / GUJARATI_RULES / TELUGU_RULES / KANNADA_RULES / MALAYALAM_RULES / ORIYA_RULES / SINHALA_RULES / KHMER_RULES / THAI_RULES / LAO_RULES / BURMESE_RULES for callers reusing the cluster machine.
  • Variable fonts (round 9)Face::set_variation_coords / variation_axes / named_instances / is_variable surface the font's fvar declarations and let callers shape against a custom axis-coord vector (e.g. wght=600 / wdth=125 on Inter Variable). Shaper::with_variation_coords(vec![..]).shape_to_paths(&mut chain, text, size_px) is the per-call override path: it installs the coords on the primary face, runs the shape, then restores. Glyph outlines flow through oxideav-ttf's gvar interpolator so the emitted Path carries the blended deltas. CFF2 charstring evaluation (TN5177 v3 with the blend operator) is deferred — scribe parses the CFF2 INDEX walker for table presence + axis count + glyph count via Face::cff2(), but doesn't yet emit variation-blended cubic outlines from CFF2 charstrings.
  • Variable-font metrics + style attributes (round 14 / #454) — full surface for the four metric-variation tables. Face::mvar() parses the global metric-variation table; metric_delta(b"hasc") returns the ascender delta in font units at the current variation coords (similar accessors for cpht cap-height, xhgt x-height, undo underline offset, unds underline size, and the rest of the OpenType MVAR ValueTag set). Face::hvar() / h_advance_delta(gid) exposes per-glyph horizontal-advance variations; Face::vvar() / v_advance_delta(gid) is the vertical mirror (returns None / 0.0 for the horizontal-only fonts that omit VVAR). Face::stat() / stat_axes() / stat_axis_values() parse the Style Attributes table — design axis labelling (wght 400 → "Regular", wght 700 → "Bold") surfaced as metadata for downstream callers. All four tables share an ItemVariationStore parser + a DeltaSetIndexMap parser — both live in crate::variations.
  • name-id resolution (round 14)Face::name_id(nid) returns the highest-ranked Unicode string for a name-table id, with the same priority the underlying TTF parser uses (Windows English first, Mac Roman English second, anything Unicode-y after, then any remaining record). Resolves axis_name_id / subfamily_name_id (from fvar) and value_name_id (from STAT) without forcing callers to reach into Face::with_font.
  • Vector text APIShaper::shape_to_paths returns one (face_idx, Node, Transform2D) per visible glyph. Each node is wrapped in an oxideav_core::Group { cache_key: Some(_), .. } so the downstream rasterizer's bitmap cache memoises the rendered glyph across renders, frames, and renderer instances.
  • Italic synthesisstyle.italic synthesises a 12° forward shear when the face is upright; falls back to the font's own slant when one is present. Bold synthesis is deferred to consumer code (or a real Bold face).
  • Face chain — multi-face fallback for missing codepoints; per-glyph face_idx tells the consumer which face owns each glyph.
  • CBDT/CBLC colour bitmaps — Noto Color Emoji and friends decode to Node::Image carrying a VideoFrame; the resampling to the requested size happens in scribe (bilinear, straight-alpha).
  • Layout — line measurement + word-wrap (no full bidi reorder yet; round 186 lands the foundation surface, see next bullet).
  • BiDi foundation (round 186)bidi::bidi_class(c) returns the UAX #9 §3.2 normative bidirectional class for every code point scribe needs today (the 12 explicit-format controls in full, ASCII / Latin-1, Hebrew, four core Arabic blocks + Syriac + Thaana + N'Ko + the two Arabic Presentation Forms blocks, Combining Diacritical Marks + Arabic NSM ranges). bidi::paragraph_level(s) implements UAX #9 rules P1 / P2 / P3 end-to-end: walks the text, skips the contents of any LRI / RLI / FSI ... PDI isolate region (nested arbitrarily deep), finds the first strong (L / R / AL) character, and returns the paragraph embedding level (0 for LTR or first-strong-L, 1 for first-strong-R-or-AL, default 0). bidi::split_paragraphs(s) is P1's split that keeps every type-B separator with the preceding paragraph (the returned slices concatenate back to s exactly). 21 unit + integration tests cover the explicit-format set, ASCII / Latin-1 / Hebrew / Arabic class assignments, isolate-skip with nested initiators, embedding initiators not skipping (only isolates do), and P3 default-when-no-strong-character.
  • BiDi weak-type resolution W1..W7 (round 191)bidi::resolve_weak_types(&mut classes, sos, eos) runs the UAX #9 §3.3.4 weak-type pass over one isolating run sequence in place. classes is a mutable slice of BidiClass values (the per- character classification from bidi_class); sos / eos are the start- and end-of-sequence strong types (L or R, derived from the paragraph embedding level by the X1 stack frame in a future round — for callers that have not yet wired X1..X10, passing BidiClass::L for paragraph level 0 and BidiClass::R for level 1 is correct for a single-paragraph no-isolate run). Rules applied in order: W1 NSM inherits the type of the preceding character (or ON when the preceding is LRI / RLI / FSI / PDI; consecutive NSMs all flip to the same type because the second NSM, after the first iteration's rewrite, sees the first one); W2 EN whose most-recent strong type is AL becomes AN; W3 every remaining AL becomes R; W4 a single ES or CS between two ENs collapses to EN, a single CS between two ANs collapses to AN; W5 runs of ET adjacent to an EN on either side collapse to EN; W6 every leftover ES / ET / CS becomes ON; W7 EN whose most-recent strong (L / R / sos) is L becomes L. After the call the slice contains no AL (W3 ate them) and no leftover ES / ET / CS (W6 ate them), so the N-rules can resolve neutrals against a clean weak-type vocabulary. 14 unit + 11 integration tests cover every rule's spec example (AL EN → R AN, AL NI EN → R NI AN, EN ES EN → EN EN EN, AN CS AN → AN AN AN, EN CS AN → EN ON AN, ET ET EN → EN EN EN, AN ET EN → AN EN EN, L NI EN → L NI L, R NI EN → R NI EN, …), the W1 isolate-initiator → ON variant, the W4 negative cases (two consecutive ES don't collapse, AN ES AN does not collapse because W4's ES branch is EN-only, mixed-type CS doesn't collapse), the W5 non-EN-adjacent case (ET next to AN stays ET → W6 → ON), the W2-before-W3 ordering, and a full Arabic phone-number-style pipeline. BidiClass::is_neutral_or_isolate() exposes the §3.3.5 NI alias predicate for the upcoming N-rules. N / I / X / L rules remain deferred.
  • BiDi neutral-type resolution N1 + N2 (round 198)bidi::resolve_neutral_types(&mut classes, embedding_level, sos, eos) runs the UAX #9 §3.3.5 neutral / isolate-formatting pass over one isolating run sequence in place. The slice is expected to be the output of resolve_weak_types (no AL left, no leftover ES / ET / CS). The implementation walks every maximal contiguous run of NI elements (B / S / WS / ON / LRI / RLI / FSI / PDI) and resolves it with N1 when the strong type on both sides agrees — EN and AN count as R per the spec's "European and Arabic numbers act as if they were R", and the strong-side walk skips over NSM / BN (which are non-strong but also non-NI); falls back to sos / eos at the sequence boundaries — or with N2 (the embedding direction, L for even embedding_level, R for odd) when the strong context differs. After the pass every NI has been resolved to a strong direction; NSM and BN survive untouched (the §3.3.6 implicit- level pass handles them). 10 unit + 11 integration tests cover every spec example (L NI L → L L L, R NI R → R R R, the full R/AN/EN N1 table with EN/AN counting as R, the N2 mismatch table at both embedding levels, the full NI alias collapsing in one run, NSM / BN pass-through across NI boundaries, sos / eos driving boundary-spanning runs, idempotence on NI-free slices, and the compose-with-W realistic Arabic + numbers pipeline). N0 (bracket-pair resolution per §3.1.3 + §3.3.5) is deferred — it requires the Unicode BidiBrackets.txt data file to identify opening / closing paired brackets, which is not yet vendored under docs/. The N1 / N2 surface is forward- compatible: an N0 implementation lands as a pre-N1 pass that turns bracket positions into strong types, after which N1 / N2 continues to apply unchanged (the spec narrative confirms this in the closing N0 note: "if the enclosed text contains no strong types the bracket pairs will both resolve to the same level when resolved individually using rules N1 and N2"). I / X / L rules remain deferred.

Out of scope

  • Pixel work — bitmap rasterisation, alpha compositing, synthetic bold dilation, stroke dilation. All in oxideav-raster.
  • Bidi (UAX #9) W / N / I / X / L rules, CFF2 variable charstrings (the blend operator in TN5177 v3 — scribe parses the CFF2 INDEX walker, but doesn't yet emit variation-blended cubic outlines), TrueType bytecode hinting, subpixel LCD filtering, GPOS cursive attachment — deferred.

Test fixtures

Reuses crates/oxideav-ttf/tests/fixtures/DejaVuSans.ttf plus DejaVuSansMono.ttf (Bitstream Vera license), crates/oxideav-otf/tests/fixtures/SourceSans3-Regular.otf (SIL OFL), and a vendored copy of InterVariable.ttf (SIL OFL — see tests/fixtures/INTER-OFL-LICENSE.txt) for the round-9 variable-font suite. Network-gated emoji/CJK fixtures fetch on demand; see tests/font_fixtures/ and run with OXIDEAV_NETWORK_TESTS=1.

License

MIT — see LICENSE.