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.

pub package Dart SDK CI

Preview

Basic Latin Kerning & Ligatures
Basic Latin text path rendering "Hello, World!" as vector outlines Kerning and ligature substitution applied to "Kerning & fi fl"
CJK / Japanese Multi-line Word-wrap
Japanese text "日本語テキスト" rendered as glyph outlines Three lines of word-wrapped text demonstrating multi-line layout

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_METRICS flag, falling back to hhea
  • normalizeToCubic — uniform cubic output across TTF and OTF fonts

Layout & measurement

  • Kerning — kern table (format 0) and GPOS pair adjustments
  • Ligature substitution — GSUB liga feature (fi → fi-ligature)
  • Multi-line layout with word-wrap — TextLayout wraps at a configurable maxWidth
  • Text alignment — left, center, right via TextAlign
  • measureText — returns a TextMeasurement (width, ascent, descent) without generating path data
  • Font.metricsFontMetrics (unitsPerEm, ascender, descender) without generating paths
  • Font.lineGap — typographic line gap in design units (from OS/2 or hhea)

Contour tools

  • generateGlyphPaths() — per-glyph GlyphPathResult with commands, tight bounds (Bézier extrema), advance width, and source text
  • GlyphPathResult.contoursList<Contour> with pre-computed WindingDirection and signed area
  • Contour.flipY() — Y-flip that keeps winding and signedArea consistent
  • Contour.reversed() — reverses the tracing direction of a closed outline
  • Contour.rotated(int shift) — rotates the start vertex of a closed outline
  • splitIntoContours — splits any List<PathCommand> into List<Contour>
  • PathCommandListTransformtranslate(), scale(), and flipY() on any command list

Export

  • SVG path data — toSvgPathData() emits an SVG d attribute string
  • PDF content stream — toPdfContentStream() emits PDF path operators (PDF 1.7 §8.5.2)
  • JSON — toJson() serialises all commands to List<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) raise ArgumentError instead

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: null for 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 via generateRawGlyphPaths().

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:

  1. Obtain per-glyph commands via generateGlyphPaths(..., fontSize: null) (design units)
  2. Interpolate coordinates between keyframe fonts
  3. At render time, scale by fontSize / unitsPerEm and 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.