fontations 0.0.1
fontations: ^0.0.1 copied to clipboard
Flutter bindings for the fontations font stack (read-fonts / skrifa), powered by flutter_rust_bridge.
fontations #
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 forss01..ss08. Listing them only in theFeatureListmakes Flutter's macOS shaper ignoreFontFeature('ss01')completely. - Being referenced does not enable them:
required_feature_indexstays0xFFFF, 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/2is filled in properly:usWeightClass(derived from the subfamily name when not given),usWidthClass,fsSelection,achVendID,xAvgCharWidthand the unicode range bits.kernPairsbecome aGPOSkernfeature (onePairPosformat 1 lookup).- A variable font also gets a
STATtable (axes + named instances) and, when every master declaresadvanceWidth, anHVARtable 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.mappingswrite anavartable, which remaps the normalized axis coordinate — the same valuesgvar/HVAR/MVARdeltas 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.nameis written as assNN/cvNNfeature 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) andrust/src/api/build.rs(write/compile, implemented inrust/src/font_compile.rs) are the single source of truth for the Dart surface; everything else inlib/src/rustis generated. The heavier readers live next to them:rust/src/shaping.rs(theGSUB/GPOSengine),rust/src/math.rsandrust/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: aPackage.swift, plus the prebuilt binaries they link (see below).scripts/build_xcframework.sh— rebuilds those prebuilt binaries and records therust/fingerprint inscripts/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—GlyphOutlinetodart:uiPathconversion..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(afontations.framework) andmacos/fontations/fontations.xcframework(alibfontations.dylib), each referenced by thePackage.swiftnext 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 rootand commit them together with that change, so the binary always matches the Rust sources it was built from.
build_xcframework.shrecords a hash ofrust/inscripts/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.shbeforeflutter build web.
Not wired up yet #
Compared with the upstream crates, these are still missing:
- Writing: CFF/CFF2 output (
write-fonts0.54 has no charstring encoder, so only TrueType outlines can be produced),sbix/CBDTbitmaps (the tables need per glyph offset bookkeeping thatwrite-fontsdoes not build),VORG(write-fontshas novorgmodule),COLRversion 1 paint graphs,gvardeltas on composite glyphs, and otherGSUBlookup 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.
MATHis read as data only (constants, corrections, variants); nothing lays a formula out.VARCcomponents are resolved, but the plugin does not composite the outline for you.avar2deltas are applied and inspectable;cmap14 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.