letterpaths
v1.0.1
Published
`letterpaths` is a headless TypeScript library for generating handwriting geometry.
Readme
letterpaths
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: {
minSidebearingGap: 50,
targetBendRate: 30
},
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?: JoinSpacingOptionstunes cursive joinskeepInitialLeadIn?: booleankeeps the first letter's lead-in in cursive outputkeepFinalLeadOut?: booleankeeps the last letter's lead-out in cursive output
Notes:
joinSpacing,keepInitialLeadIn, andkeepFinalLeadOutonly affect cursive joiningjoinMetricsis 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.
