Skip to content

Instantly share code, notes, and snippets.

@simoncozens
Created September 16, 2026 20:29
Show Gist options
  • Select an option

  • Save simoncozens/9d25895a26764e3f96393c2ecd3007ed to your computer and use it in GitHub Desktop.

Select an option

Save simoncozens/9d25895a26764e3f96393c2ecd3007ed to your computer and use it in GitHub Desktop.
rust-fontations-patterns/SKILL.md
name rust-fontations-patterns
description Idioms for reading, manipulating and writing OpenType fonts in Rust with the fontations crates (read-fonts, skrifa, write-fonts and fontdrasil). Use this when writing any Rust code that parses font tables, draws outlines, measures glyphs, works with design-space coordinates/locations (including avar), edits layout tables (GSUB/GPOS/GDEF/STAT) or builds/modifies font binaries. No project-specific abstractions — only the public crate APIs.

OpenType Font Manipulation in Rust (fontations)

Idioms for the fontations family of crates, as used in real-world font tooling:

Crate Version Role
read-fonts 0.43.3 Low-level, zero-copy reading of font tables (FontRef, TableProvider, raw table structs).
skrifa 0.46.2 High-level API: outlines, metrics, variation, glyph names, charmaps. Re-exports read-fonts as skrifa::raw.
write-fonts 0.52.0 Building and modifying font binaries (FontBuilder, owned table structs).
fontdrasil 1.0.0 Design-space coordinates, CoordConverter (piecewise-linear maps, avar-style).
font-types 0.12.5 Shared scalar types (Tag, Fixed, GlyphId, BigEndian, NameId…).

font-types is the foundation: read-fonts::types and write-fonts::types are the same crate re-exported twice. skrifa::raw is read-fonts.

Choose crates by task:

  • Read a raw table / fieldread-fonts (FontRef + TableProvider).
  • Draw, measure, get metrics/glyph-names/charmap, resolve variationskrifa.
  • Build or modify a fontwrite-fonts (and ToOwnedTable to go from read to write).
  • Nonlinear coordinate math / avar-style mapsfontdrasil.

1. Import paths

// read-fonts — raw access
use read_fonts::{FontRef, TableProvider, TopLevelTable, ReadError, FontData, FontRead};
use read_fonts::{ArrayOfOffsets, ArrayOfNullableOffsets, Offset, ResolveOffset};
use read_fonts::types::{Tag, Fixed, GlyphId, GlyphId16, BigEndian, NameId, LongDateTime, F2Dot14};
use read_fonts::tables::{gsub::..., gpos::..., gdef::..., stat::..., head::..., os2::...};
use read_fonts::tables::name::NameString; // etc.

// skrifa — high-level (FontRef and raw types are re-exported here too)
use skrifa::raw::{ReadError, TableProvider};          // == read_fonts
use skrifa::{GlyphId, GlyphId16, Tag, GlyphNames, MetadataProvider};
use skrifa::string::StringId;
use skrifa::setting::{Setting, VariationSetting};
use skrifa::prelude::{LocationRef, NormalizedCoord, Size};
use skrifa::outline::{DrawSettings, OutlinePen, OutlineGlyph, OutlineGlyphCollection};
use skrifa::{Axis, AxisCollection, NamedInstance, NamedInstanceCollection};

// write-fonts — building / writing
use write_fonts::FontBuilder;
use write_fonts::from_obj::ToOwnedTable;
use write_fonts::tables::{name::{Name, NameRecord}, cmap::Cmap, os2::Os2, head::Head};
use write_fonts::types::{Tag, Fixed, GlyphId, BigEndian, NameId};
use write_fonts::validate::Validate;
use write_fonts::{FontWrite, TableWriter, dump_table, OffsetMarker, NullableOffsetMarker};
use write_fonts::BuilderError;

// fontdrasil — design space / coordinates
use fontdrasil::coords::{
    Coord, CoordConverter, DesignCoord, UserCoord, NormalizedCoord,
    DesignSpace, UserSpace, NormalizedSpace,
    Location, DesignLocation, UserLocation, NormalizedLocation, ConvertSpace,
};
use fontdrasil::types::{Axes, Axis};

FontRef is the same type everywhere: read_fonts::FontRef, skrifa::FontRef, and skrifa::font::FontRef all resolve to read_fonts::FontRef.


2. Shared scalar types (font-types)

read_fonts::types == write_fonts::types == font_types. There is exactly one Tag, one Fixed, one GlyphId — no cross-crate conversions needed.

Type Backing Notes
Tag [u8; 4] Tag::new(b"OS/2") (panics if not 4 bytes), Tag::new_checked(bytes) -> Result, FromStr ("wght".parse()), .to_be_bytes(), Display
GlyphId u32 general-purpose id used by skrifa's high-level API
GlyphId16 u16 id as stored in binary tables (coverage, class defs, GSUB/GPOS output)
GlyphId24 24-bit rare; some table formats
Fixed 16.16 Fixed::from_f64(12.0), .to_f32(), .to_f64(), .to_bits(), .from_bits()
F2Dot14 2.14 normalized coordinates; this is skrifa's NormalizedCoord
F26Dot6, F48Dot16 other fixed-point formats
BigEndian<T> scalar wraps a scalar in big-endian byte order; seen as &[BigEndian<Tag>]; unwrap with .get(), wrap with .new() / From<T>
NameId u16 name-table id, NameId::new(u16); constants e.g. NameId::FAMILY_NAME
LongDateTime i64 seconds since 1904-01-01
Offset16/24/32 offsets; .get() / ResolveOffset
Nullable<T> nullable offset/marker

Glyph ID conversions

GlyphId::from(gid16)         // GlyphId16 -> GlyphId (From, infallible)
gid16.into()                 // same
GlyphId::from(u32)           // also From<u16> and From<GlyphId24>
GlyphId16::try_from(gid)     // GlyphId -> GlyphId16 (TryFrom, fallible)
gid16.to_u32()               // raw value

GlyphId implements PartialEq<GlyphId16> and PartialOrd<GlyphId16> (and vice versa), so you can compare the two directly. Read glyph ids out of raw subtables as GlyphId16; convert to GlyphId when calling skrifa's outline/metric APIs.


3. Opening fonts

let font = FontRef::new(&bytes)?;                 // single font (sfnt)
let glyph_count: usize = font.maxp()?.num_glyphs().into();

For font collections (.ttc):

// iterate every font in a collection
for result in FontRef::fonts(&bytes) {
    let font = result?;
}

// or pick one by index
let font = FontRef::from_index(&bytes, 0)?;

FontRef::fonts works for single fonts too (returns one item). FileRef is the lower-level entry point if you need to inspect the collection wrapper itself.


4. Two access layers: raw (TableProvider) vs high-level (MetadataProvider)

read-fonts gives you raw, zero-copy table structs through the TableProvider trait. skrifa layers a semantic API on top through MetadataProvider.

use read_fonts::TableProvider;   // raw: font.os2(), font.head(), font.gsub() ...
use skrifa::MetadataProvider;    // high-level: font.axes(), font.charmap(), font.glyph_metrics() ...

TableProvider methods (each returns Result<T, ReadError>, mostly):

os2() head() hhea() hmtx() maxp() name() post() cmap() glyf() loca() gvar() fvar() avar() gsub() gpos() gdef() stat() base() cff() cff2() colr() gasp() kern() vhea() vmtx() ...

MetadataProvider methods:

font.axes()                  // AxisCollection  (high-level variation axes)
font.named_instances()       // NamedInstanceCollection
font.localized_strings(id)   // LocalizedStrings (name table, localized)
font.metrics(size, loc)      // global metrics
font.glyph_metrics(size, loc)// per-glyph metrics
font.charmap()               // Charmap
font.outline_glyphs()        // OutlineGlyphCollection
font.attributes()            // Attributes (strikeout, underline, etc.)

Table presence / raw bytes:

font.has_table(b"STAT")                    // bool  (b"..." is &[u8; 4])
font.table_data(Tag::new(b"fvar"))         // Option<&[u8]>
font.table_directory.table_records()       // iterate raw table records

5. Container / offset wrapper types (read-fonts)

Raw tables expose offsets and arrays through a small set of wrapper types. Learn these and most traversal code becomes mechanical.

Type .iter() yields .get(idx)
ArrayOfOffsets<'a, T> Result<T, ReadError> Result<T, ReadError>
ArrayOfNullableOffsets<'a, T> Option<Result<T, ReadError>> Option<Result<T, ReadError>>
VarLenArray<'a, T> Result<T, ReadError> Option<Result<T, ReadError>>
ComputedArray<'a, T> Result<T, ReadError> Result<T, ReadError>

The single most common idiom is .iter().flatten() to collapse the Result/Option layers in one pass:

// ArrayOfOffsets<Lookup>
for lookup in gpos.lookup_list()?.lookups().iter().flatten() { /* Lookup */ }

// ArrayOfNullableOffsets<AxisValue>
for axis_value in stat.offset_to_axis_values()?.axis_values().iter().flatten() { /* AxisValue */ }

// zip coverage (Vec<GlyphId16>) with a parallel array of offsets
for (old_gid, seq) in coverage.iter().zip(sequences.iter().flatten()) { /* ... */ }

Offsets are read with ? (they return Result) or via .get() for nullable offsets. A nullable offset field is typically Option<Result<T, ReadError>> or returned through a method that itself returns Option<Result<...>>:

if let Some(Ok(subtable)) = stat.offset_to_axis_values() { ... }

coverage() tables (CoverageTable) iterate as GlyphId16. BigEndian<T> wraps a scalar in big-endian bytes and is unwrapped with .get() (e.g. &[BigEndian<GlyphId16>]glyph_id.get()).


6. Glyph IDs and glyph names

use skrifa::GlyphNames;

let names = GlyphNames::new(&font);
names.get(gid)                 // Option<GlyphName>; may be synthesized
names.iter()                   // Iterator over (GlyphId, GlyphName)

GlyphName::as_str() gives the name; GlyphName::is_synthesized() tells you whether it came from a post/CFF table or was fabricated (gidNNNN). The typical "only trust real names" pattern:

names.get(gid).and_then(|n| (!n.is_synthesized()).then(|| n.as_str().to_string()))

7. Charmap / codepoints

use skrifa::MetadataProvider;

font.charmap().map('A')          // Option<GlyphId>  (char -> glyph)
font.charmap().map(0x00A0_u32)   // also From<u32> / From<char>

// all (codepoint, glyph) mappings
for (codepoint, gid) in font.charmap().mappings() { /* u32, GlyphId */ }

// reverse: glyph -> codepoint
let reverse: HashMap<GlyphId, u32> = font.charmap().mappings().map(|(cp, g)| (g, cp)).collect();

To inspect the raw cmap table directly, use font.cmap()? (read_fonts::tables::cmap::Cmap), which exposes encoding_records() / subtables — but the high-level charmap() already deduplicates and handles all encodings, so prefer it.


8. Outlines and pens

skrifa draws glyphs through the OutlinePen trait, decoupling geometry from the binary outline format (glyf / CFF). Implement the five methods:

use skrifa::outline::OutlinePen;

impl OutlinePen for MyPen {
    fn move_to(&mut self, x: f32, y: f32) {}
    fn line_to(&mut self, x: f32, y: f32) {}
    fn quad_to(&mut self, cx: f32, cy: f32, x: f32, y: f32) {}
    fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {}
    fn close(&mut self) {}
}

Draw a glyph:

use skrifa::prelude::Size;
use skrifa::outline::DrawSettings;

let glyph = font.outline_glyphs().get(gid).unwrap();
let location = font.axes().location(settings);          // see §11
let draw = DrawSettings::unhinted(Size::unscaled(), &location);
glyph.draw(draw, &mut pen)?;

DrawSettings controls hinting and size; DrawSettings::unhinted(size, location) is the common choice for geometry checks. Size::unscaled() is used for font-unit work; use Size::new(ppem) for device work.

Common pens to write: an area/bounds pen, a contour-count pen, a "has ink" pen, or a converter to another path type (e.g. kurbo::BezPath). Each curve_to call means TrueType quadratic vs cubic depending on the outline source, which is why the trait surfaces both quad_to and curve_to.


9. Metrics

use skrifa::prelude::{LocationRef, Size};
use skrifa::MetadataProvider;

let metrics = font.glyph_metrics(Size::unscaled(), LocationRef::default());
metrics.advance(gid)         // Option<GlyphAdvance>  (width/height)
metrics.bounds(gid)          // Option<BoundingBox>   (x_min/y_min/x_max/y_max)

let global = font.metrics(Size::unscaled(), LocationRef::default());
global.cap_height; global.x_height; global.ascent; global.descent; // etc.

LocationRef::default() (or LocationRef::new(&[])) is the default instance. Pass a resolved Location (see §11) to measure at another point in design space.


10. Layout tables

GSUB

Walk LookupList -> Lookup -> subtables and match on the subtable format. The Subtables wrapper type is not re-exported by read-fonts, so alias it locally:

use read_fonts::tables::{
    gsub::{SubstitutionSubtables, ExtensionSubstFormat1, SingleSubst, MultipleSubstFormat1,
           AlternateSubstFormat1, LigatureSubstFormat1, ReverseChainSingleSubstFormat1},
    layout::Subtables,
};

// read-fonts doesn't export this alias, so copy it:
type SubSubtables<'a, T> = Subtables<'a, T, ExtensionSubstFormat1<'a, T>>;

let gsub = font.gsub()?;
for lookup in gsub.lookup_list()?.lookups().iter().flatten() {
    let subtables = lookup.subtables()?;   // SubstitutionSubtables
    match subtables {
        SubstitutionSubtables::Single(st) => { /* st.iter() -> SingleSubst */ }
        SubstitutionSubtables::Multiple(st) => { /* ... */ }
        SubstitutionSubtables::Alternate(st) => { /* ... */ }
        SubstitutionSubtables::Ligature(st) => { /* ... */ }
        SubstitutionSubtables::Reverse(st) => { /* ... */ }
        SubstitutionSubtables::Contextual(_) | SubstitutionSubtables::ChainContextual(_) => { /* ... */ }
        SubstitutionSubtables::Extension(_) => { /* ... */ }
    }
}

Inside SingleSubst, for example:

match subtable {
    SingleSubst::Format1(t) => {
        let delta = t.delta_glyph_id() as i32;
        // t.coverage()?.iter() yields GlyphId16
        for old_gid in t.coverage()?.iter() {
            let new_gid = GlyphId16::from((old_gid.to_u32() as i32 + delta) as u16);
        }
    }
    SingleSubst::Format2(t) => {
        // zip coverage with substitute_glyph_ids()
        for (old, new) in t.coverage()?.iter().zip(t.substitute_glyph_ids().iter()) {
            // new is BigEndian<GlyphId16>; use new.get()
        }
    }
}

Glyph-id arrays in raw subtables (substitute_glyph_ids, alternate_glyph_ids, component_glyph_ids, second_glyph) are BigEndian<GlyphId16> — call .get(). Coverage tables yield plain GlyphId16.

GPOS

Same lookup/list structure, but the subtable enum is PositionSubtables:

use read_fonts::tables::gpos::{PositionSubtables, PairPos, PairPosFormat1, PairPosFormat2};

for lookup in gpos.lookup_list()?.lookups().iter().flatten() {
    let subtables = lookup.subtables()?;   // PositionSubtables
    match subtables {
        PositionSubtables::Pair(p) => {
            for pp in p.iter().flatten() {
                match pp {
                    PairPos::Format1(f) => { /* f.coverage()?.iter() zip f.pair_sets().iter() */ }
                    PairPos::Format2(f) => { /* f.class_def1()? / class_def2()? + class1_records() */ }
                }
            }
        }
        _ => {}
    }
}

PairPos Format 1 pairs each covered glyph with a PairSet of second-glyph records; Format 2 uses class definitions — build HashMap<class, HashSet<GlyphId>> from class_def1()/class_def2() (skipping class 0), then iterate class1_records() × class2_records().

Feature records

use read_fonts::tables::layout::{Feature, FeatureRecord};

// Feature lists live on both GSUB and GPOS
let gsub_features = font.gsub().ok().and_then(|g| g.feature_list().ok());
if let Some(list) = gsub_features {
    for record in list.feature_records().iter() {
        let tag = record.feature_tag();              // e.g. "kern", "tnum"
        let feature: Feature = record.feature(list.offset_data())?;
        for index in feature.lookup_list_indices().iter() {
            let lookup = lookup_list.lookups().get(index.get() as usize)?;
        }
    }
}

GDEF

use read_fonts::tables::gdef::GlyphClassDef;

let class_def = font.gdef()?.glyph_class_def();    // Option<Result<ClassDef>>
// map GlyphId16 -> class; missing glyphs are GlyphClassDef::Unknown
let lig_carets = font.gdef()?.lig_caret_list();    // Option<Result<LigCaretList>>

11. STAT

STAT is awkward because of its four AxisValue formats. The recurring pattern:

use read_fonts::tables::stat::AxisValue;

let stat = font.stat()?;
let axes = stat.design_axes()?;                    // ArrayOfOffsets<AxisRecord>
let opsz_index = axes.iter().position(|a| a.axis_tag() == "opsz").map(|i| i as u16);

if let Some(Ok(subtable)) = stat.offset_to_axis_values() {
    for axis_value in subtable.axis_values().iter().flatten() {
        match axis_value {
            AxisValue::Format1(v) => { v.axis_index(); v.value(); v.value_name_id(); }
            AxisValue::Format2(v) => { v.axis_index(); v.nominal_value(); v.value_name_id(); }
            AxisValue::Format3(v) => { v.axis_index(); v.value(); v.value_name_id(); }
            AxisValue::Format4(v) => { for av in v.axis_values() { av.axis_index(); } }
        }
    }
}

Notes:

  • design_axes() returns raw axis records referenced by index (u16), not tag.
  • axis_value.flags().contains(AxisValueTableFlags::ELIDABLE_AXIS_VALUE_NAME).
  • Format 1/3 use value(); Format 2 uses nominal_value() and range_min_value()/range_max_value(); Format 4 is a set of sub-AxisValueRecords.

12. Design space, coordinates and locations

This is the subtlest area: two coordinate systems and two "Axis" types, plus an overloaded name (NormalizedCoord) that means different things in each crate.

skrifa's variation model

use skrifa::setting::{Setting, VariationSetting};   // { selector: Tag, value: f32 } (user coords)
use skrifa::prelude::{LocationRef, NormalizedCoord, Size}; // NormalizedCoord == F2Dot14

// High-level axes (skrifa::Axis, from axes()):
for axis in font.axes().iter() {
    axis.tag();           // Tag
    axis.min_value();     // f32
    axis.default_value(); // f32
    axis.max_value();     // f32
    axis.name_id();       // StringId
    axis.is_hidden();     // bool
    axis.normalize(x);    // f32 -> NormalizedCoord (F2Dot14)
}

// A location from user-space settings:
let location = font.axes().location(settings);  // settings: IntoIterator<Item: Into<VariationSetting>>
// location: skrifa::instance::Location = Vec<F2Dot14>

// Borrowed default location:
LocationRef::default();
LocationRef::new(&[]);

axes().location(settings) resolves user coords to normalized coords (applying fvar defaults and clamping); NamedInstance::location() does the same for a named instance. NamedInstance (from font.named_instances()) also has subfamily_name_id(), postscript_name_id() -> Option<StringId>, and user_coords() -> Iterator<f32>.

Raw fvar axes (different type!)

font.fvar()?.axes()?.iter() yields the raw fvar::Axis, whose accessors return Fixed and use axis_tag() (not tag()):

axis.axis_tag();         // Tag
axis.min_value();        // Fixed
axis.default_value();    // Fixed
axis.max_value();        // Fixed
axis.axis_name_id();     // NameId

Do not confuse skrifa::Axis (from axes(), returns f32) with the raw read_fonts::tables::fvar::Axis (returns Fixed).

fontdrasil's coordinate model

fontdrasil models three spaces — user, design, normalized — and a piecewise-linear converter between them (exactly what avar segment maps are):

use fontdrasil::coords::{Coord, CoordConverter, UserCoord, NormalizedCoord, DesignCoord,
                         UserLocation, NormalizedLocation, DesignLocation};
use fontdrasil::types::Axes;

// A converter is a piecewise-linear map user <-> normalized (design is optional):
let converter = CoordConverter::default_normalization(min, default, max); // linear
let converter = CoordConverter::new(mapping, default_idx);                // piecewise (avar)

// Locations are Vec<(Tag, Coord<Space>)>:
let norm: NormalizedLocation = /* ... */;
let user: UserLocation = norm.to_user(&axes)?;        // normalized -> user
let norm2: NormalizedLocation = user.to_normalized(&axes)?;
let design: DesignLocation = norm.to_design(&axes)?;

// A single coordinate:
let user = NormalizedCoord::new(0.5).to_user(&converter);  // Coord<UserSpace>

fontdrasil::types::Axis has fields converter: CoordConverter, tag: Tag, min/default/max: UserCoord, name, hidden, localized_names. Axes is a collection of these with .iter(), and provides the to_user/to_normalized/ to_design conversion methods on locations.

Building an Axes from a font (the avar bridge)

To get well-typed design-space math from a real font, reconstruct fontdrasil Axes from fvar (+ avar if present):

use fontdrasil::coords::{CoordConverter, DesignCoord, NormalizedCoord, UserCoord};
use fontdrasil::types::Axis;

// Collect avar segment maps once. Avar::axis_segment_maps() returns a
// VarLenArray<SegmentMaps>, whose .iter() yields Result<SegmentMaps, ReadError>.
let per_axis_maps: Vec<_> = match font.avar() {
    Ok(avar) => avar.axis_segment_maps().iter().collect::<Result<Vec<_>, _>>()?,
    Err(_) => vec![],
};

let axes: Axes = font.axes().iter().enumerate()
    .map(|(ix, a)| {
        let (min, default, max) = (
            UserCoord::new(a.min_value() as f64),
            UserCoord::new(a.default_value() as f64),
            UserCoord::new(a.max_value() as f64),
        );
        let mut fd_axis = Axis {
            converter: CoordConverter::default_normalization(min, default, max),
            tag: a.tag(),
            name: Default::default(),
            hidden: a.is_hidden(),
            min, default, max,
            localized_names: Default::default(),
        };
        // Optionally apply avar segment maps (per-axis index):
        if let Some(map) = per_axis_maps.get(ix) {
            let mapping: Vec<(UserCoord, DesignCoord)> = map
                .axis_value_maps
                .iter()
                .map(|m| {
                    // avar maps normalized->normalized; convert 'from' back to user
                    // space, and treat 'to' as design space (a common convention).
                    let user = NormalizedCoord::new(m.from_coordinate().to_f32() as f64)
                        .to_user(&fd_axis.converter);
                    let design = DesignCoord::new(m.to_coordinate().to_f32() as f64);
                    (user, design)
                })
                .collect();
            let default_idx = mapping.iter().position(|(_, d)| d.to_f64() == 0.0).unwrap_or(0);
            fd_axis.converter = CoordConverter::new(mapping, default_idx)
                .unwrap_or_else(|_| CoordConverter::default_normalization(min, default, max));
        }
        fd_axis
    })
    .collect();

Converting between skrifa and fontdrasil

Round-trip a normalized location through fontdrasil (for denormalization) and back into skrifa VariationSettings:

let user = normalized_location.to_user(&axes)?;          // fontdrasil
let settings: Vec<VariationSetting> = user.iter()
    .map(|(tag, value)| VariationSetting {
        selector: Tag::new(&tag.to_be_bytes()),
        value: value.to_f64() as f32,
    })
    .collect();
// now pass `settings` to font.axes().location(..), glyph.draw(..), etc.

VariationSetting is just Setting<f32> ({ selector: Tag, value: f32 }); any &[VariationSetting] (or iterator of Into<VariationSetting>) can be passed to axes().location, draw_glyph, etc.


13. Writing and modifying fonts

The read-modify-write pattern

use write_fonts::{from_obj::ToOwnedTable, FontBuilder};
use read_fonts::TableProvider;

let mut os2: write_fonts::tables::os2::Os2 = font.os2()?.to_owned_table();
os2.us_weight_class = 700;

let new_bytes = FontBuilder::new()
    .add_table(&os2)?                  // replaces/compiles the OS/2 table
    .copy_missing_tables(font)         // copies every table not already added
    .build();

ToOwnedTable converts a read-fonts table into its owned write-fonts equivalent — always prefer it over hand-copying fields. It is implemented for every table that has a write-fonts representation.

The three write-fonts traits:

  • FontWrite — the owned type knows how to serialize itself.
  • Validate — the owned type can check itself for invariants.
  • TopLevelTable (from read-fonts) — it is a top-level table with a 4-byte tag.

FontBuilder::add_table requires T: FontWrite + Validate + TopLevelTable.

Copying raw tables / removing & adding arbitrary tables

For tables with no write-fonts representation, or surgical remove/add, iterate the raw table directory and use add_raw:

let mut builder = FontBuilder::new();
for record in font.table_directory.table_records() {
    let tag = record.tag.get();
    if tag != tag_to_remove {
        if let Some(data) = font.table_data(tag) {
            builder.add_raw(tag, data);   // copy raw bytes verbatim
        }
    }
}
builder.add_raw(new_tag, &[0u8; 4]);      // add raw/dummy bytes
let new_bytes = builder.build();

Building common tables from scratch

use write_fonts::tables::name::{Name, NameRecord};
use write_fonts::tables::cmap::Cmap;
use write_fonts::tables::os2::Os2;
use write_fonts::types::{Fixed, NameId, Tag};

// name table
let records = vec![NameRecord::new(3, 1, 1033, NameId::new(1), "Family".to_string().into())];
let name = Name::new(records);            // or Name::default(); name.name_record = records

// cmap
let cmap = Cmap::from_mappings(
    mappings.into_iter().map(|(cp, gid)| (char::from_u32(cp).unwrap_or('\0'), gid)),
)?;

// OS/2
let os2 = Os2 { us_weight_class: 700, ..Default::default() };

Cmap::from_mappings accepts (char, GlyphId) pairs and picks the optimal subtable format(s) automatically.

A harder table: STAT

use write_fonts::tables::stat::{AxisRecord, AxisValue, AxisValueTableFlags, Stat};
use write_fonts::types::{Fixed, NameId, Tag};

let stat = Stat::new(
    vec![
        AxisRecord::new(Tag::new(b"wght"), NameId::new(256), 0),
        AxisRecord::new(Tag::new(b"opsz"), NameId::new(257), 1),
    ],
    vec![AxisValue::format_1(
        1,                                        // axis index
        AxisValueTableFlags::ELIDABLE_AXIS_VALUE_NAME,
        NameId::new(258),
        Fixed::from_f64(12.0),
    )],
    NameId::new(259),                             // elided fallback name id
);

A harder table: BASE

The BASE table is deeply nested. The write-fonts construction is:

Base::new(Option<Axis>, Option<Axis>) → each Axis::new(Option<BaseTagList>, BaseScriptList)BaseScriptList::new(Vec<BaseScriptRecord>)BaseScriptRecord::new(tag, BaseScript::new(Option<BaseValues>, Option<BaseMinMax>, Vec<BaseLangSysRecord>))BaseValues::new(default_index, Vec<BaseCoord>)BaseCoord::Format1(BaseCoordFormat1::new(i16)).

use write_fonts::tables::base as base;

let horiz = base::Axis::new(
    Some(base::BaseTagList::new(vec![Tag::new(b"romn"), Tag::new(b"ideo")])),
    base::BaseScriptList::new(vec![
        base::BaseScriptRecord::new(
            Tag::new(b"latn"),
            base::BaseScript::new(
                Some(base::BaseValues::new(
                    0,
                    vec![
                        base::BaseCoord::Format1(base::BaseCoordFormat1::new(0)),
                        base::BaseCoord::Format1(base::BaseCoordFormat1::new(800)),
                    ],
                )),
                None,
                vec![],
            ),
        ),
    ]),
);
let table = base::Base::new(Some(horiz), None);

For most "difficult" tables (BASE, COLR, GDEF with mark/ligature classes, STAT, GSUB/GPOS), the read side is one match over a big enum; the write side is one nested ::new(...) call. Use cargo doc -p write-fonts (or read write-fonts's tables/ source) to find the exact constructor signatures.

Building a minimal font for tests

let mut builder = FontBuilder::new();
builder.add_table(&Maxp::default()).unwrap();  // required for the result to parse as a font
builder.add_table(&name).unwrap();
let bytes = builder.build();

A font without maxp will not parse back with FontRef::new.


14. Gotchas

  • read_fonts::types == write_fonts::types == font_types. One scalar type system; ToOwnedTable handles whole-table conversion.

  • Two Axis types. High-level skrifa::Axis (axes(), f32, tag()) vs raw read_fonts::tables::fvar::Axis (fvar()?.axes(), Fixed, axis_tag()).

  • NormalizedCoord name collision. skrifa::prelude::NormalizedCoord is F2Dot14 (one number); fontdrasil::coords::NormalizedCoord is Coord<NormalizedSpace> (number + space type). They are unrelated.

  • GlyphId (u32) vs GlyphId16 (u16). Raw subtable arrays/covers yield GlyphId16; skrifa high-level APIs take GlyphId. GlyphId16 -> GlyphId is From; GlyphId -> GlyphId16 is TryFrom.

  • BigEndian<T> unwraps with .get(). Raw glyph-id arrays (substitute_glyph_ids, alternate_glyph_ids, etc.) are BigEndian<GlyphId16>; coverage tables yield plain GlyphId16.

  • ? on empty lookups can fail. lookup.subtables() returns Err for a lookup with zero subtables (read-fonts OutOfBounds). Use if let Ok(subtables) = lookup.subtables() when a lookup might legitimately be empty.

  • Tag::new panics on non-4-byte input. Use Tag::new_checked(bytes) for untrusted/unknown lengths, Tag::from_str("wght") for literal strings, and .to_be_bytes() when crossing into fontdrasil (Tag::new(&tag.to_be_bytes())).

  • .iter().flatten() collapses Result/Option layers on offset arrays and nullable offsets — the standard read-fonts iteration idiom.

  • Nullable offsets are Option<Result<T, ReadError>>. Unwrap the outer Option with if let Some(Ok(x)), not ?.

  • copy_missing_tables only copies tables you didn't add_table. Adding a table you want to keep recompiles it (that's the "replace this table" behavior); to drop a table, just don't add it and don't copy it.

  • Font collections. Use FontRef::from_index / FontRef::fonts for .ttc; FontRef::new is for single fonts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment