glyph_path 1.0.0
glyph_path: ^1.0.0 copied to clipboard
A pure Dart OpenType/TrueType parser and text-to-path converter.
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>> - Flutter
ui.Path—toUiPath()via conditional export; Pure Dart stub included
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: ^1.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 }, …]
Flutter (dart:ui)
Add glyph_path to your Flutter project's pubspec.yaml, then use toUiPath() to
draw directly on a Canvas:
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:glyph_path/glyph_path.dart';
class GlyphPainter extends CustomPainter {
const GlyphPainter({required this.result});
final TextPathResult result;
@override
void paint(Canvas canvas, Size size) {
// Flip Y: font coordinates grow upward, Canvas grows downward.
canvas.translate(0, size.height);
canvas.scale(1, -1);
canvas.drawPath(result.toUiPath(), Paint()..color = Colors.black);
}
@override
bool shouldRepaint(GlyphPainter old) => old.result != result;
}
See example/06_flutter_canvas.dart for a complete
StatefulWidget + FutureBuilder integration example.
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 (Flutter Canvas, SVG, HTML canvas), apply a flip at render time:
canvas.translate(offsetX, offsetY + ascenderHeight);
canvas.scale(1, -1);
canvas.drawPath(result.toUiPath(), paint);
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 (01–05 via dart run;
06 requires a Flutter project). 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() |
06_flutter_canvas.dart |
(copy into a Flutter project) | Flutter CustomPainter integration using toUiPath() |
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.