OpenType font parsing, shaping and text bounds for MoonBit
let data : Bytes = ... // TTF, OTF, WOFF, WOFF2, or TTC file bytes
let font = @font.parse_font(data).unwrap()
// Get glyph outline as SVG path commands
let cmds = font.scaled_outline('A'.to_int(), 48.0)
// Get glyph metrics
let gid = font.glyph_index('A'.to_int())
let metrics = font.glyph_metrics(gid)
// Text layout with kerning
let positions = font.layout_text("Hello", 48.0)
let width = font.measure_text("Hello", 48.0)
// Baseline-relative logical/ink rectangles and per-glyph UTF-16 ranges
let bounds = font.layout_text_bounds("Hello", 48.0)
let ink = bounds.ink_bounds // None for empty text or spaces
// OpenType shaping and mixed LTR/RTL layout, with source cluster ranges
let shaped = font.shape_text("office AV אבג", 48.0, direction=@font.Auto)
let width = shaped.bounds.advance_width
let runs = shaped.runs // Visual order, script, embedding level and glyph ranges
// Variable font with axis values
let cmds = font.char_outline_at('A'.to_int(), { "wght": 700.0 })
// Vertical text layout
let vpositions = font.layout_text_vertical("縦書き", 48.0)
let vheight = font.measure_text_vertical("縦書き", 48.0)
// Font subsetting (glyf-based TrueType only)
let subset = @font.subset_font(data, [0x41, 0x42, 0x43]) // A, B, C| Format | Notes | |
|---|---|---|
| TTF (TrueType) | ✅ | sfnt flavor 0x00010000 and 0x74727565 |
| OTF (OpenType/CFF) | ✅ | sfnt flavor OTTO |
| WOFF1 | ✅ | zlib decompression |
| WOFF2 | ✅ | Brotli decompression with glyf/loca/hmtx transforms |
| TTC/OTC (Font Collections) | ✅ | Index-based or first-font access |
| Table | Description |
|---|---|
| head | units_per_em, indexToLocFormat |
| maxp | numGlyphs |
| hhea | ascent, descent, lineGap, numOfLongHorMetrics |
| hmtx | advanceWidth, leftSideBearing per glyph |
| cmap | Format 0, 4 (BMP), 6, and 12 (full Unicode) |
| loca | Short and long formats |
| glyf | Simple and compound glyphs |
| CFF | Type 2 charstrings with subroutines |
| CFF2 | Variable font support with blend/vsindex |
| fvar | Axis definitions (wght, wdth, opsz, etc.) |
| avar | Piecewise linear axis value mapping |
| gvar | TrueType delta interpolation with IUP |
| kern | Format 0 horizontal pairs |
| name | Windows Unicode, Unicode platform, Mac Roman |
| OS/2 | Weight/width class, PANOSE, x-height, cap-height |
| post | Italic angle, fixed pitch, glyph names (v2.0) |
| vhea/vmtx | Vertical metrics — advance heights and top side bearings |
| gasp | Grid-fitting and scan-conversion ranges |
| VORG | Vertical origin Y coordinates for CFF glyphs |
| Table | Description |
|---|---|
| BASE | Baseline offsets for mixing scripts (e.g. Latin + CJK in one line) |
| JSTF | Justification alternatives for full-justified text |
| Table | Description |
|---|---|
| COLR/CPAL | Color layers with palettes (COLRv0/v1 color fonts) |
| SVG | SVG glyph documents (SVG-in-OpenType color fonts) |
| CBDT/CBLC | Color bitmap glyphs (Google/Android emoji) |
| sbix | Apple bitmap glyphs (Apple emoji) |
| EBDT/EBLC | Monochrome/grayscale embedded bitmaps (legacy low-res screens) |
| Table | Description |
|---|---|
| MATH | Math layout constants and glyph assembly — only for TeX-like math typesetting engines |
| cvt/fpgm/prep | TrueType hinting programs — grid-fitting instructions for low-DPI rasterization. Irrelevant for SVG/vector output |
| hdmx | Pre-computed device widths for specific PPEMs — legacy optimization for bitmap rendering |
| Table | Description |
|---|---|
| morx/mort | Apple Advanced Typography — state-machine-based shaping |
| Table | Description |
|---|---|
| DSIG | Digital signature — font authenticity verification, does not affect rendering |
| cvar | CVT variations — hinting value adjustments in variable fonts, only relevant if hinting is executed |
| Category | Operators | |
|---|---|---|
| Move | ✅ | rmoveto, hmoveto, vmoveto |
| Line | ✅ | rlineto, hlineto, vlineto |
| Curve | ✅ | rrcurveto, hhcurveto, vvcurveto, hvcurveto, vhcurveto |
| Mixed | ✅ | rcurveline, rlinecurve |
| Flex | ✅ | flex, hflex, vflex, hflex1, flex1 |
| Hint | ✅ | hstem, vstem, hstemhm, vstemhm, hintmask, cntrmask |
| Subroutine | ✅ | callsubr, callgsubr, return |
| Arithmetic | ✅ | add, sub, mul, div, neg, abs, sqrt, eq |
| Logic | ✅ | and, or, not, ifelse |
| Stack | ✅ | dup, exch, drop, index, roll, put, get |
| CFF2 Variation | ✅ | blend, vsindex |
| Control | ✅ | endchar |
| Deprecated | ➖ | seac, dotsection — removed from modern specs |
| Feature | Notes | |
|---|---|---|
| Simple glyphs (TrueType) | ✅ | |
| Compound glyphs (TrueType) | ✅ | Scale, 2x2 matrix, XY offset transforms |
| CFF1 charstrings | ✅ | Local/global subroutines |
| CFF2 charstrings | ✅ | Blend interpolation |
| TrueType variations (gvar) | ✅ | Shared tuples, IUP, delta unpacking |
| CFF2 variations (ItemVariationStore) | ✅ | Region scalars, blend deltas |
| Hinting / instruction execution | ❌ | Not needed for vector/SVG output |
| Format | Coverage | |
|---|---|---|
| Format 0 | ✅ | Mac Roman 256-char |
| Format 4 | ✅ | BMP (U+0000–U+FFFF) |
| Format 6 | ✅ | Trimmed table |
| Format 12 | ✅ | Full Unicode (preferred when available) |
| Format 2 | ❌ | CJK mixed 8/16-bit — obsolete encoding, replaced by Format 12 |
| Format 14 | ❌ | Unicode Variation Sequences — needed for CJK glyph variants (e.g. JP vs CN forms) |
| Feature | Notes | |
|---|---|---|
| kern table Format 0 (flat pairs) | ✅ | Covers most Latin fonts |
| kern table Format 1 (Apple state machine) | ❌ | Apple-only, rare in cross-platform fonts |
| GPOS pair adjustment (PairPos) | ✅ | Pair and class adjustments through shape_text |
| GPOS contextual positioning | ✅ | Handled by the shaping backend |
| Feature | Notes | |
|---|---|---|
| Glyf-based TrueType subsetting | ✅ | |
| Compound glyph dependency resolution | ✅ | |
| Glyph ID remapping | ✅ | |
| cmap Format 12 rebuild | ✅ | |
| Table copy (head, hhea, hmtx, maxp, name, OS/2, post) | ✅ | |
| CFF/CFF2 subsetting | ❌ | CFF charstring rewriting is complex |
| WOFF/WOFF2 output | ❌ | Outputs raw sfnt — wrap with external compressor |
| Layout table subsetting (GSUB/GPOS) | ❌ | Requires lookup/coverage rewriting |
| Feature | Notes | |
|---|---|---|
| Horizontal advance widths | ✅ | |
| Pair kerning (kern table) | ✅ | |
| UTF-16 surrogate pair handling | ✅ | |
| Text width measurement | ✅ | |
| OpenType shaping (GSUB/GPOS) | ✅ | shape_text: ligatures, contextual forms, kerning and mark positioning |
| Bidirectional text | ✅ | Resolved levels, isolates, mirroring and visual runs through Unicode BiDi |
| Vertical layout | ✅ | Top-to-bottom CJK typesetting (vhea/vmtx/VORG) |
| Line breaking | ❌ | Text wrapping — typically handled by the application layer |
let shaped = font.shape_text(
"fi AV אבג 123",
32.0,
direction=@font.Auto, // Also Ltr and Rtl
language="und", // BCP 47, e.g. "ar", "he", "tr"
kerning=true,
ligatures=true,
font_weight=700.0, // Optional: select wght on a variable font
axis_values={ "wdth": 90.0 }, // Optional: explicit axes override font_weight
)
let fit = shaped.bounds.check_width(80.0, 240.0)
// fit.status: Underflow, Fits or Overflow; inclusive limits in CSS pixels| Function | Returns | Description |
|---|---|---|
| loadFont(data) | JSON string | Parse font, return metrics |
| getFontInfo() | JSON string | Get cached font info |
| glyphToSvgPath(codepoint, fontSize) | SVG path string | Scaled glyph outline |
| glyphOutlineCommands(codepoint, fontSize) | JSON string | Scaled path commands |
| glyphAdvance(codepoint, fontSize) | Double | Scaled advance width |
| fontName(nameId) | String | Name table lookup |
| kernAdvance(cp1, cp2, fontSize) | Double | Scaled kerning value |
| layoutText(text, fontSize) | JSON string | Glyph positions with kerning |
| measureText(text, fontSize) | Double | Total text width |
| layoutTextBounds(text, fontSize) | JSON string | Advance, logical/ink bounds and positioned glyph bounds; null if unloaded or size is invalid |
| shapeText(text, fontSize, direction, language, kerning, ligatures) | JSON string | {ok:true,result:ShapedText} or {ok:false,error:{code,message}} |
| shapeTextWithWeight(text, fontSize, direction, language, kerning, ligatures, weight) | JSON string | Same shaped contract, selecting the requested weight |
| shapeTextWithAxes(text, fontSize, direction, language, kerning, ligatures, weight, axesJson) | JSON string | Same contract with explicit numeric variation axes; malformed input returns an error |
| fontWeightClass() | Int | OS/2 weight class |
| isFixedPitch() | Int (0/1) | Post table fixed-pitch flag |
| codepointCoverage() | JSON string | All supported codepoints |
| glyphIds() | JSON string | All glyph IDs (0 to numGlyphs-1) |
| tableSizes() | JSON string | Table tag to byte size |
import { checkTextFit, inspectTextFit } from '/browser/fit.js';
const result = await checkTextFit('Hello world Hello world', {
minWidth: 40,
maxWidth: 120,
maxHeight: 96, // Omit for an unconstrained height
requiredFonts: ['YourLoadedFont'], // Optional: require a registered web font
allowTruncation: false, // Default: require the full text
style: {
fontFamily: 'YourLoadedFont, sans-serif',
fontSize: '20px', fontWeight: '700', lineHeight: '28px',
whiteSpace: 'normal', overflowWrap: 'anywhere',
},
});
console.log(result.unwrapped.status); // Natural max-content width vs [40, 120]
console.log(result.layout.status); // Actual wrapping and height at 120 x 96
console.log(result.layout.overflowX, result.layout.overflowY);
console.log(result.accepted); // Wrapped-layout acceptance, including fonts/clipping
console.log(result.presentation); // Full / truncated / clipped / scrollable / overflow
// Inspect an existing element using its actual fonts, inline spans and CSS.
// This does not resize or modify the source element.
const existing = await inspectTextFit(document.querySelector('.label'), {
minWidth: 40, maxWidth: 120,
});# Report both results; --mode selects which result --assert-fit checks
just check-fit --text 'Hello world Hello world' --weight 700 \
--min-width 40 --max-width 120 --height 96 --assert-fit
# Single-line check; returns exit code 1 for overflow/underflow/unknown
just check-fit --text 'Hello world' --max-width 80 \
--mode single-line --assert-fit
# Inspect a real page in the selected headless engine
just check-fit --url http://localhost:3000 --selector '.label' \
--browser firefox --min-width 40 --max-width 120 \
--require-font 'YourLoadedFont' --allow-truncation --assert-fitjust setup-browser
just test-browser
# Save six default samples as _build/measurements/font-metrics.json
just measure-browser
# Measure a local font, with optional DOM wrapping; --text can be repeated
just measure-browser --font fixtures/NotoSansMono-Regular.ttf \
--text "Hello world" --text "Ag" --size 20 --width 70 \
--line-height 30 --output _build/measurements/wrapped.json
# GSUB/GPOS and mixed-direction samples
just measure-shaping
just measure-browser --font fixtures/shaping-reference/arabic.ttf \
--text "العربية" --direction rtl --language ar --size 32
just measure-browser --font fixtures/shaping-reference/roboto-variable.ttf \
--text "Hello world" --weight 700 --size 24
just measure-browser --font fixtures/shaping-reference/robotoserif-variable.ttf \
--text "Hello world" --axis wdth=75 --axis opsz=8 --size 32
# Optional feature switches: --no-kerning --no-ligatures| Data | Coordinates and meaning |
|---|---|
| moonbit.advance_width | Shaped paragraph advance, including spaces and kerning |
| moonbit.logical_bounds | hhea ascent/descent box, baseline origin, y down; line gap reported separately |
| moonbit.ink_bounds | Union of tight outline bounds computed from curve extrema; null when no ink |
| moonbit.glyphs | Visual glyph positions, advances, ink bounds and half-open UTF-16 cluster ranges |
| shaping | Full MoonBit shaped result, including direction, runs and missing code points |
| canvas | Unwrapped browser-shaped advance and baseline-relative ink bounds |
| dom | Element/text bounds, Range fragments and grapheme rectangles, relative to the element's top-left |
| missingCodepoints | Code points missing from the selected font; the browser may use fallback fonts |
| widthDelta | MoonBit advance minus Canvas advance, in CSS pixels |
import { loadFont } from '/browser/font.js';
const font = await loadFont('/fixtures/NotoSansMono-Regular.ttf');
try {
const metrics = font.measure('Hello world', 20, { maxWidth: 70, lineHeight: 30 });
console.log(metrics.canvas.advanceWidth, metrics.dom.lineRects);
} finally {
font.dispose();
}
// Explicit variation instances load asynchronously and are cached until dispose().
const variable = await loadFont('/fixtures/shaping-reference/robotoserif-variable.ttf');
try {
const metrics = await variable.measureWithAxes('Hello world', 32, {
fontWeight: 400, axisValues: { wdth: 75, opsz: 8 },
});
console.log(metrics.shaping.variations);
} finally {
variable.dispose();
}# Download test fixtures and run tests
just test
# Or manually
bash scripts/download-fixtures.sh
moon test
# Type check
just check
# Format code
just fmt
# Default-backend tests and headless browser checks
just cipub suberror ShapingError {
InvalidFontSize
InvalidFont
MultipleParagraphs
InvalidFontWeight
UnsupportedFontWeight
InvalidVariationAxis(String)
}pub(all) struct AtlasPageInfo {
page_id : Int
width : Int
height : Int
dirty : Bool
}impl Show for AtlasPageInfopub(all) struct BinaryReader {
data : Bytes
pos : Int
}pub(all) struct CFFData {
charstrings : CFFIndex
global_subrs : CFFIndex
local_subrs : CFFIndex
default_width_x : Int
nominal_width_x : Int
is_cff2 : Bool
ivs : ItemVariationStore?
}pub(all) struct FontKey {
family : String
weight : Int
italic : Bool
}pub struct GlyphAtlas {
cache : GlyphCache
width : Int
height : Int
pixels : Array[Int]
dirty : Bool
last_used_generation : Int
}fn GlyphAtlas::rasterize_glyph(self : GlyphAtlas, font : TTFont, codepoint : Int, size_px : Double) -> GlyphCacheEntry?impl Show for GlyphBitmappub struct GlyphCache {
entries : Map[Int, GlyphCacheEntry]
next_x : Double
next_y : Double
row_height : Double
atlas_width : Double
atlas_height : Double
atlas_page_id : Int
}fn GlyphCache::allocate(self : GlyphCache, glyph_id : Int, width : Double, height : Double) -> GlyphCacheEntry?pub(all) struct GlyphCacheEntry {
glyph_id : Int
atlas_x : Double
atlas_y : Double
atlas_w : Double
atlas_h : Double
atlas_page_id : Int
}pub(all) struct GlyphPosition {
codepoint : Int
glyph_id : Int
x_offset : Double
y_offset : Double
x_advance : Double
}pub(all) struct GlyphQuad {
glyph_id : Int
atlas_x : Double
atlas_y : Double
atlas_w : Double
atlas_h : Double
dst_x : Double
dst_y : Double
dst_w : Double
dst_h : Double
}pub(all) struct OS2Table {
version : Int
us_weight_class : Int
us_width_class : Int
fs_type : Int
panose : FixedArray[Int]
ul_unicode_range1 : Int
ul_unicode_range2 : Int
ul_unicode_range3 : Int
ul_unicode_range4 : Int
fs_selection : Int
s_typo_ascender : Int
s_typo_descender : Int
s_typo_line_gap : Int
us_win_ascent : Int
us_win_descent : Int
sx_height : Int
s_cap_height : Int
}pub(all) struct PositionedGlyphBounds {
codepoint : Int
glyph_id : Int
text_start : Int
text_end : Int
x : Double
y : Double
advance_width : Double
ink_bounds : TextRect?
}impl ToJson for PositionedGlyphBoundspub(all) struct PostTable {
italic_angle : Double
underline_position : Int
underline_thickness : Int
is_fixed_pitch : Bool
glyph_names : Array[String]
}pub(all) struct ShapedRun {
text_start : Int
text_end : Int
glyph_start : Int
glyph_end : Int
level : Int
direction : String
script : String
x : Double
advance_width : Double
} derive(ToJson)pub(all) struct TTFont {
units_per_em : Int
index_to_loc_format : Int
num_glyphs : Int
ascent : Int
descent : Int
line_gap : Int
num_h_metrics : Int
glyf_offset : Int
data : Bytes
sfnt_offset : Int
cmap : Map[Int, Int]
loca : Array[Int]
advance_widths : Array[Int]
left_side_bearings : Array[Int]
cff : CFFData?
gvar : GvarData?
var_axes : Array[VarAxis]
kern_pairs : Map[Int, Int]
avar_segments : Array[Array[(Double, Double)]]
name_records : Map[Int, String]
os2 : OS2Table?
post : PostTable?
vertical : VerticalMetrics?
gasp : Array[GaspRange]
vorg : VorgData?
}fn TTFont::char_outline_at(self : TTFont, codepoint : Int, axis_values : Map[String, Double]) -> Array[PathCommand]fn TTFont::glyph_metrics_at(self : TTFont, glyph_id : Int, axis_values : Map[String, Double]) -> GlyphMetricsfn TTFont::glyph_outline_at(self : TTFont, glyph_id : Int, axis_values : Map[String, Double]) -> Array[PathCommand]fn TTFont::layout_text_vertical(self : TTFont, text : String, font_size : Double) -> Array[GlyphPosition]fn TTFont::shape_text(self : TTFont, text : String, font_size : Double, direction? : TextDirection, language? : String, kerning? : Bool, ligatures? : Bool, font_weight? : Double, axis_values? : Map[String, Double]) -> ShapedText raise ShapingErrorpub(all) struct TextBounds {
advance_width : Double
ascent : Double
descent : Double
line_gap : Double
logical_bounds : TextRect
ink_bounds : TextRect?
glyphs : Array[PositionedGlyphBounds]
}impl ToJson for TextBoundsfn TextBounds::check_width(self : TextBounds, min_width : Double, max_width : Double, tolerance? : Double) -> WidthCheck raise WidthCheckErrorpub(all) struct TextMetrics {
width : Double
height : Double
baseline : Double
}impl Show for TextMetricspub struct TextRenderer {
pages : Array[GlyphAtlas]
font : TTFont
pipeline_id : Int
atlas_size : Int
base_page_id : Int
max_pages : Int
generation : Int
}fn TextRenderer::ensure_glyph(self : TextRenderer, codepoint : Int, size_px : Double) -> (Int, GlyphCacheEntry)?fn TextRenderer::new(font : TTFont, atlas_size : Int, base_page_id : Int, pipeline_id : Int) -> TextRendererfn TextRenderer::new_with_options(font : TTFont, atlas_size : Int, base_page_id : Int, pipeline_id : Int, max_pages : Int) -> TextRendererpub(all) struct TextStyle {
font : FontKey
size_px : Double
line_height : Double
letter_spacing : Double
}pub(all) struct VarAxis {
tag : String
min_value : Double
default_value : Double
max_value : Double
}pub(all) struct VarRegionAxis {
start_coord : Double
peak_coord : Double
end_coord : Double
}pub(all) struct WidthCheck {
status : WidthStatus
width : Double
min_width : Double
max_width : Double
underflow_by : Double
overflow_by : Double
}fn blit_to_atlas(atlas_pixels : Array[Int], atlas_width : Int, src : GlyphBitmap, dst_x : Int, dst_y : Int) -> Unitfn bytes_slice(data : Bytes, offset : Int, length : Int) -> Bytesfn calc_table_checksum(data : Bytes) -> Intfn check_text_width(width : Double, min_width : Double, max_width : Double, tolerance? : Double) -> WidthCheck raise WidthCheckErrorfn make_glyph_cache_key(glyph_id : Int, size_px : Double) -> Intfn parse_avar(reader : BinaryReader, offset : Int, axis_count : Int) -> Array[Array[(Double, Double)]]fn parse_hmtx(reader : BinaryReader, offset : Int, num_glyphs : Int, num_h_metrics : Int) -> (Array[Int], Array[Int])fn parse_vmtx(reader : BinaryReader, offset : Int, num_glyphs : Int, num_v_metrics : Int) -> (Array[Int], Array[Int])fn scale_commands(commands : Array[PathCommand], scale : Double, flip_y : Bool) -> Array[PathCommand]Install
Download zipOpenType font parsing, shaping and text bounds for MoonBit