A handwriting practice library for kanji, kana, and numbers. Per-stroke tome / hane / harai check, composable practice problems, and a paper-style multi-block layout.
npm install @k1low/kakitori
kakitori is built around three primitives: char for one character,
block for one practice problem (cells + furigana), and
page for a full vertical-rl practice sheet of many blocks.
char handles a single character. You can render it as a static SVG,
mount an interactive writer that checks each stroke, or check strokes headlessly without a DOM.
import { char } from "@k1low/kakitori";
// Mount an interactive writer that checks each stroke.
const target = document.getElementById("writer")!;
const c = char.create("学");
c.mount(target, {
size: 300,
onCorrectStroke: (data) => console.log("OK", data.strokeNum),
onMistake: (data) => console.log("NG", data.strokeNum),
onComplete: ({ totalMistakes }) => console.log("done", totalMistakes),
});
c.start();
// Static SVG render with no interaction.
char.render(document.getElementById("preview")!, "学", { size: 80 });
Live example. Pick any character from the gallery below to start a practice session. The slot row shows the expected stroke ending (tome / hane / harai) for each stroke, and is updated to OK / NG after each stroke is checked.
Five common mount configurations. Each example has its own writer instance — Restart re-arms the writer from scratch (clears the canvas + starts a fresh practice session). The chip row below each cell colors green / red as strokes are checked.
Default mount: cross-grid on, hanzi-writer paints each accepted stroke.
const c = char.create("学");
c.mount(target, { size: 160 });
c.start();
Same configuration as above, but the cross-grid is suppressed for a cleaner cell background.
const c = char.create("学");
c.mount(target, { size: 160, showGrid: false });
c.start();
The faint outline is suppressed too, so the cell starts completely blank.
const c = char.create("学");
c.mount(target, { size: 160, showOutline: false });
c.start();
User writes every stroke freely without per-stroke rejection. Correction runs once all strokes are captured; NG attempts wipe and re-arm in place.
const c = char.create("学");
c.mount(target, { size: 160, correction: "per-char" });
c.start();
Paper-like UX — the user's own ink stays on screen after each accepted stroke, and hanzi-writer's official gray stroke is suppressed.
const c = char.create("学");
c.mount(target, {
size: 160,
retainStrokes: true,
showAcceptedStroke: false,
});
c.start();
c.result() returns a CharResult. This is the leaf type used
across the whole stack. The same shape comes back from headless Char.checkStroke()
and from the mounted interactive path.
interface CharResult {
character: string;
complete: boolean; // every logical stroke has been observed
matched: boolean; // every observed stroke matched
perStroke: CharStrokeResult[];
mistakes?: number; // guided-only
strokeEndingMistakes?: number; // guided-only
similarity?: number; // free / annotation only
candidate?: string; // free / annotation only
// The two fields below are populated when a CharResult comes through
// a Block / Page result tree (undefined for standalone char.result()).
source?: "guided" | "free" | "annotation";
mode?: "write" | "show";
}
interface CharStrokeResult {
matched: boolean;
similarity: number;
strokeEnding?: StrokeEndingResult;
points?: TimedPoint[]; // raw drawn samples with timestamps
mistakesOnStroke?: number; // guided write only
isBackwards?: boolean; // guided write only
}
block represents one practice problem: a row of cells plus an optional furigana annotation strip.
Cells can be guided (one specific character to write or show), free (any of an
expected string, matched freehand), or blank (visual placeholder).
import { block } from "@k1low/kakitori/block";
const target = document.getElementById("block-host")!;
const b = block.create(target, {
spec: {
cells: [
{ kind: "guided", char: "学", mode: "write" },
{ kind: "guided", char: "校", mode: "write" },
],
annotations: [
{ cellRange: [0, 1], expected: "がっこう", mode: "write" },
],
},
cellSize: 140,
onCellComplete: (index, kind, chars) => { /* per cell finished */ },
onBlockComplete: (result) => { /* this problem finished */ },
});
// Get the structured result tree at any time.
const result = b.result();
Live example. The selector switches between use cases that mix guided / free cells with and without furigana.
Five common block configurations. Each example has its own block instance —
Restart re-creates the block from scratch. The chip row below each block colors green / red
per cell as onCellComplete fires.
Two guided cells side by side. The user writes both characters in order; each cell commits independently.
const b = block.create(target, {
spec: {
cells: [
{ kind: "guided", char: "学", mode: "write" },
{ kind: "guided", char: "校", mode: "write" },
],
},
cellSize: 140,
});
First cell renders the template (read-only); second cell is the writable target. Useful for "trace then write" prompts.
const b = block.create(target, {
spec: {
cells: [
{ kind: "guided", char: "学", mode: "show" },
{ kind: "guided", char: "校", mode: "write" },
],
},
cellSize: 140,
});
A free cell decouples visible width from answer length: expected: "学校" (2 characters)
but span: 3 reserves 3 grid slots, so the user can write freely across the strip.
const b = block.create(target, {
spec: {
cells: [
{ kind: "free", expected: "学校", mode: "write", span: 3 },
],
},
cellSize: 140,
});
Two guided cells plus a write-mode furigana strip spanning both. The annotation accepts the reading freely across the strip.
const b = block.create(target, {
spec: {
cells: [
{ kind: "guided", char: "学", mode: "write" },
{ kind: "guided", char: "校", mode: "write" },
],
annotations: [
{ cellRange: [0, 1], expected: "がっこう", mode: "write" },
],
},
cellSize: 140,
});
Block-wide correction: "per-block": every cell defers its check until the entire block is written. NG cells wipe and re-arm in place;
onCellComplete / onBlockComplete fire in one burst on the final OK round.
const b = block.create(target, {
spec: {
cells: [
{ kind: "guided", char: "学", mode: "write" },
{ kind: "guided", char: "校", mode: "write" },
],
},
cellSize: 140,
correction: "per-block",
});
b.result() returns a BlockResult. Each cell exposes its
CharResult[] (one entry per expected character); each annotation does the same.
mode: "show" entries are display-only and always come back complete: true
with an empty perStroke.
interface BlockResult {
id?: string; // echoed from PageBlockEntry.id when generated by Page
complete: boolean; // all writable chars completed
matched: boolean; // no observed failures
cells: BlockCellResult[];
annotations: BlockAnnotationResult[];
}
interface BlockCellResult {
kind: "guided" | "free" | "blank";
chars: CharResult[]; // guided=1, free=expected length, blank=0
}
interface BlockAnnotationResult {
chars: CharResult[]; // one entry per expected character
}
page lays out multiple blocks on a vertical-rl grid (Japanese practice-sheet style).
Blocks flow column-by-column. A block that crosses a column boundary is split per-cell automatically,
even when it carries a furigana annotation, and strokes drawn across split surfaces are checked
from a shared buffer.
import { page } from "@k1low/kakitori/page";
const target = document.getElementById("page-host")!;
const p = page.create(target, {
writingMode: "vertical-rl",
columns: 5,
cellsPerColumn: 8,
cellSize: 96,
blocks: [
{
id: "q1",
spec: {
cells: [
{ kind: "guided", char: "学", mode: "write" },
{ kind: "guided", char: "校", mode: "write" },
],
annotations: [
{ cellRange: [0, 1], expected: "がっこう", mode: "show" },
],
},
},
{ id: "q2", spec: { cells: [{ kind: "guided", char: "山", mode: "write" }] } },
// ...
],
onBlockComplete: (blockIndex, result) => { /* one problem done */ },
onPageComplete: (result) => { /* whole sheet done */ },
});
Live example. Notice block q9 (図書館 + としょかん) crossing a column boundary,
and block q10 rendering as a 5-cell visual blank.
p.result() returns a PageResult. Each block carries a
BlockResult directly, with the original id echoed back so a caller
can correlate results against the input blocks.
interface PageResult {
complete: boolean;
matched: boolean;
blocks: BlockResult[]; // each entry mirrors the order of opts.blocks
}
// Flatten + filter all CharResult leaves across a result tree.
import { collectCharResults } from "@k1low/kakitori";
const scored = collectCharResults(p.result(), {
sources: ["guided"],
modes: ["write"],
completedOnly: true,
});
Three values control how a cell looks on screen and what comes back as data. They live in deliberately different units so callers can change one without dragging the others along.
size (display px): the cell side length. The character is scaled to fit.drawingWidth (display px): the pen thickness. Size-independent, so a 6px pen draws as 6px ink in a 80px cell or in a 280px cell.result.points (internal coords): always returned in hanzi-writer's 1024-scale system (Y-up; the character region occupies x ∈ [0, 1024], y ∈ [-124, 900]). The numbers do not depend on size, so a saved stroke replays into any cell or layers over the reference paths from @k1low/hanzi-writer-data-jp without rescaling.result.points and the medians / paths from @k1low/hanzi-writer-data-jp both live in. The character region is a 1024 × 1024 square with the Y axis pointing up. The baseline sits at y = 0; descenders (e.g. the tail of ふ) extend down to y = -124, which is why the Y range is asymmetric. To render at a display size of size pixels, multiply by size / 1024 and flip Y around the baseline.
Move the slider and the pen thickness updates on every cell at once. The ink stays the same physical thickness across cells because drawingWidth is in display pixels, not internal coords. Each cell can also be restarted individually.
// All three cells use the same drawingWidth, expressed in display px.
const c = char.create("永");
c.mount(target, { size: 80, drawingWidth: 6, /* ...same for 160 and 280 */ });
c.start();
Write a stroke. The right-hand inspector shows the latest stroke's points. Move the size slider and write again. The on-screen ink scales, but the numeric ranges stay anchored to the 1024-scale internal coordinate system.
draw a stroke to populate this panel.
// The captured points come back in the same numeric range regardless of cell size.
char.create("一").mount(target, {
size: 200, // display px
retainStrokes: true,
showOutline: false,
showCharacter: false,
correction: "per-char",
maxRetries: 0,
onCorrectStroke: ({ points }) => console.log(points), // {x, y, t} in internal coords
onMistake: ({ points }) => console.log(points),
}).start();
leniency tunes the stroke matcher's tolerance. The default (1.0)
is hanzi-writer's baseline; lower values are stricter (sloppy strokes get rejected),
higher values are more permissive. The same option exists at
char.create, block.create (block-wide), and
page.create (page-wide); per-cell overrides.leniency
wins over block/page-wide defaults.
Each stroke is checked against the reference median. Chips below the cell color
green / red per stroke as you write. With leniency = 0.1, even slightly
off strokes are rejected; with 3.0 the matcher becomes very forgiving.
Releasing the slider remounts the writer with the new value (and clears the canvas).
const c = char.create("学", { leniency: 1.0 });
c.mount(target, {
size: 240,
retainStrokes: true,
showAcceptedStroke: false,
});
c.start();
Because CharResult.perStroke[].points stays in the 1024-scale internal coordinate system, a saved CharResult can be redrawn anywhere later via char.restore. No Char instance, no matcher, no quiz lifecycle. Just data going back into pixels.
Trace the outline on the left. Once all strokes are written, the three preview cells on the right are populated by char.restore at three different display sizes from the resulting CharResult. Press Restart to write again.
// `result` was captured from a prior writing session
// (e.g. via onComplete / Char.result()). char.restore renders it back
// without needing a Char instance or any matcher state.
char.restore(target, result, { size: 160 });
Trace 山 / 川 in the source block on the left, plus the furigana やまかわ in the annotation strip. Once every cell and the annotation settle, the right shows the BlockResult restored at two smaller cellSize values via block.restore.
block.restore(target, blockResult, {
cellSize: 60,
});
The source page on the left has 3 blocks (山川 / 大小 / 天地人) laid out in vertical-rl across 3 columns. The 2 + 2 + 3 cell counts overflow each column, so block 2 lands in column 1 and block 3 in column 2. Block 1 also carries a furigana annotation (やまかわ). Trace all 11 characters (山 / 川 / や / ま / か / わ / 大 / 小 / 天 / 地 / 人) and the right shows the PageResult restored at a smaller cellSize via page.restore.
page.restore(target, pageResult, {
columns: 3,
cellsPerColumn: 3,
cellSize: 100,
});