oxideav-scribe 0.1.4

Pure-Rust font rasterizer + shaper + layout for the oxideav framework — TrueType outline flattening, scanline anti-aliasing, GSUB ligatures, GPOS kerning
Documentation

oxideav-scribe

Pure-Rust font rasterizer + simple shaper + line layout for the oxideav framework. Sits on top of oxideav-ttf (outlines, GSUB/GPOS lookups) and oxideav-pixfmt (Porter-Duff alpha compositing) to turn a UTF-8 string + a face into an RGBA bitmap suitable for a subtitle track or a scene compositor.

Round-2 scope (this release)

Round 2 closes the four deferrals flagged by the subtitle integration:

  • Synthesised italicStyle { italic: bool, weight: u16 } plumbed through shaping + rasterisation. When the requested style is italic AND the underlying face's post.italicAngle is ~0 (upright), the rasterizer applies a 12° horizontal shear at outline-flatten time; when the face is already italic, no double-shear happens.
  • Per-run colour — verified end-to-end: cached glyph bitmaps are alpha-only, the run colour mixes in at compose time. Two back-to-back calls with different colours produce different RGB at glyph pixels but identical alpha shape.
  • Font fallbackFaceChain walks an ordered list of faces. For each codepoint, the first face whose glyph_index returns a real (non-.notdef) glyph wins; if no face has it, the primary's .notdef is used. PositionedGlyph::face_idx tells the rasterizer which face to fetch the outline from.
  • Text outline / strokecompose_run_with_stroke paints a dilated alpha-mask underneath the fill, matching the \bord semantics that mpv / libass and the ffmpeg subtitles filter ship. Round 2 uses circular max-filter dilation (fast, looks identical to ffmpeg at typical 1–3 px bord values); true offset-curve geometry is a round-3 lift.

Round-1 scope (still in)

  • Outline flattening — quadratic-Bezier subdivision via the classic de Casteljau split (chord tolerance 0.5 px). Implements the TrueType implicit-on-curve rule for adjacent off-curve handles, plus the Apple "all-off-curve" synthetic-start fallback. Now optionally honours a horizontal shear for synthetic italic.
  • Scanline rasterisation — active-edge-list fill with 4× vertical supersampling for anti-aliasing. Even-odd fill rule.
  • Shapercmap mapping with .notdef fallback for unmapped codepoints. Ligature substitution via GSUB type 4. Pair kerning via GPOS type 2 with legacy kern table fallback.
  • Composer — Porter-Duff "over" via oxideav_pixfmt::blit_alpha_mask with straight-alpha destinations.
  • Layout — line measurement + word-wrap. Tokenises on whitespace and falls back to character-level breaks if a single word overflows.
  • LRU cache — glyph bitmap reuse keyed by (face_id, glyph_id, size_q8, shear_q14); default capacity 256 covers a typical subtitle session at >95% hit rate. Shear key component keeps synthesised-italic glyphs out of the upright slot.

Public API

use oxideav_scribe::{
    render_text, render_text_styled, render_text_wrapped,
    Composer, Face, FaceChain, RgbaBitmap, Shaper, Style, StrokeStyle, WHITE,
};

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

// Round-1 entry point — defaults to upright Regular.
let bitmap: RgbaBitmap = render_text(&face, "Hello, world!", 16.0, WHITE)?;

// Round-2: italic + weight via Style.
let italic = render_text_styled(&face, "Hello, world!", 16.0, WHITE, Style::italic())?;

// Word-wrap to a max width; one bitmap per output line.
let lines = render_text_wrapped(&face, "Some long subtitle text", 16.0, WHITE, 200.0)?;

// Lower-level: shape once, then compose into a destination you allocate.
let glyphs = Shaper::shape(&face, "AVATAR", 32.0)?;
let mut dst = RgbaBitmap::new(400, 80);
let mut composer = Composer::new();
composer.compose_run(&glyphs, &face, 32.0, WHITE, &mut dst, 0.0, face.ascent_px(32.0))?;

// Multi-face fallback chain.
let cjk_face = Face::from_ttf_bytes(std::fs::read("NotoCJK.otf")?)?;
let chain = FaceChain::new(face).push_fallback(cjk_face);
let glyphs = chain.shape("Hello 日本語", 16.0)?;

// Stroked subtitle text (\bord 2 + white fill on black border).
let stroke = StrokeStyle::new(2.0, [0, 0, 0, 255]);
composer.compose_run_with_stroke(
    &glyphs, &chain, 16.0, Style::REGULAR,
    [255, 255, 255, 255], Some(stroke),
    &mut dst, 0.0, 32.0,
)?;

Out of scope (round 3+)

  • Bidi (UAX #9) — left-to-right only; bidi resolution is round 3.
  • Arabic shaping — joining types, connection tables, mandatory ligatures; round 3.
  • Indic conjunct formation — reordering + half-form selection; round 3.
  • Variable fontsfvar / gvar / MVAR; round 3.
  • TrueType bytecode hinting — modern AA at ≥ 16 px does not need it.
  • CFF / Type 2 charstringsoxideav-otf carries the cubic-Bezier outline pipeline.
  • Mark-to-base / mark-to-mark attachment (GPOS types 4/5/6) — reserved by PositionedGlyph::y_offset; round 3.
  • Subpixel positioning — round 3 once we wire up RGB / BGR LCD filtering.
  • Synthetic boldStyle.weight is carried through the cache key but no synthesis pass runs yet; round 3 will dilate the alpha mask in proportion to the (weight - 400) delta.
  • True offset-curve stroke geometry — current stroke uses alpha-mask dilation; round 3 may add geometric Minkowski-sum mode for cases where exact corners matter at large bord widths.

Test fixture

Reuses crates/oxideav-ttf/tests/fixtures/DejaVuSans.ttf plus DejaVuSansMono.ttf (Bitstream Vera license). The integration tests check rasterised output dimensions, shaper glyph counts, kerning shrinkage on AVATAR, the fi ligature on office, italic-shear widening of upright glyphs, per-run colour preservation, font-fallback routing, and stroke dilation. A two-script-coverage fallback test (Latin primary + CJK fallback) is deferred until a small CJK fixture lands in oxideav-ttf — Noto Sans CJK at 10 MB is too big to vendor for one test.

License

MIT — see LICENSE.