Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Changelog

## [Unreleased]
### Added
- `Buhin#onMissing` callback that is invoked when `search` is called with a name not registered in the store. Allows surfacing data inconsistencies that would otherwise be silently dropped by the engine. Defaults to `null`, preserving the original silent-fallback behavior. The callback may return a replacement string, or `undefined` to fall back to `""`.

## [0.6.1] - 2026-03-08
### Fixed
Expand Down
1 change: 1 addition & 0 deletions docs/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 25 additions & 6 deletions docs/classes/Buhin.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions docs/type-aliases/BuhinMissingHandler.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 50 additions & 2 deletions src/buhin.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,50 @@
/**
* Callback invoked when {@link Buhin.search} is called for a name that has no
* registered data. The return value, if a string, is used as the lookup result;
* returning `undefined` (or omitting the return) preserves the default behavior
* of returning `""`.
*
* Useful for surfacing data inconsistencies that would otherwise be silently
* dropped by the engine — for example, while building fonts from large dumps
* where a missing buhin causes downstream `99:` instructions to disappear
* without any warning.
*
* @example
* ```ts
* // Log warnings for every missing buhin while keeping the default fallback:
* kage.kBuhin.onMissing = (name) => {
* console.warn(`Buhin "${name}" is missing`);
* };
*
* // Or fail fast:
* kage.kBuhin.onMissing = (name) => {
* throw new Error(`Buhin "${name}" is missing`);
* };
* ```
*/
export type BuhinMissingHandler = (name: string) => string | undefined;

/**
* A key-value store that maps a glyph name to a string of KAGE data.
*/
export class Buhin {
/** The object whose keys are glyph names and whose values are KAGE data. */
protected hash: Record<string, string>;

/**
* Optional callback invoked from {@link search} whenever a name has no
* registered data. See {@link BuhinMissingHandler} for usage.
*
* Defaults to `null` (silent fallback to `""`), preserving the original
* behavior so existing callers are unaffected.
*/
public onMissing: BuhinMissingHandler | null;

constructor() {
// initialize
// no operation
this.hash = {};
this.onMissing = null;
}

// method
Expand All @@ -24,13 +60,25 @@ export class Buhin {
/**
* Searches the store for the given glyph name and returns the corresponding
* KAGE data.
*
* If the name is not registered and {@link onMissing} is set, the callback
* is invoked with the name. If the callback returns a string, that value is
* used as the lookup result; otherwise the default value `""` is returned.
*
* @param name - The name of the glyph to be looked up.
* @returns The KAGE data if found, otherwise an empty string.
* @returns The KAGE data if found, the value returned by {@link onMissing}
* if it is a string, otherwise an empty string.
*/
public search(name: string): string {
if (this.hash[name]) {
if (Object.prototype.hasOwnProperty.call(this.hash, name)) {
return this.hash[name];
}
if (this.onMissing) {
const replacement = this.onMissing(name);
if (typeof replacement === "string") {
return replacement;
}
}
return ""; // no data
}

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export { Polygons } from "./polygons.js";
export { Buhin } from "./buhin.js";
export { KShotai } from "./font/index.js";

export type { BuhinMissingHandler } from "./buhin.js";
export type { Font, Mincho, Gothic } from "./font/index.js";
export type { Polygon, Point } from "./polygon.js";
87 changes: 86 additions & 1 deletion test/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,91 @@
/* global console */

import { Kage, Polygons } from "@kurgm/kage-engine";
import { Kage, Polygons, Buhin } from "@kurgm/kage-engine";

// ─── Buhin#onMissing tests ─────────────────────────────────────────

function assert(cond, msg) {
if (!cond) {
throw new Error(`Assertion failed: ${msg}`);
}
}

// Default behavior: missing names return "" without invoking any callback.
{
const b = new Buhin();
b.push("foo", "1:0:0:10:10:20:20");
assert(b.search("foo") === "1:0:0:10:10:20:20", "search returns registered data");
assert(b.search("bar") === "", "search returns '' for missing names by default");
assert(b.onMissing === null, "onMissing defaults to null");
}

// onMissing fires for missing names and is not invoked for registered ones.
{
const b = new Buhin();
b.push("foo", "1:0:0:10:10:20:20");
const calls = [];
b.onMissing = (name) => {
calls.push(name);
};
assert(b.search("foo") === "1:0:0:10:10:20:20", "registered name skips onMissing");
assert(calls.length === 0, "onMissing not called for present name");
assert(b.search("bar") === "", "missing name still returns '' when handler returns undefined");
assert(calls.length === 1 && calls[0] === "bar", "onMissing called with the missing name");
}

// onMissing returning a string is used as the lookup result.
{
const b = new Buhin();
b.onMissing = () => "1:0:0:0:0:200:200";
assert(b.search("anything") === "1:0:0:0:0:200:200", "string return overrides default");
}

// onMissing throwing propagates to the caller (fail-fast pattern).
{
const b = new Buhin();
b.onMissing = (name) => {
throw new Error(`missing: ${name}`);
};
let thrown = null;
try {
b.search("bar");
} catch (e) {
thrown = e;
}
assert(thrown !== null && /missing: bar/.test(thrown.message), "throw propagates from onMissing");
}

// onMissing is consulted only when the name is genuinely absent — even if the
// stored value is the empty string, the registered entry takes precedence.
{
const b = new Buhin();
b.set("empty", "");
let called = false;
b.onMissing = () => {
called = true;
return "fallback";
};
assert(b.search("empty") === "", "stored '' is returned without invoking onMissing");
assert(called === false, "onMissing not called when name is registered");
}

// End-to-end: a missing 99: target surfaces through onMissing during makeGlyph.
{
const kage = new Kage();
const seen = [];
kage.kBuhin.onMissing = (name) => {
seen.push(name);
};
// Reference an unregistered buhin from a 99: stroke.
kage.kBuhin.push("dummy", "99:0:0:0:0:200:200:not-registered");
const polygons = new Polygons();
kage.makeGlyph(polygons, "dummy");
assert(seen.includes("not-registered"), "onMissing surfaces missing 99: targets during makeGlyph");
}

console.log("Buhin#onMissing: ok");



/**
* @param {Record<string, string>} buhins
Expand Down
Loading