oxideav-scribe
Pure-Rust vector font shaper + line layout for the
oxideav framework. Parses TTF / OTF
tables (via oxideav-ttf
oxideav-otf) and emits positioned glyphs asoxideav-coreNodevectors ready for the rasterizer inoxideav-raster.
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 ;
use Renderer;
use ;
let bytes = read?;
let face = from_ttf_bytes?;
let chain = new;
// 1. Shape: emit positioned vector glyph nodes.
let placed = shape_to_paths;
// 2. Wrap into a VectorFrame + render via oxideav-raster.
let mut root = default;
for in placed
let mut frame = new;
frame.root = root;
let rgba: VideoFrame = new.render;
Capabilities
- Outline access —
Face::glyph_path(gid)returns a Y-upoxideav_core::Path(rawMoveTo/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-readyNode::Path(orNode::Imagefor CBDT colour glyphs). - Shaper —
cmap+ 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. enablingsmcpsmall-caps only when the font ships it). Pure pass-through overoxideav-ttf's GSUB walker; OTF / GSUB-less faces return an empty vec. A GPOS introspection mirror is a known follow-up — GPOS-only features likekernare 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 ofFace::shape_text: every requested feature is resolved against the explicitscript_tagalone (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.ligaunder bothlatnandarab). Two concrete uses: an Arabic shaper forcingliga/dligagainstarab, and a CJK pipeline forcingvert/vrt2againsthani/kana/hang. An unknownscript_tagyields cmap-identity. The companion auto-probe surfaceFace::shape_textnow walks a broadened script-tag priority list —latn→cyrl→grek→DFLT→arab→hebr→thai/lao→ Indic v1+v2 (deva/dev2,beng/bng2,taml/tml2,gujr/gjr2,guru/gur2,knda/knd2,mlym/mlm2,orya/ory2,telu/tel2,sinh) →khmr→mymr/mym2→hang/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 mirrorFace::shape_text_with_script_and_alternates(text, script_tag, &[(feature_tag, alternate_index)])let the caller name theAlternateSetentry the Type-3 (Alternate Substitution) walker picks per feature, instead of the round-156 hardcoded0. Useful forsalt/aalt/ss01..ss20features 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 everysalt-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 underlyingoxideav-ttfaccessor returnsNoneand 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, defaultalternateIndex = 0), and ligature-substitution (Type 4) lookup the requested feature tags reference underlatn/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-legalglyphCount = 0deletion 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 throughoxideav-ttf'sgsub_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 throughshape_textas well as the always-onshaping::general::apply_ccmppass;liga/dlig/rligcollapse 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 ofaaltentries on DejaVu Sans). Useful for the display-toggled catalogue the round-15ccmp/caltpasses don't reach:smcp/c2sc,case,salt,aalt,frac,sups/subs/numr/dnom/ordn,ss01..ss20,cv01..cv99,zero,pnum/tnum, plusliga/dlig/rligas 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 throughShaper::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 fromcmap('a'), length still 1) where previously the Type-3 lookup was silently skipped. - General-script GSUB features (round 15) —
shaping::generalwires the OpenType required-featureccmp(Glyph Composition / Decomposition) as a pre-ligature pass andcalt(Contextual Alternates) as a post-ligature pass intoshape_run_with_fontfor 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 appropriateFont::gsub_apply_lookup_type_Nentry 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 publishedccmprule). Coverage tables decide per-glyph whether each lookup fires — fonts without accmp/caltfeature, or runs whose glyphs aren't in the lookup's coverage, are a no-op. - Arabic contextual joining (round 7) —
shaping::arabicpicksisol/init/medi/finaper character via the Unicode joining-class state machine;FaceChain::shaperewrites 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::indicclassifies 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 viaRephKind::BurmeseKinzionBURMESE_RULES). When the active face publishes arphfGSUB lookup for the active script, the leading RA glyph is rewritten to its reph form viaFont::gsub_apply_lookup_type_1and the halant glyph is dropped (round 10). Round 11 wires cluster-position-aware GSUB features:halffor non-final consonants in conjuncts;pref/blwf/abvf/pstf(cascaded — first that returns a substitute wins) for post-halant consonants; and the presentation- pass featurespres/psts/abvs/blwsover 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 dispatcheslocl/nukt/akhn/cjct/init/halnviaFont::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 asDEVANAGARI_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_RULESfor callers reusing the cluster machine. - Variable fonts (round 9) —
Face::set_variation_coords/variation_axes/named_instances/is_variablesurface the font'sfvardeclarations and let callers shape against a custom axis-coord vector (e.g.wght=600 / wdth=125on 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 throughoxideav-ttf's gvar interpolator so the emittedPathcarries the blended deltas. CFF2 charstring evaluation (TN5177 v3 with theblendoperator) is deferred — scribe parses the CFF2 INDEX walker for table presence + axis count + glyph count viaFace::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 forcphtcap-height,xhgtx-height,undounderline offset,undsunderline 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 (returnsNone/0.0for 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 anItemVariationStoreparser + aDeltaSetIndexMapparser — both live incrate::variations. name-id resolution (round 14) —Face::name_id(nid)returns the highest-ranked Unicode string for aname-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). Resolvesaxis_name_id/subfamily_name_id(fromfvar) andvalue_name_id(from STAT) without forcing callers to reach intoFace::with_font.- Vector text API —
Shaper::shape_to_pathsreturns one(face_idx, Node, Transform2D)per visible glyph. Each node is wrapped in anoxideav_core::Group { cache_key: Some(_), .. }so the downstream rasterizer's bitmap cache memoises the rendered glyph across renders, frames, and renderer instances. - Italic synthesis —
style.italicsynthesises 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_idxtells the consumer which face owns each glyph. - CBDT/CBLC colour bitmaps — Noto Color Emoji and friends decode to
Node::Imagecarrying aVideoFrame; 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 anyLRI/RLI/FSI...PDIisolate region (nested arbitrarily deep), finds the first strong (L/R/AL) character, and returns the paragraph embedding level (0for LTR or first-strong-L,1for first-strong-R-or-AL, default0).bidi::split_paragraphs(s)is P1's split that keeps every type-Bseparator with the preceding paragraph (the returned slices concatenate back tosexactly). 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.classesis a mutable slice ofBidiClassvalues (the per- character classification frombidi_class);sos/eosare the start- and end-of-sequence strong types (LorR, derived from the paragraph embedding level by the X1 stack frame in a future round — for callers that have not yet wired X1..X10, passingBidiClass::Lfor paragraph level 0 andBidiClass::Rfor level 1 is correct for a single-paragraph no-isolate run). Rules applied in order: W1 NSM inherits the type of the preceding character (orONwhen the preceding isLRI/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); W2ENwhose most-recent strong type isALbecomesAN; W3 every remainingALbecomesR; W4 a singleESorCSbetween twoENs collapses toEN, a singleCSbetween twoANs collapses toAN; W5 runs ofETadjacent to anENon either side collapse toEN; W6 every leftoverES/ET/CSbecomesON; W7ENwhose most-recent strong (L/R/sos) isLbecomesL. After the call the slice contains noAL(W3 ate them) and no leftoverES/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 ofresolve_weak_types(noALleft, no leftoverES/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 —ENandANcount asRper the spec's "European and Arabic numbers act as if they were R", and the strong-side walk skips overNSM/BN(which are non-strong but also non-NI); falls back tosos/eosat the sequence boundaries — or with N2 (the embedding direction,Lfor evenembedding_level,Rfor odd) when the strong context differs. After the pass every NI has been resolved to a strong direction;NSMandBNsurvive 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/BNpass-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 UnicodeBidiBrackets.txtdata file to identify opening / closing paired brackets, which is not yet vendored underdocs/. 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
blendoperator 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.