glyph_path
A Pure Dart package that parses TrueType/OpenType fonts and converts text strings into vector path commands — no Flutter, no native code, no external dependencies.
Preview
| Basic Latin | Kerning & Ligatures |
|---|---|
| CJK / Japanese | Multi-line Word-wrap |
|---|---|
Preview SVGs are generated by
dart run example/generate_preview_svgs.dart.
Features
Core parsing
- TrueType (
glyf/loca) and CFF/OpenType outline parsing - cmap format 4 & 12 — full Unicode and CJK support
- Typographic metrics resolved from OS/2
USE_TYPO_METRICSflag, falling back tohhea normalizeToCubic— uniform cubic output across TTF and OTF fonts
Layout & measurement
- Kerning —
kerntable (format 0) and GPOS pair adjustments - Ligature substitution — GSUB
ligafeature (fi→ fi-ligature) - Multi-line layout with word-wrap —
TextLayoutwraps at a configurablemaxWidth - Text alignment — left, center, right via
TextAlign measureText— returns aTextMeasurement(width, ascent, descent) without generating path dataFont.metrics—FontMetrics(unitsPerEm, ascender, descender) without generating pathsFont.lineGap— typographic line gap in design units (from OS/2 orhhea)
Contour tools
generateGlyphPaths()— per-glyphGlyphPathResultwith commands, tight bounds (Bézier extrema), advance width, and source textGlyphPathResult.contours—List<Contour>with pre-computedWindingDirectionand signed areaContour.flipY()— Y-flip that keepswindingandsignedAreaconsistentContour.reversed()— reverses the tracing direction of a closed outlineContour.rotated(int shift)— rotates the start vertex of a closed outlinesplitIntoContours— splits anyList<PathCommand>intoList<Contour>PathCommandListTransform—translate(),scale(), andflipY()on any command list
Export
- SVG path data —
toSvgPathData()emits an SVGdattribute string - PDF content stream —
toPdfContentStream()emits PDF path operators (PDF 1.7 §8.5.2) - JSON —
toJson()serialises all commands toList<Map<String, dynamic>>
Safety
ResourceLimits— hard caps on table count, glyph count, cmap entries, kern pairs, composite depth, CFF subroutine nesting, input text length, and font size (DoS prevention)- Scoped
BinaryReader.slice()— cross-table reads are structurally impossible FontParseException— font-structure parse errors surface as a single typed exception; invalid API arguments (oversized text, out-of-range font size) raiseArgumentErrorinstead
Installation
dart pub add glyph_path
Or add to pubspec.yaml manually:
dependencies:
glyph_path: ^2.0.0
Usage
Quick start
import 'dart:io';
import 'dart:typed_data';
import 'package:glyph_path/glyph_path.dart';
void main() {
// Load font bytes from any source (File, Flutter rootBundle, network, …)
final Uint8List fontBytes = File('assets/MyFont.ttf').readAsBytesSync();
// Parse once — expensive; reuse the instance for multiple calls
final font = Font.parse(fontBytes);
// Generate path commands for a string at a given font size
final TextPathResult result = font.generateGlyphPaths('Hello Dart', fontSize: 48.0);
// Iterate the flattened command sequence across all glyphs
for (final cmd in result.commands) {
switch (cmd) {
case MoveTo(:final x, :final y):
print('move to ($x, $y)');
case LineTo(:final x, :final y):
print('line to ($x, $y)');
case QuadTo(:final controlX, :final controlY, :final x, :final y):
print('quad to ($x, $y) via ($controlX, $controlY)');
case CubicTo(:final control1X, :final control1Y,
:final control2X, :final control2Y,
:final x, :final y):
print('cubic to ($x, $y)');
case ClosePath():
print('close');
}
}
}
Font.parse() throws FontParseException for malformed input or exceeded resource limits.
Always wrap it in a try-catch when processing fonts from untrusted sources — and note that
generateGlyphPaths, measureText, resolveGlyphIdsForLayout, and TextLayout.layout can
also throw FontParseException, even for otherwise ordinary-length text, if the font's GSUB
ligature table is crafted to exceed the per-call evaluation budget
(ResourceLimits.maxLigatureEvalOps). Wrap those calls too whenever the font itself is not
fully trusted:
try {
final font = Font.parse(untrustedBytes);
final result = font.generateGlyphPaths('Hello Dart', fontSize: 48.0);
print('generated ${result.glyphs.length} glyphs');
} on FontParseException catch (e) {
print('Font rejected: ${e.message}');
}
Text measurement
measureText returns a TextMeasurement with the advance width and typographic metrics
of a string at the requested font size — without generating any path data:
final TextMeasurement m = font.measureText('Hello', fontSize: 48.0);
print('width: ${m.width}'); // advance width in scaled pixels
print('ascent: ${m.ascent}'); // positive, above baseline
print('descent: ${m.descent}'); // negative, below baseline
To get typographic metrics for the font itself (without a specific string), use
Font.metrics and Font.lineGap:
final FontMetrics metrics = font.metrics; // unitsPerEm, ascender, descender
final double gap = font.lineGap; // typographic line gap in design units
Per-glyph path generation
generateGlyphPaths() decomposes a string into one GlyphPathResult per rendered glyph.
Each entry carries drawing commands in glyph-local coordinates (origin at 0, 0), a
tight bounding box computed from Bézier curve extrema, advance width, and source text.
"Hi!" → generateGlyphPaths()
│
├─ GlyphPathResult { sourceText: "H", positionX: 0.0,
│ advanceWidth: 34.6, bounds: GlyphBounds(0, 0, 33, 47),
│ commands: [MoveTo, LineTo, …] }
│
├─ GlyphPathResult { sourceText: "i", positionX: 34.6,
│ advanceWidth: 14.2, bounds: GlyphBounds(0, 0, 13, 47),
│ commands: [MoveTo, LineTo, …] }
│
└─ GlyphPathResult { sourceText: "!", positionX: 48.8,
advanceWidth: 15.1, bounds: GlyphBounds(0, 0, 14, 47),
commands: [MoveTo, LineTo, …] }
Use positionX to place a glyph's local commands at its absolute X offset in the line.
normalizeToCubic (default true) converts all QuadTo to CubicTo — enabling
cross-format glyph morphing between TTF and OTF fonts.
final TextPathResult result = font.generateGlyphPaths(
'Hi!',
fontSize: 48.0, // pass null for raw design-unit coordinates (scale = 1.0)
kerning: true,
ligatures: true,
);
for (final GlyphPathResult glyph in result.glyphs) {
print('glyph: "${glyph.sourceText}" at x=${glyph.positionX}');
print(' advance: ${glyph.advanceWidth}');
print(' bounds: minX=${glyph.bounds.minX} maxX=${glyph.bounds.maxX}');
}
fontSize: nullfor raw font units: Returns coordinates in the font's design-unit space (scale = 1.0). Useful for layout engines, resolution-independent measurement, or glyph morphing before applying a scale at render time. The same result is available viagenerateRawGlyphPaths().
Contour decomposition
Each GlyphPathResult exposes a contours list where every Contour groups a closed
sub-path with its pre-computed WindingDirection and signedArea (shoelace / 2).
Use signedArea.abs() to distinguish outer outlines from inner counters (holes) by area:
for (final contour in glyph.contours) {
print(' winding=${contour.winding}'); // .clockwise / .counterClockwise
print(' area=${contour.signedArea.abs()}'); // larger = outer outline
}
Multi-line layout
TextLayout handles explicit \n newlines and optional word-wrap:
final engine = TextLayout(font);
final TextLayoutResult result = engine.layout(
'The quick brown fox jumps over the lazy dog.',
fontSize: 36.0,
maxWidth: 400.0,
alignment: TextAlign.center,
);
for (final line in result.lines) {
print('baseline y=${line.baselineY}, width=${line.lineWidth}');
// Per-glyph access in glyph-local coordinates:
// absolute x = glyph.positionX + line.alignmentOffset
for (final glyph in line.glyphs) {
final double absoluteX = glyph.positionX + line.alignmentOffset;
print(' "${glyph.sourceText}" at x=$absoluteX');
}
}
Available alignment values: TextAlign.left, TextAlign.center, TextAlign.right.
Path manipulation
PathCommandListTransform provides geometric transforms on any List<PathCommand>:
final commands = glyph.commands;
final shifted = commands.translate(10.0, -5.0); // shift by (dx, dy)
final scaled = commands.scale(2.0); // uniform scale
final flipped = commands.flipY(); // negate all Y (font→screen)
When working with Contour objects, prefer the contour-aware variants so that
winding and signedArea stay consistent with the transformed coordinates:
final flippedContour = contour.flipY(); // flips winding too
final reversedContour = contour.reversed(); // reverses tracing direction
final rotatedContour = contour.rotated(1); // shifts start vertex by 1
splitIntoContours splits any List<PathCommand> into List<Contour>, including open
sub-paths (flushed at the next MoveTo or end of list):
final List<Contour> contours = splitIntoContours(<PathCommand>[
MoveTo(0, 0), LineTo(100, 0), LineTo(50, 100), ClosePath(), // closed
MoveTo(200, 0), LineTo(300, 0), // open
]);
Exporters
SVG
final result = font.generateGlyphPaths('Hello SVG', fontSize: 48.0);
final String d = result.toSvgPathData(); // SVG <path d="…"> attribute value
Font coordinates are Y-up; SVG is Y-down. Wrap the <path> in a <g transform="scale(1,-1)"> to render correctly (see the example 02_svg_export.dart for a complete snippet).
PDF content stream
final String pdfOps = result.toPdfContentStream();
// e.g. "100 700 m 148 700 l … h"
Quadratic Bézier curves are automatically elevated to cubic (PDF 1.7 §8.5.2).
JSON
final String json = const JsonEncoder.withIndent(' ')
.convert(result.toJson());
// [{ "type": "MoveTo", "x": 0.0, "y": 0.0 }, …]
API Reference
Coordinate system
All path commands use the font coordinate system: Y increases upward, baseline at Y = 0.
Glyph outlines span from the descender line (negative Y) to the ascender line (positive Y).
To render in Y-down environments (e.g. HTML canvas), either flip the data before rendering —
via PathCommandListTransform.flipY() on a raw command list, or Contour.flipY() when working
with Contour objects so that winding and signedArea stay consistent with the flipped
coordinates — or apply an equivalent transform in the renderer itself (e.g. a canvas
scale(1, -1), as shown for SVG above).
For glyph morphing, interpolate in font coordinates (before any scale or flip), then apply
scale(1, -1) once at render time. This avoids re-inverting normals between keyframes:
- Obtain per-glyph commands via
generateGlyphPaths(..., fontSize: null)(design units) - Interpolate coordinates between keyframe fonts
- At render time, scale by
fontSize / unitsPerEmand flip Y
Classes and extensions
| Type | Description |
|---|---|
Font |
Parses a Uint8List font (.ttf/.otf) via Font.parse(). Exposes generateGlyphPaths(), generateRawGlyphPaths(), measureText(), resolveGlyphIdsForLayout(), metrics, and lineGap. |
FontMetrics |
Typographic values (unitsPerEm, ascender, descender) in design units; from Font.metrics. |
TextMeasurement |
Result of measureText() — width, ascent, and descent in scaled pixels. |
TextLayout |
Multi-line layout engine; wraps a Font and exposes layout() with word-wrap and alignment. |
TextPathResult |
Output of generateGlyphPaths() — glyphs (List<GlyphPathResult>), lazily-cached commands, and scaled metrics. |
GlyphPathResult |
Per-glyph result — commands, contours, advanceWidth, tight bounds, positionX, and sourceText. |
GlyphBounds |
Tight axis-aligned bounding box (minX, minY, maxX, maxY) computed from Bézier curve extrema. |
Contour |
A closed sub-path with commands, WindingDirection, and signedArea (shoelace / 2). |
WindingDirection |
Enum: clockwise / counterClockwise in font coordinate space (Y-up). |
computeWindingResult |
Low-level function returning WindingDirection and signedArea in one shoelace pass. |
splitIntoContours |
Top-level function that splits any List<PathCommand> into List<Contour>. |
PathCommandListTransform |
Extension on List<PathCommand> — translate(), scale(), flipY(). |
TextLayoutResult |
Output of TextLayout.layout() — lines and totalHeight. |
TextLineResult |
One rendered line — commands, baselineY, lineWidth, glyphs, and alignmentOffset. |
PathCommand |
Sealed base class; subtypes: MoveTo, LineTo, QuadTo, CubicTo, ClosePath. |
FontParseException |
Thrown by Font.parse() for malformed input or exceeded limits, and by generateGlyphPaths()/measureText()/resolveGlyphIdsForLayout()/TextLayout.layout() if GSUB ligature evaluation exceeds its per-call budget. |
ResourceLimits |
Abstract class of static const hard caps on all parsing operations. |
SvgExporter |
Extension on TextPathResult; toSvgPathData() returns an SVG d string. |
PdfContentStreamExporter |
Extension on TextPathResult; toPdfContentStream() returns PDF path operators. |
JsonExporter |
Extension on TextPathResult; toJson() returns List<Map<String, dynamic>>. |
Security
Font.parse() validates the sfnt magic bytes and all required table headers before any
further parsing. Font-structure parse errors are unified to FontParseException; invalid
public API arguments (oversized text, out-of-range font size, non-positive cache sizes)
raise ArgumentError instead — see SECURITY.md for the
full distinction.
ResourceLimits enforces hard caps during both font parsing and text-shaping API calls:
- Font structure — max tables (64), max glyphs (65535), max composite depth (8), max composite components (64)
- Glyph geometry — max contours per glyph (256), max points per glyph (32768)
- cmap — max format 4 iterations (65536), max format 12 groups (10000), max format 12 code-point span (1114112)
- Kerning — max kern pairs (65535), max GPOS pairs (65535), max GPOS matrix cells (65535)
- GSUB — max ligature rules (10000), max ligature component count (16), max ligature evaluation ops per call (1000000)
- CFF — max subroutine call depth (10), max charstring stack depth (513)
- Input — max input text length (4096 UTF-16 code units), max font size (1e6)
Each table parser receives a BinaryReader.slice() scoped to the table's declared byte
range, making cross-table reads structurally impossible.
For full details on parser-level bounds checks, see SECURITY.md.
Reporting vulnerabilities
Please report security concerns — including denial-of-service vectors or parser bypasses — via a GitHub Security Advisory. Do not open a public issue for security vulnerabilities.
Requirements
- Dart >= 3.8.0
- Pure Dart — no Flutter dependency
License
This package is released under the BSD 3-Clause license — see LICENSE.
Dependencies
| Package | License | Notes |
|---|---|---|
cacherine |
MIT | Runtime dependency |
meta |
BSD-3-Clause | Runtime dependency (annotations only) |
test is a dev-only dependency and is not included in the published package.
Font licensing
This package ships no fonts. Ensure that any font file you supply to Font.parse() is
used in compliance with its license terms.
- OFL (e.g., Noto) and Apache 2.0 (e.g., Roboto) explicitly permit outline embedding and export.
- Commercial EULAs may restrict outline export or conversion. Check the Embedding and Conversion clauses before distributing path data derived from a commercial font.
Examples
The example/ directory contains runnable examples (via dart run).
Each requires a font file path as a command-line argument.
Free fonts are available at Google Fonts and Noto Fonts.
| File | Run command | Description |
|---|---|---|
01_basic_usage.dart |
dart run example/01_basic_usage.dart path/to/font.ttf |
Core API: generateGlyphPaths() + PathCommand iteration + measureText() |
02_svg_export.dart |
dart run example/02_svg_export.dart latin.ttf cjk.ttf |
SVG output via toSvgPathData() — Latin and CJK text |
03_pdf_content_stream.dart |
dart run example/03_pdf_content_stream.dart path/to/font.ttf |
PDF operator export via toPdfContentStream() |
04_multiline_layout.dart |
dart run example/04_multiline_layout.dart path/to/font.ttf |
Multi-line layout with left / center / right alignment |
05_json_export.dart |
dart run example/05_json_export.dart path/to/font.ttf |
Serialising path commands to JSON via toJson() |
Contributing
Contributions are welcome! To maintain code quality, this project uses a strict CI pipeline.
Local setup
We recommend setting up the pre-commit hook to automate formatting and lint fixes:
sh scripts/setup-hooks.sh
This links .git/hooks/pre-commit to scripts/pre-commit, ensuring your changes match
the project's style guide automatically.
Development workflow
After cloning, dart pub get is sufficient to set up all dependencies including the test
runner — no global tool installation required.
# 1. Apply automated fixes (trailing commas, type annotations, etc.)
dart fix --apply
# 2. Format the code
dart format .
# 3. Run static analysis
dart analyze
# 4. Run the test suite (with coverage)
dart test --coverage=coverage
# 5. Verify pub.dev publishing checklist
dart pub publish --dry-run
Note: If you've modified layout or rendering logic, regenerate the preview SVGs:
dart run example/generate_preview_svgs.dart
Feel free to open an issue or submit a pull request for any suggestions or bug fixes.
Libraries
- glyph_path
- Pure Dart package for parsing TrueType/OpenType fonts and generating text path commands.