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:
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.
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?: LetterGuides rescales the output onto your guide systemwordSpacing?: number overrides the spacing used for spacesjoinSpacing?: JoinSpacingOptions adds a constant adjustment to hard-coded cursive pair gapscapitalKerning?: CapitalToLowercaseKerningPairs overrides the built-in capital-to-lowercase visible gaps (with separate values for lead-in and no-lead-in forms)keepInitialLeadIn?: boolean keeps the first letter's lead-in/entry strokekeepFinalLeadOut?: boolean keeps the last letter's exit/lead-out strokeNotes:
joinSpacing only affects cursive joiningjoinMetrics and capitalKerningMetrics are only populated for cursive outputWritingPath.strokes and are useful for dots and similar marksThe 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()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?)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?: numberAnimationFrame:
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 has two layers:
compileTracingPath() converts a WritingPath into evenly sampled strokesTracingSession manages pointer-driven progress through that prepared pathimport {
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?: number distance between samples in pixels, default 2PreparedTracingPath:
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".
TracingSessionTracingSession methods:
getState()getPath()beginAt(point)update(point)end()reset()TracingSessionOptions:
startTolerance?: numberhitTolerance?: numbermaxAdvanceSamples?: numberadvanceBias?: numberTracingState.status is one of:
"idle""tracing""await_pen_up""complete"Dots are auto-completed when beginAt() starts on a dot stroke.
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 turnsimport {
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;
};
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-dashImportant defaults:
turningPoints, drawOrderNumbers, startArrows, and midpointArrows are enabled unless set to falsedirectionalDashes is opt-in and only generated when you pass options for itCompileFormationAnnotationsOptions:
sections?: TracingSection[]sectionAnalysis?: AnalyzeTracingSectionsOptionsdirectionalDashes?: false | DirectionalDashAnnotationOptionsturningPoints?: false | TurningPointAnnotationOptionsstartArrows?: false | StartArrowAnnotationOptionsdrawOrderNumbers?: false | DrawOrderNumberAnnotationOptionsmidpointArrows?: false | MidpointArrowAnnotationOptionsIf 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 ?? []);
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)defaultCursiveEntryVariantcursiveEntryVariantByExitVariantcursiveExitVariantByLetterThe 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.
The package root exports:
src/types.tsCubicBezierIf you want the most stable way to discover the package surface in code, check src/index.ts.
208 commits
Hacker News (1)
TypeScript
81.1%
CSS
7.7%
HTML
4.4%
JavaScript
4.0%
Python
2.8%
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:
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.
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?: LetterGuides rescales the output onto your guide systemwordSpacing?: number overrides the spacing used for spacesjoinSpacing?: JoinSpacingOptions adds a constant adjustment to hard-coded cursive pair gapscapitalKerning?: CapitalToLowercaseKerningPairs overrides the built-in capital-to-lowercase visible gaps (with separate values for lead-in and no-lead-in forms)keepInitialLeadIn?: boolean keeps the first letter's lead-in/entry strokekeepFinalLeadOut?: boolean keeps the last letter's exit/lead-out strokeNotes:
joinSpacing only affects cursive joiningjoinMetrics and capitalKerningMetrics are only populated for cursive outputWritingPath.strokes and are useful for dots and similar marksThe 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()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?)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?: numberAnimationFrame:
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 has two layers:
compileTracingPath() converts a WritingPath into evenly sampled strokesTracingSession manages pointer-driven progress through that prepared pathimport {
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?: number distance between samples in pixels, default 2PreparedTracingPath:
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".
TracingSessionTracingSession methods:
getState()getPath()beginAt(point)update(point)end()reset()TracingSessionOptions:
startTolerance?: numberhitTolerance?: numbermaxAdvanceSamples?: numberadvanceBias?: numberTracingState.status is one of:
"idle""tracing""await_pen_up""complete"Dots are auto-completed when beginAt() starts on a dot stroke.
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 turnsimport {
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;
};
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-dashImportant defaults:
turningPoints, drawOrderNumbers, startArrows, and midpointArrows are enabled unless set to falsedirectionalDashes is opt-in and only generated when you pass options for itCompileFormationAnnotationsOptions:
sections?: TracingSection[]sectionAnalysis?: AnalyzeTracingSectionsOptionsdirectionalDashes?: false | DirectionalDashAnnotationOptionsturningPoints?: false | TurningPointAnnotationOptionsstartArrows?: false | StartArrowAnnotationOptionsdrawOrderNumbers?: false | DrawOrderNumberAnnotationOptionsmidpointArrows?: false | MidpointArrowAnnotationOptionsIf 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 ?? []);
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)defaultCursiveEntryVariantcursiveEntryVariantByExitVariantcursiveExitVariantByLetterThe 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.
The package root exports:
src/types.tsCubicBezierIf you want the most stable way to discover the package surface in code, check src/index.ts.
Hacker News (1)
208 commits
TypeScript
81.1%
CSS
7.7%
HTML
4.4%
JavaScript
4.0%
Python
2.8%