kakitori

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

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.


        

Examples

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.

1. Normal

Default mount: cross-grid on, hanzi-writer paints each accepted stroke.

const c = char.create("学");
c.mount(target, { size: 160 });
c.start();

2. No grid

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();

3. No outline

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();

4. Per-char correction

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();

5. Retain strokes, hide accepted stroke

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();

CharResult

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

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.


      

Examples

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.

1. Normal (two guided cells, write)

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,
});

2. Show + write

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,
});

3. Free cell

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,
});

4. With furigana annotation

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,
});

5. Per-block correction

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",
});

BlockResult

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

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.


      

PageResult

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,
});

sizing

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.

y (up) x 900 0 -124 0 1024 y = 0 (baseline) descender character region 1024 × 1024 1024 units wide (x)
hanzi-writer's internal coordinate system, which is what 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.

1. Same character, three sizes, shared drawingWidth

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.

size = 80, drawingWidth = 6
size = 160, drawingWidth = 6
size = 280, drawingWidth = 6
// 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();

2. result.points stay in internal coords no matter the cell size

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.

latest stroke

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

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.

Slide to change leniency, then write 学

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();

restore

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.

Write once, restore at any size

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.

source cell (size = 240)

restored previews

size = 80
size = 160
size = 240
// `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 });

block.restore — multi-cell block with furigana annotation

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.

source block (cellSize = 120)

restored previews

cellSize = 60
cellSize = 100
block.restore(target, blockResult, {
  cellSize: 60,
});

page.restore — 3 blocks on a page, restore at a smaller layout

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.

source page (cellSize = 120)

restored preview

cellSize = 100
page.restore(target, pageResult, {
  columns: 3,
  cellsPerColumn: 3,
  cellSize: 100,
});