fontations 0.0.1 copy "fontations: ^0.0.1" to clipboard
fontations: ^0.0.1 copied to clipboard

Flutter bindings for the fontations font stack (read-fonts / skrifa), powered by flutter_rust_bridge.

fontations #

CI

English | 简体中文

Flutter bindings for the fontations font stack (read-fonts / skrifa), generated with flutter_rust_bridge.

It lets Dart code parse OpenType fonts without going through the platform text stack: read name / head / OS/2 / fvar data, look up glyphs, and extract scaled glyph outlines — including variable font instances.

Usage #

import 'package:flutter/services.dart' show rootBundle;
import 'package:fontations/fontations.dart';

Future<void> main() async {
  await initFontations(); // loads the native Rust library, call once

  final bytes = await rootBundle
      .load('assets/fonts/Inter.ttf')
      .then((data) => data.buffer.asUint8List());

  final font = Font.fromBytes(bytes: bytes);
  final info = font.info();
  print(info.names.family);          // Inter
  print(info.metrics.unitsPerEm);    // 2048
  print(info.axes.first.tag);        // opsz

  final glyphId = font.charToGlyph(codePoint: 'A'.codeUnitAt(0))!;
  final outline = await font.outline(
    glyphId: glyphId,
    size: 48,
    variations: [VariationSetting(tag: 'wght', value: 700)],
  );

  // Draw it with a CustomPainter.
  final path = outline.toPath();
}

Reading variable fonts #

Already supported on the read side: Font.axes() / Font.namedInstances() expose fvar, and Font.outline / Font.metrics accept a list of VariationSettings in user space, which are normalized through fvar/avar.

Reading any table #

Font.tableData hands out the raw bytes of whichever table you name, so nothing in a font is out of reach even when this plugin has no typed API for it (offsets inside the buffer are relative to the start of that table):

final gsub = font.tableData(tag: 'GSUB'); // Uint8List? — null when absent
print(font.hasTable(tag: 'MATH'));
print(font.tableLength(tag: 'CFF '));

For the two tables almost every consumer wants, Font.layout returns the structured view:

final gsub = font.layout(table: 'GSUB').single;
print(gsub.scripts);                                  // [DFLT, hani, latn, …]
print(gsub.features.map((f) => f.tag));               // [ss01, ss02, liga, …]
print(gsub.features.first.languages);                 // [dflt, latn , …]

Color glyphs (COLR) and embedded bitmaps #

COLR glyphs are flattened into layers that can be painted straight onto a Flutter canvas. Layer geometry is in font units, y up, so paint scales it by size / unitsPerEm and flips y:

if (font.hasColorGlyph(glyphId: glyphId)) {
  final glyph = font.colorGlyph(glyphId: glyphId, variations: const [])!;
  // In a CustomPainter:
  glyph.paint(canvas, size: 48, unitsPerEm: font.info().metrics.unitsPerEm);
}

A layer is a PathElement list plus a ColorPaint (solid, linear, radial or sweep gradient) whose stops are already resolved against the palette in font.colorPalettes(). Interested in emoji bitmaps instead? sbix, CBDT and EBDT strikes are exposed too, decoded into RGBA (or raw PNG for sbix):

for (final strike in font.bitmapStrikes()) {
  final bitmap = font.bitmapGlyph(glyphId: glyphId, ppem: strike.ppemX);
  if (bitmap != null) {
    // bitmap.pixels is RGBA (or PNG bytes when `pixelFormat == 'png'`).
    final image = await decodeImageFromPixels(...);
  }
}

Vertical text, names and attributes #

final attrs = font.attributes();          // weight 400..900, stretch %, style
print(font.glyphName(glyphId: 1));        // from `post` or `CFF`
print(font.nameRecords(nameId: 1));       // every localization of the family
print(font.verticalMetrics());            // `vhea`, null when absent
print(font.verticalOrigin(glyphId: 1));   // `VORG`, or `vhea` - top bearing

Shaping: applying GSUB and GPOS #

Font.layout describes the layout tables; Font.shape applies them. It maps a run of text through cmap, runs the substitutions and positions of a script / language / feature selection, and hands back every glyph with its advance and offset:

final result = font.shape(
  text: 'ffi',
  size: 48,
  variations: const [],
  features: ['liga', 'kern'],
);

var pen = 0.0;
for (final glyph in result.glyphs) {
  final outline = await font.outline(
    glyphId: glyph.glyphId,
    size: 48,
    variations: const [],
  );
  canvas.save();
  canvas.translate(pen + glyph.xOffset, baseline - glyph.yOffset);
  canvas.drawPath(outline.toPath(), paint);
  canvas.restore();
  pen += glyph.xAdvance;
}

cluster is the index of the character the glyph came from, so several glyphs can share one (a decomposition) and one glyph can cover several (a ligature).

script and language are four character OpenType tags; without them the font's DFLT (or latn) script and its default language system are used. features: null turns on the ones that are on by default — kern, liga, ccmp, mark, mkmk, rlig, calt, clig, curs, dist, rclt — and passing a list names exactly the features to apply. An empty list turns every feature off, which is what a "before shaping" comparison needs.

What it covers: GSUB single, multiple, alternate, ligature, reverse chain and the contextual and chained contextual lookups (all three formats, including the nested lookups they trigger); GPOS single, pair, cursive, mark-to-base, mark-to-ligature, mark-to-mark and the same two contextual lookups. A mark that follows a ligature attaches to its last component.

What it does not: Unicode script detection, normalization, and cluster splitting. Latin, Cyrillic and Greek are fine; for Arabic or Indic, use the platform text stack instead.

Math typesetting (MATH) #

MATH is what a formula layout needs: the constants, the per glyph corrections and the way a delimiter grows.

if (font.hasMath()) {
  final constants = font.mathConstants(ppem: 48)!;
  final axisHeight = constants.firstWhere((c) => c.name == 'axisHeight');
  // `value` is in design units, `deltaPx` is what the device table adds at 48px.

  final info = font.mathGlyphInfo(glyphId: glyphId, ppem: 48)!;
  print(info.italicsCorrection);   // how far a slanted glyph overhangs
  print(info.topAccentAttachment); // where an accent sits above it
  for (final kern in info.kerns) {
    print('${kern.corner}: ${kern.entries}'); // bands of (maxHeight, kern)
  }

  // Delimiters: ready made sizes, then the pieces to assemble.
  final variants = font.mathVariants(glyphId: parenId, axis: 'vertical')!;
  print(variants.variants.map((v) => v.advance));
  print(variants.assembly?.parts.map((p) => p.glyphId));
}

value and deltaPx are kept apart because they are in different units: a device table adjusts by whole pixels, and only the caller knows which space it is laying out in.

Variable composites (VARC) #

A VARC glyph owns no outline: it is a list of other glyphs with their own transforms, positions in variation space and conditions.

if (font.hasVarc()) {
  final glyph = font.varcComponents(glyphId: glyphId, variations: const [])!;
  for (final component in glyph.components) {
    if (!component.conditionMet) continue; // not drawn at this location
    // transform is [xx, yx, xy, yy, dx, dy] in font units.
    canvas.transform(Float64List.fromList(<double>[...component.transform, 0, 0, 1]));
    drawGlyph(component.glyphId);
  }
}

Variation selectors (cmap format 14) #

Some code points only pick a glyph together with a variation selector (U+E0100 and friends):

final mapped = font.mapVariant(codePoint: 0x845B, selector: 0xE0100);
if (mapped != null) {
  // `useDefault`: ignore the selector and use the character's own glyph.
  // `variant`: draw `mapped.glyphId` instead.
}
for (final entry in font.variantMappings()) {
  print('${entry.codePoint} + ${entry.selector} -> ${entry.glyphId}');
}

Axis remapping (avar and avar2) #

fvar normalizes a user coordinate linearly; avar then remaps it, and avar2 shifts it again from an item variation store. Font.avarInfo shows the maps and Font.normalizedCoords shows where a set of user settings ends up — the number gvar deltas are scaled by:

final avar = font.avarInfo()!;
print(avar.version);       // 1.0, or 2.0 when avar2 deltas are present
print(avar.axes.first.maps);

final coords = font.normalizedCoords(
  variations: [VariationSetting(tag: 'wght', value: 700)],
);

Everything that takes variations already goes through all three steps, so this is mainly for inspecting (and for debugging a font that does not interpolate the way you expect).

Writing a font #

buildFont compiles contours (in font units, y up) into a .ttf with write-fonts, static or variable. Combined with FontLoader you can draw a glyph and use it straight away:

final bytes = await buildFont(
  request: const FontBuildRequest(
    familyName: 'MyHandwriting',
    unitsPerEm: 1000,
    ascender: 800,
    descender: -200,
    glyphs: [
      GlyphSpec(
        codePoint: 0x4E00, // 一
        contours: [GlyphContour(points: [/* ... ContourPoint(x, y, onCurve) ... */])],
        advanceWidth: 1000,
      ),
    ],
  ),
);

await (FontLoader('MyHandwriting1')
      ..addFont(Future.value(ByteData.sublistView(bytes))))
    .load();
// Now `Text('一', style: TextStyle(fontFamily: 'MyHandwriting1'))` uses it.

Contours follow TrueType semantics: consecutive off-curve points get the implied on-curve midpoints. The example app's third tab is a complete draw-your-own-glyph demo built on this API.

Variable fonts #

Add axes to the request and give a glyph masters — one alternative outline per position in variation space — and the result becomes a real variable font with fvar + gvar:

final bytes = await buildFont(
  request: FontBuildRequest(
    familyName: 'MyHandwriting',
    unitsPerEm: 1000,
    ascender: 800,
    descender: -200,
    axes: const [
      VariationAxisSpec(
        tag: 'wght',
        name: 'Weight',
        minValue: 100,
        defaultValue: 400,
        maxValue: 900,
      ),
    ],
    instances: [
      NamedInstanceSpec(name: 'Light', coordinates: Float64List.fromList([100])),
      NamedInstanceSpec(name: 'Black', coordinates: Float64List.fromList([900])),
    ],
    glyphs: [
      GlyphSpec(
        codePoint: '一'.runes.first,
        contours: defaultOutlines,   // drawn at wght = 400
        advanceWidth: 1000,
        masters: [
          VariationMaster(coordinates: Float64List.fromList([100]), contours: thinOutlines),
          VariationMaster(coordinates: Float64List.fromList([900]), contours: thickOutlines),
        ],
      ),
    ],
  ),
);

Stylistic sets (ss01..ss20) #

An axis is continuous; ss01..ss20 (and cv01..) are discrete GSUB substitutions — the whole glyph is swapped, nothing interpolates. Use them when one character should offer several distinct designs that the text engine picks between:

GlyphSpec(
  codePoint: '一'.runes.first,
  contours: defaultOutlines,
  advanceWidth: 1000,
  alternates: [
    StylisticAlternate(featureTag: 'ss01', contours: outlinesA, advanceWidth: 600),
    StylisticAlternate(featureTag: 'ss02', contours: outlinesB, advanceWidth: 200),
  ],
)

Each alternate becomes an extra glyph that the cmap does not point at; it is only reachable through a single substitution. Turn it on at render time:

Text('一', style: TextStyle(
  fontFamily: 'MyHandwriting1',
  fontFeatures: const [FontFeature('ss01')],
));

Any four character tag works, not just ss01..ss20.

Two behaviours were verified end to end on macOS (the integration test shapes text and compares advance widths):

  • The features must be referenced from LangSys — which is what real fonts such as Inter do for ss01..ss08. Listing them only in the FeatureList makes Flutter's macOS shaper ignore FontFeature('ss01') completely.
  • Being referenced does not enable them: required_feature_index stays 0xFFFF, so nothing changes until the feature is requested.

Composite glyphs, ligatures and color layers #

A glyph can be assembled from parts of other glyphs instead of being drawn: that is a composite glyph, and it is how real fonts share strokes between characters (á is a plus ´, 回 reuses its own box).

GlyphSpec(
  codePoint: 0x42,
  components: [
    GlyphComponent(codePoint: 0x41),                 // reuse glyph "A" as is
    GlyphComponent(codePoint: 0x41, dx: 100, dy: 200, useMyMetrics: true),
  ],
),

Components are addressed by the code point of the glyph they reuse, must refer to another glyph of the same request, and may only be translated and uniformly scaled. Composite glyphs cannot carry masters (gvar on composites is not supported), and they ignore contours.

Ligatures replace a sequence of characters by one glyph, through a GSUB ligature substitution:

ligatures: const [
  LigatureSpec(
    featureTag: 'liga',
    sequence: Uint32List.fromList([0x66, 0x69]), // f i
    contours: [fiOutline],
  ),
],

Color glyphs are COLR version 0: a base glyph is drawn as a stack of shapes, each filled from a CPAL palette:

palette: Uint32List.fromList([0xFFFF0000, 0xFF0000FF]), // 0xAARRGGBB
glyphs: [
  GlyphSpec(
    codePoint: 0x2764,
    contours: [heartOutline],
    colorLayers: [
      GlyphColorLayer(contours: [leftHalf], paletteIndex: 0),
      GlyphColorLayer(contours: [rightHalf], paletteIndex: 1),
    ],
  ),
],

Read it back with Font.colorGlyph, then draw it with ColorGlyphInfo.paint(canvas, …).

Hinting, vertical metrics and raw tables #

FontBuildRequest(
  // `vhea` + `vmtx` so the font can be laid out vertically.
  verticalMetrics: VerticalMetricsSpec(ascender: 880, descender: -120),
  glyphs: [
    GlyphSpec(
      codePoint: 0x4E00,
      contours: [bar],
      // TrueType hinting bytecode for this glyph, stored in `glyf`.
      instructions: Uint8List.fromList([0xB0, 0x01, 0x2F, 0x3E]),
      verticalAdvance: 1000,
    ),
  ],
  // Anything `write-fonts` has no typed API for can be copied verbatim:
  // `fpgm`, `prep`, `cvt `, `gasp`, ...
  rawTables: [
    RawTableSpec(tag: 'fpgm', data: Uint8List.fromList([0xB0, 0x01])),
  ],
  // `cmap` format 14: 葛 + U+E0100 picks a different glyph.
  variationSelectors: [
    VariationSelectorSpec(
      selector: 0xE0100,
      variants: [
        CodePointVariant(codePoint: 0x845B, variantCodePoint: 0xE0100),
      ],
    ),
  ],
)

Metadata, kerning and variable advance widths #

FontBuildRequest also carries the metadata text engines look at, plus real kerning:

final bytes = await buildFont(
  request: FontBuildRequest(
    familyName: 'MyHandwriting',
    subfamilyName: 'Bold',        // -> OS/2 weight 700 + BOLD/ITALIC flags
    // weightClass / widthClass / vendorId override the derived values.
    designer: 'Me',
    license: 'OFL 1.1',
    version: 'Version 1.100',
    unitsPerEm: 1000,
    kernPairs: const [
      KernPair(left: 0x41, right: 0x56, value: -80), // A + V
    ],
    glyphs: [/* … */],
    axes: [],
    instances: [],
  ),
);
  • OS/2 is filled in properly: usWeightClass (derived from the subfamily name when not given), usWidthClass, fsSelection, achVendID, xAvgCharWidth and the unicode range bits.
  • kernPairs become a GPOS kern feature (one PairPos format 1 lookup).
  • A variable font also gets a STAT table (axes + named instances) and, when every master declares advanceWidth, an HVAR table so the advance width interpolates along the axes instead of staying constant:
VariationMaster(
  coordinates: Float64List.fromList([100]),
  contours: thinOutlines,
  advanceWidth: 600,     // at wght = 100
  globalMetrics: [       // and, written into `MVAR`:
    MetricValue(tag: 'hasc', value: 900),
  ],
),

MVAR understands hasc (ascender), hdsc (descender), hlgp (line gap) and hcla (cap height); their deltas are measured against the request's own ascender / descender.

  • VariationAxisSpec.mappings write an avar table, which remaps the normalized axis coordinate — the same values gvar/HVAR/MVAR deltas are expressed in, so interpolation and the table stay consistent:
VariationAxisSpec(
  tag: 'wght', name: 'Weight', minValue: 100, defaultValue: 400, maxValue: 900,
  mappings: [AxisValueMapSpec(from: 0.5, to: 0.8)], // reach bold sooner
),
  • StylisticAlternate.name is written as a ssNN / cvNN feature parameter, which is what font menus show as the feature's label.

Editing an existing font #

modifyFont patches a font instead of building one: outlines, the cmap and every table the request does not mention are copied over unchanged (so fvar, gvar, STAT, HVAR and friends survive). That makes it the tool for adding kerning, ligatures or substitutions to a font that already exists:

final patched = await modifyFont(
  bytes: original,
  request: FontModificationRequest(
    familyName: 'My Handwriting',
    subfamilyName: 'Bold',           // -> OS/2 weight 700 + BOLD flag
    designer: 'Me',
    kernPairs: const [KernPair(left: 0x41, right: 0x56, value: -80)],
    alternates: [
      ExistingAlternateSpec(          // reachable through FontFeature('ss01')
        featureTag: 'ss01',
        codePoint: 0x41,
        replacementCodePoint: 0x3001, // a glyph the font already has
        name: 'Wide A',
      ),
    ],
    ligatures: [
      ExistingLigatureSpec(
        featureTag: 'liga',
        sequence: Uint32List.fromList([0x66, 0x69]),
        replacementCodePoint: 0xFB01,
      ),
    ],
  ),
);

Only existing glyphs are rewired — outlines and glyph ids are never touched, so cmap entries, kerning and shaping data in the original font stay valid.

Point compatibility #

Masters must be point compatible with the default outline: same number of contours, same number of points, same order. Hand drawn strokes meet this automatically when every master is drawn with the same number of strokes and resampled to a fixed point count (the example does exactly that). Anything else is rejected with an explicit error.

Note: axis min/default/max and all coordinates are in user space; normalization to [-1, 1] is done by the plugin (no avar is emitted). Advance widths are constant because no HVAR is written.

For a .ttc collection pass index:

final face = Font.fromBytes(bytes: bytes, index: 2);

Passing size: null returns values in font units instead of pixels.

API overview #

Dart Description
initFontations() Loads the native library. Must be called before anything else.
Font.fromBytes(bytes, {index}) Parses a font; handles .ttc via index.
Font.countFonts(bytes) Number of faces inside a file.
readFontInfo(bytes, {index}) One-shot metadata read, no handle kept.
Font.info() names, metrics, axes, instances, tables.
Font.names() / .metrics() / .axes() / .namedInstances() / .tables() Individual accessors; metrics accepts a size and variation settings.
Font.charToGlyph(codePoint) Unicode code point to glyph id, null when unsupported.
Font.charmapInfo() / .charMappings() / .glyphToChar(glyphId) cmap summary, every mapping, and the reverse lookup.
Font.advanceWidth(glyphId, {size, variations}) Advance width in px or font units.
Font.glyphMetrics(glyphId, {size, variations}) Advance width, left side bearing and bounds of one glyph.
Font.outline(glyphId, {size, variations, hinted}) Path elements, bounds and advance width of a glyph.
Font.outlineFormat() Where outlines come from: glyf, cff, cff2 or varc.
Font.tableData(tag) / .tableLength(tag) / .hasTable(tag) Raw bytes of any table (GSUB, CPAL, MATH, …), its length, or whether it exists.
Font.layout({table}) Scripts, languages and features of GSUB and/or GPOS.
Font.attributes() Weight, stretch and style derived from OS/2 (or head).
Font.glyphName(glyphId) / .glyphNameSource() Glyph names from post / CFF.
Font.nameRecords({nameId}) Every name record, with platform, encoding and BCP 47 language.
Font.verticalMetrics() / .verticalAdvance() / .verticalOrigin() Vertical text support from vhea / vmtx / VORG.
Font.colorPalettes() / .hasColorGlyph() / .colorGlyph(...) CPAL palettes and COLR glyphs flattened into layers.
Font.bitmapStrikes() / .bitmapGlyph(glyphId, {ppem}) Embedded bitmaps (sbix, CBDT, EBDT), decoded to RGBA.
Font.shape(text, {size, variations, script, language, features}) Applies GSUB and GPOS: positioned glyphs with advances and offsets.
Font.hasMath() / .mathConstants({ppem}) / .mathGlyphInfo(...) / .mathVariants(...) MATH table: constants, per glyph corrections and kerns, stretchy variants.
Font.hasVarc() / .varcComponents(glyphId, {variations}) VARC glyph broken into the components it is drawn from.
Font.mapVariant(codePoint, selector) / .variantMappings() cmap format 14: a character plus a variation selector.
Font.avarInfo() / .normalizedCoords(variations) What avar (and avar2) does to each axis, and where settings end up.
GlyphOutline.toPath() Converts the outline into a dart:ui Path (flips Y for canvas).
ColorGlyphInfo.paint(canvas, {size, unitsPerEm}) Draws a color glyph onto a Flutter canvas.
fontationsVersion() Versions of the fontations crates in use.
buildFont(request) Compiles a FontBuildRequest (glyph contours in font units) into .ttf bytes.
modifyFont(bytes, request) Patches an existing font: metadata, kerning and substitutions, outlines untouched.

Variation settings are given in user space (wght = 700), exactly like CSS font-variation-settings; the plugin normalizes them through fvar/avar.

Project structure #

  • rust/ — the Rust crate. rust/src/api/fontations.rs (read/parse) and rust/src/api/build.rs (write/compile, implemented in rust/src/font_compile.rs) are the single source of truth for the Dart surface; everything else in lib/src/rust is generated. The heavier readers live next to them: rust/src/shaping.rs (the GSUB/GPOS engine), rust/src/math.rs and rust/src/varc.rs.
  • cargokit/ — build glue that compiles the Rust crate for Android (Gradle), Linux/Windows (CMake), and iOS/macOS when they are built with CocoaPods.
  • ios/fontations/, macos/fontations/ — the SwiftPM packages: a Package.swift, plus the prebuilt binaries they link (see below).
  • scripts/build_xcframework.sh — rebuilds those prebuilt binaries and records the rust/ fingerprint in scripts/xcframework.sha256.
  • scripts/check_xcframework.sh — fails when the two no longer match (run by CI).
  • scripts/build_web.sh — compiles the crate to WebAssembly (see Web).
  • scripts/coop_server.py — static server that adds the cross origin isolation headers a threaded wasm module needs.
  • lib/fontations_web.dart — the (empty) web plugin entry point Flutter asks every plugin with web support for.
  • lib/fontations.dart — the public Dart API.
  • lib/src/glyph_path.dart — GlyphOutline to dart:ui Path conversion.
  • .github/workflows/ci.yml — the four CI jobs described below.

Regenerating the bindings #

After editing rust/src/api/fontations.rs:

flutter_rust_bridge_codegen generate

Install the generator once with cargo install flutter_rust_bridge_codegen --version 2.13.0.

Building and bundling native code #

The Rust crate is built by cargokit and bundled by each platform's build system:

  • Android: Gradle + NDK (android/build.gradle)
  • Linux / Windows: CMake (linux/CMakeLists.txt, windows/CMakeLists.txt)
  • iOS / macOS: both integrations work, see below.

A Rust toolchain (and, for Android, cargo-ndk) is required on machines that build Android/Linux/Windows, or that build iOS/macOS with CocoaPods.

iOS / macOS: Swift Package Manager or CocoaPods #

Both are supported and neither needs configuration in the app: Flutter picks one based on flutter config --enable-swift-package-manager (SwiftPM is the default since Flutter 3.44). What differs is when Rust gets compiled:

  • Swift Package Manager links a prebuilt binary committed to this repo — ios/fontations/fontations.xcframework (a fontations.framework) and macos/fontations/fontations.xcframework (a libfontations.dylib), each referenced by the Package.swift next to it. SwiftPM has no script phases and its build tool plugins run sandboxed, so cargo cannot run while Xcode builds the app. Apps built this way need no Rust toolchain and skip the compile.

    Regenerate both binaries whenever anything under rust/ changes:

    ./scripts/build_xcframework.sh   # from the repo root
    

    and commit them together with that change, so the binary always matches the Rust sources it was built from. build_xcframework.sh records a hash of rust/ in scripts/xcframework.sha256, and CI compares it against the current crate — forgetting to repackage fails a job instead of quietly shipping a stale binary.

  • CocoaPods compiles Rust from source during the app build through a cargokit script phase (ios/fontations.podspec, macos/fontations.podspec). It needs a Rust toolchain (cargokit bootstraps one when missing) and makes clean builds slower, but always reproduces the current sources.

initFontations() finds the library either way: inside the app bundle when SwiftPM linked it dynamically, or in the app binary itself when CocoaPods linked it statically.

macOS ships a plain dylib rather than a framework because macOS framework bundles are deep bundles — they need Versions/Current symlinks, which flutter pub publish does not preserve.

Continuous integration #

.github/workflows/ci.yml runs five jobs:

Job Runner Checks
rust ubuntu cargo fmt --check, cargo test --all-targets
dart ubuntu dart format on the hand-written Dart, flutter analyze, flutter test (unit tests, after cargo build --release)
web ubuntu scripts/build_web.sh, then flutter build web on the example
artifacts ubuntu scripts/check_xcframework.sh — the committed XCFrameworks match rust/
macos macOS the example's integration tests

The macOS job is gated on rust, dart and artifacts. The example links the committed XCFramework through SwiftPM, so without that gate its tests could pass while exercising Rust code that is not the commit under test.

The artifacts job is deliberately cheap — it only hashes rust/ — so the expensive macOS job never starts on a stale binary.

The web job installs a nightly toolchain and wasm-bindgen, builds the module, and compiles the example for the web: it proves the toolchain path still works, not that the browser is happy — no test harness runs the page, and the cross origin isolation the module needs comes from a service worker that only a real browser can apply.

Web #

The web build compiles the crate to WebAssembly instead of linking a native library, so there is nothing for cargokit to do:

./scripts/build_web.sh          # writes example/web/pkg

The output is pkg/fontations.js plus pkg/fontations_bg.wasm. An app has to serve that folder from its own web root as pkg/, because the generated Dart loads pkg/<stem>.js relative to the page:

./scripts/build_web.sh <your_app>/web/pkg

Then initFontations() loads the module the same way it does on the desktop, and the rest of the API is unchanged.

Cross origin isolation is required #

The module is threaded: flutter_rust_bridge runs Rust on a pool of Web Workers, which needs SharedArrayBuffer, which browsers only expose to a cross origin isolated page. Without it the module loads and then panics inside Rust on the first call:

panicked at .../wasm_bindgen/worker_pool.rs:253:43:
fail to create WorkerPool: JsValue(DataCloneError: Failed to execute
'postMessage' on 'Worker': SharedArrayBuffer transfer requires
self.crossOriginIsolated.

A page becomes isolated when its response carries

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Getting the headers without a cooperating server #

flutter run -d chrome sends no such headers and cannot be told to, and most static hosts — GitHub Pages among them — cannot set them either. So the example ships a service worker that adds them: example/web/coi_serviceworker.js rewrites the headers of the navigation response, and example/web/index.html registers it and reloads the page once so it runs under that worker. The first load therefore blinks; after it the page is isolated and no server configuration is needed:

cd example && flutter build web --no-web-resources-cdn
python3 -m http.server 8080 --directory build/web   # any plain static server
# open localhost:8080

The same recipe works in any app: copy coi_serviceworker.js into web/, add the registration snippet from example/web/index.html to web/index.html, and turn Flutter's own service worker off — a scope can only be controlled by one worker, and Flutter's would evict this one on every load. That last part is example/web/flutter_bootstrap.js, whose only job is to call _flutter.loader.load() without serviceWorkerSettings.

scripts/coop_server.py is still there for hosts that can send the headers (_headers on Netlify, headers in firebase.json, add_header in nginx, ...); in that case the service worker is unnecessary.

A single threaded build is not a way out: without shared memory the worker pool panics at start up. That is why scripts/build_web.sh installs a nightly toolchain and builds with -Z build-std, exactly like flutter_rust_bridge_codegen build-web does through wasm-pack.

One more constraint comes from require-corp: cross origin subresources that are not requested through CORS are blocked, which includes CanvasKit when it is loaded from a CDN. Build with --no-web-resources-cdn so CanvasKit is served from the same origin.

What is verified #

flutter build web (compile check) runs in CI, together with the wasm build itself and a check that the built app still carries the isolation worker and does not register Flutter's. Integration tests cannot run on a web device, and the flutter test --platform chrome harness serves neither pkg/ nor an isolated page, so the browser path is checked by hand: serve the build with a plain static server, open it in Chrome, and confirm that self.crossOriginIsolated is true, that pkg/fontations_bg.wasm loads, and that no error reaches the console. The example's web module is ~2.8 MB, which says something about start up cost on a slow connection.

Notes #

  • Supported platforms: Android, iOS, macOS, Linux, Windows, and the web (WebAssembly — see above).
  • The web build is the only one that is not wired into the platform build systems: run scripts/build_web.sh before flutter build web.

Not wired up yet #

Compared with the upstream crates, these are still missing:

  • Writing: CFF/CFF2 output (write-fonts 0.54 has no charstring encoder, so only TrueType outlines can be produced), sbix/CBDT bitmaps (the tables need per glyph offset bookkeeping that write-fonts does not build), VORG (write-fonts has no vorg module), COLR version 1 paint graphs, gvar deltas on composite glyphs, and other GSUB lookup types (contextual, multiple, alternate) besides single substitution and ligature.
  • Reading: shaping is a level 1 OpenType shaper, not HarfBuzz — no Unicode script detection, no normalization, no cluster splitting, so complex scripts (Arabic, Indic) need the platform text stack. MATH is read as data only (constants, corrections, variants); nothing lays a formula out. VARC components are resolved, but the plugin does not composite the outline for you. avar2 deltas are applied and inspectable; cmap 14 lookups work.
  • Crates: subsetting/instancing (skera) and incremental font transfer (incremental-font-transfer) are separate upstream crates and are not vendored.

License #

See LICENSE. The bundled example font Inter is licensed under the SIL Open Font License 1.1.

1
likes
160
points
112
downloads

Documentation

API reference

Publisher

verified publishernoob-coder.com

Weekly Downloads

Flutter bindings for the fontations font stack (read-fonts / skrifa), powered by flutter_rust_bridge.

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, flutter_rust_bridge, flutter_web_plugins

More

Packages that depend on fontations

Packages that implement fontations