letterpaths is a headless TypeScript library for generating handwriting geometry.
It does not render anything itself. You give it text and options, and it returns pure data for:
- handwriting paths built from cubic Beziers
- pen-motion animation
- tracing preparation and session state
- retrace-group and tracing-section analysis
- formation annotations such as start arrows, directional dashes, and draw-order numbers
- built-in glyph data and variant lookup helpers
Main entrypoint
buildHandwritingPath() is the main high-level API:
import { buildHandwritingPath } from "letterpaths"; const path = buildHandwritingPath("cat", { style: "cursive", targetGuides: { xHeight: 360, baseline: 720 } });
It returns a WritingPath:
type WritingPath = { strokes: Array<{ type: "lead-in" | "body" | "join" | "exit" | "lift"; curves: CubicBezier[]; curveSegments?: Array<WritingPathSegment | undefined>; deferred: boolean; }>; bounds: { minX: number; maxX: number; minY: number; maxY: number }; guides: LetterGuides; joinMetrics?: JoinMetric[]; };
strokes is the main payload. Each stroke contains one or more CubicBezier objects that you can render however you want.
Layout API
buildHandwritingPath(text, options)
This switches between print, pre-cursive, and cursive output.
import { buildHandwritingPath } from "letterpaths"; const path = buildHandwritingPath("cat dog", { style: "cursive", targetGuides: { xHeight: 360, baseline: 720 }, joinSpacing: { sidebearingGapAdjustment: 10 }, wordSpacing: 540 });
BuildHandwritingOptions:
style?: "print" | "pre-cursive" | "cursive"defaults to"cursive"letters?: Record<string, BezierLetter>overrides the built-in glyph maptargetGuides?: LetterGuidesrescales the output onto your guide systemwordSpacing?: numberoverrides the spacing used for spacesjoinSpacing?: JoinSpacingOptionsadds a constant adjustment to hard-coded cursive pair gapscapitalKerning?: CapitalToLowercaseKerningPairsoverrides the built-in capital-to-lowercase visible gaps (with separate values for lead-in and no-lead-in forms)keepInitialLeadIn?: booleankeeps the first letter's lead-in/entry strokekeepFinalLeadOut?: booleankeeps the last letter's exit/lead-out stroke
Notes:
joinSpacingonly affects cursive joiningjoinMetricsandcapitalKerningMetricsare only populated for cursive output- unknown characters are skipped rather than throwing
- deferred strokes are preserved in
WritingPath.strokesand are useful for dots and similar marks
Style-specific builders
The style-specific builders are exported too:
import { buildPrintWord, buildPreCursiveWord, joinCursiveWord } from "letterpaths"; const printPath = buildPrintWord("cat"); const preCursivePath = buildPreCursiveWord("cat"); const cursivePath = joinCursiveWord("cat", { keepInitialLeadIn: true, keepFinalLeadOut: true });
The package also exports spacing defaults and helpers used by the layout layer:
printLetterSpacingpreCursiveLetterSpacingcursiveLetterSpacingdefaultJoinSpacingOptionslistAvailableLetters()
Working with curves
WritingPath strokes contain CubicBezier instances, not SVG strings:
import { buildHandwritingPath } from "letterpaths"; const path = buildHandwritingPath("a"); const curve = path.strokes[0]?.curves[0]; if (curve) { console.log({ point: curve.getPointAt(0.5), tangent: curve.getTangentAt(0.5), length: curve.length() }); }
CubicBezier methods:
getPointAt(t)getTangentAt(t)length(steps?)getPointAtLength(distance)getTAtLength(distance, steps?)
Animation API
Use compileAnimation() to turn a WritingPath into a time-based player:
import { buildHandwritingPath, compileAnimation } from "letterpaths"; const path = buildHandwritingPath("hi", { style: "pre-cursive" }); const player = compileAnimation(path, { speed: 1.8, penUpSpeed: 2.2, strokeDelayMs: 0, deferredDelayMs: 120 }); console.log(player.totalDuration); console.log(player.getFrame(120));
AnimationOptions:
speed?: numberpenUpSpeed?: numberminLiftDistance?: numberstrokeDelayMs?: numberdeferredDelayMs?: number
AnimationFrame:
type AnimationFrame = { point: Point; velocity: Point; isPenDown: boolean; completedStrokes: number[]; activeStrokeIndex: number; activeStrokeProgress: number; };
When the player is in a pen-up lift or pause, activeStrokeIndex is -1.
Tracing API
Tracing has two layers:
compileTracingPath()converts aWritingPathinto evenly sampled strokesTracingSessionmanages pointer-driven progress through that prepared path
import { buildHandwritingPath, compileTracingPath, TracingSession } from "letterpaths"; const path = buildHandwritingPath("i", { style: "cursive" }); const prepared = compileTracingPath(path, { sampleRate: 12 }); const session = new TracingSession(prepared, { startTolerance: 30 }); const started = session.beginAt(session.getState().cursorPoint); if (started) { session.update(session.getState().cursorPoint); session.end(); }
CompileOptions:
sampleRate?: numberdistance between samples in pixels, default2
PreparedTracingPath:
type PreparedTracingPath = { strokes: Array<{ samples: Array<{ x: number; y: number; tangent: Point; distanceAlongStroke: number; }>; totalLength: number; isDot: boolean; }>; boundaries: Array<{ overallDistance: number; point: Point; previousSegment?: WritingPathSegment; nextSegment?: WritingPathSegment; incomingTangent: Point; outgoingTangent: Point; turnAngleDegrees: number; }>; guides: LetterGuides; bounds: { minX: number; maxX: number; minY: number; maxY: number }; };
compileTracingPath() ignores WritingPath strokes whose type is "lift".
TracingSession
TracingSession methods:
getState()getPath()beginAt(point)update(point)end()reset()
TracingSessionOptions:
startTolerance?: numberhitTolerance?: numbermaxAdvanceSamples?: numberadvanceBias?: number
TracingState.status is one of:
"idle""tracing""await_pen_up""complete"
Dots are auto-completed when beginAt() starts on a dot stroke.
Retrace groups and tracing sections
For higher-level tracing logic, the package exports two analysis layers:
analyzeTracingGroups(prepared, options)splits the traced path into contiguous"base"and"retrace"groupsanalyzeTracingSections(prepared, options)splits the path into user-facing tracing sections starting at the path start, stroke starts, and retrace turns
import { analyzeTracingGroups, analyzeTracingSections, buildHandwritingPath, compileTracingPath } from "letterpaths"; const prepared = compileTracingPath(buildHandwritingPath("p")); const groups = analyzeTracingGroups(prepared); const sections = analyzeTracingSections(prepared);
TracingGroup:
type TracingGroup = { index: number; startDistance: number; endDistance: number; startPoint: Point; endPoint: Point; kind: "base" | "retrace"; matchedEarlierDistance?: number; };
TracingSection:
type TracingSection = { index: number; strokeIndex: number; groupIndex?: number; startDistance: number; endDistance: number; startPoint: Point; endPoint: Point; startTangent: Point; endTangent: Point; startReason: "path-start" | "stroke-start" | "retrace-turn"; kind: "base" | "retrace"; matchedEarlierDistance?: number; };
Formation annotations
compileFormationAnnotations() returns renderer-agnostic formation hints for a prepared tracing path.
import { annotationCommandsToSvgPathData, buildHandwritingPath, compileFormationAnnotations, compileTracingPath } from "letterpaths"; const prepared = compileTracingPath(buildHandwritingPath("sys")); const annotations = compileFormationAnnotations(prepared, { directionalDashes: { spacing: 80, length: 60 }, startArrows: { offset: 13 }, midpointArrows: { density: 320, offset: 13 } }); const firstArrow = annotations.find((item) => item.kind === "start-arrow"); const svgPathData = annotationCommandsToSvgPathData(firstArrow?.commands ?? []);
Available annotation kinds:
turning-pointstart-arrowdraw-order-numbermidpoint-arrowdirectional-dash
Important defaults:
turningPoints,drawOrderNumbers,startArrows, andmidpointArrowsare enabled unless set tofalsedirectionalDashesis opt-in and only generated when you pass options for it
CompileFormationAnnotationsOptions:
sections?: TracingSection[]sectionAnalysis?: AnalyzeTracingSectionsOptionsdirectionalDashes?: false | DirectionalDashAnnotationOptionsturningPoints?: false | TurningPointAnnotationOptionsstartArrows?: false | StartArrowAnnotationOptionsdrawOrderNumbers?: false | DrawOrderNumberAnnotationOptionsmidpointArrows?: false | MidpointArrowAnnotationOptions
If you only want the older retrace U-turn arrows, compileFormationArrows() is still exported:
import { buildHandwritingPath, compileFormationArrows, compileTracingPath, formationArrowCommandsToSvgPathData } from "letterpaths"; const prepared = compileTracingPath(buildHandwritingPath("p")); const arrows = compileFormationArrows(prepared); const pathData = formationArrowCommandsToSvgPathData(arrows[0]?.commands ?? []);
Built-in glyph data
The package exports the built-in lowercase cursive dataset and its variant helpers from src/data.
Useful exports:
letterslettersByIdlettersByVariantIdgetCursiveLetterVariant(char, entryVariant?)createLetterId(char, entryVariant?)createLegacyLetterId(char)defaultCursiveEntryVariantcursiveEntryVariantByExitVariantcursiveExitVariantByLetter
The built-in data currently covers lowercase a-z in two cursive entry variants:
"entry-low""entry-high"
print and pre-cursive output are produced from this same underlying cursive data by filtering which entry and exit segments are kept.
Export surface
The package root exports:
- all public types from
src/types.ts CubicBezier- the layout builders and layout option types
- animation APIs
- tracing APIs
- annotation APIs
- built-in data helpers
If you want the most stable way to discover the package surface in code, check src/index.ts.