Skip to content

CJK Name Stroke Onomancy

A CJK five-grid (Japanese seimei-handan 姓名判断 / Chinese 五格剖象) engine that computes the heaven, person, earth, outer, and total grids from the brush-stroke counts of a name's characters, using the standard 熊崎式 formulas (including the single-character 霊数 "spirit number" rule). The selected school and character_set are recorded for provenance.

from fortune_telling_core import ReadingRequest
from fortune_telling_core.traditions.cjk_name_strokes import (
    CJK_NAME_STROKES_DECK,
    CJK_NAME_STROKES_SPREAD,
    build_engine,
)

request = ReadingRequest(
    deck_id=CJK_NAME_STROKES_DECK.id,
    spread_id=CJK_NAME_STROKES_SPREAD.id,
    options={"surname": "山田", "given_name": "太郎"},
)
reading = build_engine().cast(request)
# reading.summary -> "CJK name stroke total 20; heaven 8; person 9; earth 12; outer 11."

Stroke sources

Every character needs a stroke count, supplied by a stroke-count provider. The stroke_source option names the provider to use (default "unihan"); providers are looked up in a registry. The resolved per-character counts and the provider's id/version are recorded on the reading (values, value_system) so a reading is reproducible and auditable.

Bundled Unihan table (unihan, the default)

By default counts come from the bundled Unicode Unihan kTotalStrokes table — no preparation is needed (the example above uses it). These are representative-glyph totals per Unicode UAX #38, not the Kangxi / old-form counts that some seimei-handan schools require; the reading records value_system=cjk_unihan_strokes.v1 so the basis is explicit.

Unihan counts can differ from a school's

A character's stroke count is glyph-dependent, and Unihan records the count for its representative glyph. For example 郎 (U+90CE) is 8 in Unihan but 9 in the Japanese tradition (KANJIDIC lists "9, also 8"), so 田中太郎 totals 20 under the Unihan default versus 21 under the seimei-handan count. Where Unihan lists two values (zh-Hans then zh-Hant), the bundled table keeps the first. For tradition-faithful counts, register a provider (below) using the convention your school expects.

Third-party dataset (a registered provider)

For school-specific or higher-fidelity counts, register your own provider. The library bundles no third-party data — KANJIDIC2 and KanjiVG are share-alike (CC BY-SA), whose ShareAlike terms preclude redistributing them here — so you obtain the data yourself and comply with its licence. The library ships parsers for two common formats.

1. Obtain the dataset (and follow its licence terms):

2. Parse it into a {character: stroke_count} mapping, wrap it in a MappingStrokeProvider (its id/version are recorded as the reading's value system), and register it under a name:

from fortune_telling_core.traditions.cjk_name_strokes import (
    MappingStrokeProvider,
    parse_kanjidic2,
    register_provider,
)

with open("kanjidic2.xml", "rb") as fh:
    table = parse_kanjidic2(fh)   # {"山": 3, "田": 5, ...}
register_provider(MappingStrokeProvider("kanjidic2", "2024-01", table))

3. Select the provider by name via stroke_source:

request = ReadingRequest(
    deck_id=CJK_NAME_STROKES_DECK.id,
    spread_id=CJK_NAME_STROKES_SPREAD.id,
    options={"surname": "山田", "given_name": "太郎", "stroke_source": "kanjidic2"},
)
reading = build_engine().cast(request)
# provenance notes include: stroke_source=kanjidic2, value_system=kanjidic2

register_provider adds to a process-wide default registry. To avoid global state (e.g. in tests or a multi-tenant app), build a private registry instead and inject it:

from fortune_telling_core.traditions.cjk_name_strokes import new_default_registry

registry = new_default_registry()  # seeded with "unihan"
registry.register(MappingStrokeProvider("kanjidic2", "2024-01", table), name="kanjidic2")
reading = build_engine(registry=registry).cast(request)

Any object satisfying the StrokeCountProvider protocol (id, version, stroke_count(char) -> int | None) can be registered, so a school table with its own Kangxi/radical-restoration rules plugs in the same way. parse_kanjidic2 / parse_kanjivg produce literal, as-printed counts without radical restoration, so — like the bundled Unihan table — they are not faithful to Kangxi-based schools.

cjk_name_strokes

CJK_NAME_STROKES_DECK module-attribute

CJK_NAME_STROKES_DECK = Deck(
    id="cjk_name_strokes.deck.five_grid.v1",
    symbols=tuple(
        (
            Symbol(
                id=symbol_id,
                name=f"{position.title()} Grid",
                attributes={
                    "kind": "stroke_grid",
                    "grid_position": position,
                },
            )
        )
        for position, symbol_id in (GRID_SYMBOLS.items())
    ),
)

DEFAULT_PROVIDER module-attribute

DEFAULT_PROVIDER = 'unihan'

CJK_NAME_STROKES_SPREAD module-attribute

CJK_NAME_STROKES_SPREAD = Spread(
    id="cjk_name_strokes.spread.five_grid.v1",
    name="CJK Name Stroke Five-Grid",
    positions=(
        Position(
            "heaven", "Heaven", "Surname stroke grid value."
        ),
        Position(
            "person",
            "Person",
            "Last surname plus first given-name grid value.",
        ),
        Position(
            "earth",
            "Earth",
            "Given-name stroke grid value.",
        ),
        Position(
            "outer",
            "Outer",
            "Total less person grid value.",
        ),
        Position(
            "total", "Total", "Total stroke grid value."
        ),
    ),
)

CharacterSet

Bases: StrEnum

Declared character-set basis for request-supplied counts.

TRADITIONAL class-attribute instance-attribute

TRADITIONAL = 'traditional'

SIMPLIFIED class-attribute instance-attribute

SIMPLIFIED = 'simplified'

SHINJITAI class-attribute instance-attribute

SHINJITAI = 'shinjitai'

KYUJITAI class-attribute instance-attribute

KYUJITAI = 'kyujitai'

CjkNameStrokesEngine

CjkNameStrokesEngine(
    *, registry: StrokeProviderRegistry | None = None
)

Bases: AbstractEngine

CJK name stroke onomancy engine.

Stroke counts are resolved by a named :class:StrokeCountProvider selected via the stroke_source option (default "unihan", the bundled Unihan table). Providers are looked up in registry — the process-wide default registry unless one is injected.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/engine.py
def __init__(self, *, registry: StrokeProviderRegistry | None = None) -> None:
    self._registry = registry if registry is not None else default_registry()

id class-attribute instance-attribute

id = 'cjk_name_strokes.engine'

version class-attribute instance-attribute

version = '0.1.0'

deck

deck(request: ReadingRequest) -> Deck

Return the CJK name strokes deck.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/engine.py
def deck(self, request: ReadingRequest) -> Deck:
    """Return the CJK name strokes deck."""

    if request.deck_id != CJK_NAME_STROKES_DECK.id:
        raise ValidationError(f"unsupported CJK name strokes deck: {request.deck_id}")
    return CJK_NAME_STROKES_DECK

spread

spread(request: ReadingRequest) -> Spread

Return the CJK name strokes spread.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/engine.py
def spread(self, request: ReadingRequest) -> Spread:
    """Return the CJK name strokes spread."""

    if request.spread_id != CJK_NAME_STROKES_SPREAD.id:
        raise ValidationError(f"unsupported CJK name strokes spread: {request.spread_id}")
    return CJK_NAME_STROKES_SPREAD

draw

draw(request: ReadingRequest, rng: Rng) -> Draw

Compute the CJK five-grid stroke chart as a deterministic draw.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/engine.py
def draw(self, request: ReadingRequest, rng: Rng) -> Draw:
    """Compute the CJK five-grid stroke chart as a deterministic draw."""

    del rng
    fields = collect_values(request)
    surname = require_string(fields, "surname")
    given_name = require_string(fields, "given_name")
    school = _parse(fields.get("school"), School.JAPANESE_SEIMEI_HANDAN)
    character_set = _parse(fields.get("character_set"), CharacterSet.SHINJITAI)
    grid = _parse(fields.get("grid"), Grid.FIVE_GRID)
    provider_name = fields.get("stroke_source") or DEFAULT_PROVIDER
    provider = self._registry.get(provider_name)
    strokes = _resolve_strokes(surname, given_name, provider, provider_name)
    chart = _compute_chart(surname, given_name, strokes)

    common = {
        "value_system": provider.id,
        "value_system_version": provider.version,
        "school": school.value,
        "character_set": character_set.value,
        "stroke_source": provider_name,
        "grid": grid.value,
    }
    trace = {
        **common,
        "surname": chart.surname,
        "given_name": chart.given_name,
        "characters": "".join(unit.char for unit in chart.units),
        "values": format_value_trace(chart.units),
    }
    selections = (
        _grid_selection("heaven", chart.heaven, common),
        _grid_selection("person", chart.person, common),
        _grid_selection("earth", chart.earth, common),
        _grid_selection("outer", chart.outer, common),
        Selection(
            "total",
            GRID_SYMBOLS["total"],
            {
                **trace,
                "value": str(chart.total),
                "total": str(chart.total),
                "heaven": str(chart.heaven),
                "person": str(chart.person),
                "earth": str(chart.earth),
                "outer": str(chart.outer),
            },
        ),
    )
    return Draw(CJK_NAME_STROKES_DECK.id, CJK_NAME_STROKES_SPREAD.id, selections)

cast

cast(request: ReadingRequest) -> Reading

Compute a CJK name stroke reading without a caller RNG.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/engine.py
def cast(self, request: ReadingRequest) -> Reading:
    """Compute a CJK name stroke reading without a caller RNG."""

    draw = self.draw(request, _NULL_RNG)
    return self._interpret(request, draw, rng=None)

Grid

Bases: StrEnum

Supported stroke grids.

FIVE_GRID class-attribute instance-attribute

FIVE_GRID = 'five_grid'

School

Bases: StrEnum

Supported CJK name stroke schools.

JAPANESE_SEIMEI_HANDAN class-attribute instance-attribute

JAPANESE_SEIMEI_HANDAN = 'japanese_seimei_handan'

CHINESE_XINGMINGXUE class-attribute instance-attribute

CHINESE_XINGMINGXUE = 'chinese_xingmingxue'

MappingStrokeProvider dataclass

MappingStrokeProvider(
    id: str, version: str, mapping: Mapping[str, int]
)

A provider backed by an in-memory {character: stroke_count} mapping.

Wrap a dataset parsed by :func:fortune_telling_core.traditions.cjk_name_strokes.parsers.parse_kanjidic2 / parse_kanjivg (or your own school table) and register it; id and version identify the dataset in provenance.

id instance-attribute

id: str

version instance-attribute

version: str

mapping instance-attribute

mapping: Mapping[str, int]

stroke_count

stroke_count(char: str) -> int | None
Source code in src/fortune_telling_core/traditions/cjk_name_strokes/providers.py
def stroke_count(self, char: str) -> int | None:
    return self.mapping.get(char)

StrokeCountProvider

Bases: Protocol

Resolves CJK characters to total stroke counts under a named source.

Implementations expose a stable id and version (recorded as the reading's value system) and resolve single characters to counts.

id property

id: str

Stable identifier recorded as the reading's value system.

version property

version: str

Value-system version recorded on the reading.

stroke_count

stroke_count(char: str) -> int | None

Return the total stroke count for char, or None if unknown.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/providers.py
def stroke_count(self, char: str) -> int | None:
    """Return the total stroke count for ``char``, or ``None`` if unknown."""

StrokeProviderRegistry

StrokeProviderRegistry()

A name to :class:StrokeCountProvider registry.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/providers.py
def __init__(self) -> None:
    self._providers: dict[str, StrokeCountProvider] = {}

register

register(
    provider: StrokeCountProvider,
    *,
    name: str | None = None,
) -> str

Register provider under name (defaults to provider.id).

Parameters:

Name Type Description Default
provider StrokeCountProvider

The provider to register.

required
name str | None

The lookup name; defaults to the provider's id.

None

Returns:

Type Description
str

The name the provider was registered under.

Raises:

Type Description
ValidationError

If the resolved name is empty.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/providers.py
def register(self, provider: StrokeCountProvider, *, name: str | None = None) -> str:
    """Register ``provider`` under ``name`` (defaults to ``provider.id``).

    Args:
        provider: The provider to register.
        name: The lookup name; defaults to the provider's ``id``.

    Returns:
        The name the provider was registered under.

    Raises:
        ValidationError: If the resolved name is empty.
    """

    key = name if name is not None else provider.id
    if not key:
        raise ValidationError("stroke provider name must not be empty")
    self._providers[key] = provider
    return key

get

get(name: str) -> StrokeCountProvider

Return the provider registered under name.

Raises:

Type Description
ValidationError

If no provider is registered under name.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/providers.py
def get(self, name: str) -> StrokeCountProvider:
    """Return the provider registered under ``name``.

    Raises:
        ValidationError: If no provider is registered under ``name``.
    """

    provider = self._providers.get(name)
    if provider is None:
        available = ", ".join(self.names()) or "(none)"
        raise ValidationError(f"unknown stroke provider: {name!r} (registered: {available})")
    return provider

names

names() -> tuple[str, ...]

Return the registered provider names, sorted.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/providers.py
def names(self) -> tuple[str, ...]:
    """Return the registered provider names, sorted."""

    return tuple(sorted(self._providers))

build_engine

build_engine(
    *, registry: StrokeProviderRegistry | None = None
) -> CjkNameStrokesEngine

Create a CJK name stroke onomancy engine.

Parameters:

Name Type Description Default
registry StrokeProviderRegistry | None

Stroke-provider registry to resolve stroke_source against; defaults to the process-wide registry.

None
Source code in src/fortune_telling_core/traditions/cjk_name_strokes/engine.py
def build_engine(*, registry: StrokeProviderRegistry | None = None) -> CjkNameStrokesEngine:
    """Create a CJK name stroke onomancy engine.

    Args:
        registry: Stroke-provider registry to resolve ``stroke_source`` against;
            defaults to the process-wide registry.
    """

    return CjkNameStrokesEngine(registry=registry)

parse_kanjidic2

parse_kanjidic2(source: _Source) -> dict[str, int]

Parse total stroke counts from an EDRDG KANJIDIC2 XML file.

Each <character> carries its glyph in <literal> and one or more <misc><stroke_count> values. Per the KANJIDIC2 DTD, the first stroke_count is the accepted count and any others are common miscounts, so only the first is used.

Parameters:

Name Type Description Default
source _Source

A path to the KANJIDIC2 XML file, or a binary file object.

required

Returns:

Type Description
dict[str, int]

A mapping of each character to its accepted total stroke count.

Raises:

Type Description
ValidationError

If a <character> has a literal but no stroke count.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/parsers.py
def parse_kanjidic2(source: _Source) -> dict[str, int]:
    """Parse total stroke counts from an EDRDG KANJIDIC2 XML file.

    Each ``<character>`` carries its glyph in ``<literal>`` and one or more
    ``<misc><stroke_count>`` values. Per the KANJIDIC2 DTD, the first
    ``stroke_count`` is the accepted count and any others are common miscounts,
    so only the first is used.

    Args:
        source: A path to the KANJIDIC2 XML file, or a binary file object.

    Returns:
        A mapping of each character to its accepted total stroke count.

    Raises:
        ValidationError: If a ``<character>`` has a literal but no stroke count.
    """

    table: dict[str, int] = {}
    for _event, element in iterparse(source, events=("end",)):
        if _local(element.tag) != "character":
            continue
        literal: str | None = None
        strokes: int | None = None
        for child in element:
            name = _local(child.tag)
            if name == "literal":
                literal = (child.text or "").strip() or None
            elif name == "misc":
                for grandchild in child:
                    if _local(grandchild.tag) == "stroke_count":
                        strokes = int((grandchild.text or "").strip())
                        break
        element.clear()
        if literal is None:
            continue
        if strokes is None:
            raise ValidationError(f"KANJIDIC2 character {literal!r} has no stroke_count")
        table[literal] = strokes
    return table

parse_kanjivg

parse_kanjivg(source: _Source) -> dict[str, int]

Parse stroke counts from KanjiVG SVG or aggregated XML data.

Each kanji's strokes live under a group whose id is kvg:<hex> (the bare codepoint, without the StrokePaths_/kanji_ wrappers or the -g/-s component suffixes). The stroke count is the number of <path> elements in that group; the <text> stroke-number annotations live in a separate group and are not counted. This works for both a single <svg> file and the aggregated kanjivg-*.xml release.

Parameters:

Name Type Description Default
source _Source

A path to a KanjiVG SVG / XML file, or a binary file object.

required

Returns:

Type Description
dict[str, int]

A mapping of each character to its stroke count.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/parsers.py
def parse_kanjivg(source: _Source) -> dict[str, int]:
    """Parse stroke counts from KanjiVG SVG or aggregated XML data.

    Each kanji's strokes live under a group whose ``id`` is ``kvg:<hex>`` (the
    bare codepoint, without the ``StrokePaths_``/``kanji_`` wrappers or the
    ``-g``/``-s`` component suffixes). The stroke count is the number of
    ``<path>`` elements in that group; the ``<text>`` stroke-number annotations
    live in a separate group and are not counted. This works for both a single
    ``<svg>`` file and the aggregated ``kanjivg-*.xml`` release.

    Args:
        source: A path to a KanjiVG SVG / XML file, or a binary file object.

    Returns:
        A mapping of each character to its stroke count.
    """

    table: dict[str, int] = {}
    for _event, element in iterparse(source, events=("end",)):
        if _local(element.tag) != "g":
            continue
        match = _KVG_ROOT_ID.fullmatch(element.get("id", ""))
        if match is None:
            continue
        char = chr(int(match.group(1), 16))
        table[char] = sum(1 for node in element.iter() if _local(node.tag) == "path")
        element.clear()
    return table

default_registry

default_registry() -> StrokeProviderRegistry

Return the process-wide default stroke-provider registry.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/providers.py
def default_registry() -> StrokeProviderRegistry:
    """Return the process-wide default stroke-provider registry."""

    return _DEFAULT_REGISTRY

new_default_registry

new_default_registry() -> StrokeProviderRegistry

Return a fresh registry seeded with only the bundled unihan provider.

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/providers.py
def new_default_registry() -> StrokeProviderRegistry:
    """Return a fresh registry seeded with only the bundled ``unihan`` provider."""

    registry = StrokeProviderRegistry()
    registry.register(_UnihanStrokeProvider(), name=DEFAULT_PROVIDER)
    return registry

register_provider

register_provider(
    provider: StrokeCountProvider,
    *,
    name: str | None = None,
) -> str

Register provider in the process-wide default registry.

Parameters:

Name Type Description Default
provider StrokeCountProvider

The provider to register.

required
name str | None

The lookup name; defaults to the provider's id.

None

Returns:

Type Description
str

The name the provider was registered under (use it as stroke_source).

Source code in src/fortune_telling_core/traditions/cjk_name_strokes/providers.py
def register_provider(provider: StrokeCountProvider, *, name: str | None = None) -> str:
    """Register ``provider`` in the process-wide default registry.

    Args:
        provider: The provider to register.
        name: The lookup name; defaults to the provider's ``id``.

    Returns:
        The name the provider was registered under (use it as ``stroke_source``).
    """

    return _DEFAULT_REGISTRY.register(provider, name=name)