katsuji 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. katsuji-0.1.1/.github/workflows/check.yml +13 -0
  2. katsuji-0.1.1/.github/workflows/release.yml +19 -0
  3. katsuji-0.1.1/.gitignore +8 -0
  4. katsuji-0.1.1/LICENSE +21 -0
  5. katsuji-0.1.1/Makefile +19 -0
  6. katsuji-0.1.1/PKG-INFO +69 -0
  7. katsuji-0.1.1/README.md +57 -0
  8. katsuji-0.1.1/hatch_version.py +15 -0
  9. katsuji-0.1.1/pyproject.toml +59 -0
  10. katsuji-0.1.1/src/katsuji/__init__.py +23 -0
  11. katsuji-0.1.1/src/katsuji/atlas.py +90 -0
  12. katsuji-0.1.1/src/katsuji/banner.py +59 -0
  13. katsuji-0.1.1/src/katsuji/build.py +97 -0
  14. katsuji-0.1.1/src/katsuji/cli.py +67 -0
  15. katsuji-0.1.1/src/katsuji/config.py +164 -0
  16. katsuji-0.1.1/src/katsuji/dialog.py +116 -0
  17. katsuji-0.1.1/src/katsuji/formats.py +99 -0
  18. katsuji-0.1.1/src/katsuji/kerning.py +120 -0
  19. katsuji-0.1.1/src/katsuji/render.py +59 -0
  20. katsuji-0.1.1/src/katsuji/strings.py +63 -0
  21. katsuji-0.1.1/src/katsuji/tiles.py +78 -0
  22. katsuji-0.1.1/src/katsuji/widths.py +25 -0
  23. katsuji-0.1.1/src/katsuji/wrap.py +120 -0
  24. katsuji-0.1.1/tests/__init__.py +0 -0
  25. katsuji-0.1.1/tests/conftest.py +20 -0
  26. katsuji-0.1.1/tests/ff4_dialog.py +147 -0
  27. katsuji-0.1.1/tests/fixtures/ff4/8x8vwf.png +0 -0
  28. katsuji-0.1.1/tests/fixtures/ff4/bold_font.dat +0 -0
  29. katsuji-0.1.1/tests/fixtures/ff4/bold_vwf.png +0 -0
  30. katsuji-0.1.1/tests/fixtures/ff4/book_font.dat +0 -0
  31. katsuji-0.1.1/tests/fixtures/ff4/book_vwf.png +0 -0
  32. katsuji-0.1.1/tests/fixtures/ff4/ff4_menus.tbl +126 -0
  33. katsuji-0.1.1/tests/fixtures/ff4/ff4fr.tbl +137 -0
  34. katsuji-0.1.1/tests/fixtures/ff4/font.dat +0 -0
  35. katsuji-0.1.1/tests/fixtures/ff4/menu_font.dat +0 -0
  36. katsuji-0.1.1/tests/fixtures/ff4/vwf.png +0 -0
  37. katsuji-0.1.1/tests/fixtures/ff4/wicked_font.dat +0 -0
  38. katsuji-0.1.1/tests/fixtures/ff4/wicked_vwf.png +0 -0
  39. katsuji-0.1.1/tests/fixtures/ff4/wrap_goldens.json +4802 -0
  40. katsuji-0.1.1/tests/test_atlas.py +68 -0
  41. katsuji-0.1.1/tests/test_banner.py +70 -0
  42. katsuji-0.1.1/tests/test_build.py +164 -0
  43. katsuji-0.1.1/tests/test_dialog.py +64 -0
  44. katsuji-0.1.1/tests/test_dialog_ff4.py +310 -0
  45. katsuji-0.1.1/tests/test_ff4_fonts.py +73 -0
  46. katsuji-0.1.1/tests/test_formats.py +81 -0
  47. katsuji-0.1.1/tests/test_kerning.py +72 -0
  48. katsuji-0.1.1/tests/test_render.py +51 -0
  49. katsuji-0.1.1/tests/test_strings.py +62 -0
  50. katsuji-0.1.1/tests/test_tiles.py +72 -0
  51. katsuji-0.1.1/tests/test_wrap.py +103 -0
  52. katsuji-0.1.1/uv.lock +633 -0
@@ -0,0 +1,13 @@
1
+ on:
2
+ pull_request:
3
+ push:
4
+ branches:
5
+ - master
6
+ jobs:
7
+ check:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v7
11
+ - uses: astral-sh/setup-uv@v10.2.0
12
+ - run: uv sync --locked
13
+ - run: make check
@@ -0,0 +1,19 @@
1
+ on:
2
+ push:
3
+ tags:
4
+ - 'v*'
5
+
6
+ jobs:
7
+ pypi:
8
+ name: Publish to PyPI
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v7
12
+ - uses: astral-sh/setup-uv@v10.2.0
13
+ - run: uv sync --locked
14
+ - name: Publish
15
+ run: make publish
16
+ env:
17
+ HATCH_INDEX_AUTH: ${{ secrets.HATCH_INDEX_AUTH }}
18
+ HATCH_INDEX_USER: ${{ secrets.HATCH_INDEX_USER }}
19
+ VERSION: ${{ github.ref_name }}
@@ -0,0 +1,8 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ .coverage
7
+ coverage.xml
8
+ dist/
katsuji-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Emmanuel Peralta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
katsuji-0.1.1/Makefile ADDED
@@ -0,0 +1,19 @@
1
+ .PHONY: check tests format publish
2
+
3
+ check:
4
+ uv run ruff format --check .
5
+ uv run ruff check .
6
+ uv run mypy
7
+ uv run pytest -q --cov=katsuji --cov-report=term-missing --cov-fail-under=95
8
+
9
+ # The pre-push hook runs `make tests`.
10
+ tests: check
11
+
12
+ format:
13
+ uv run ruff format .
14
+ uv run ruff check --fix .
15
+
16
+ publish: check
17
+ rm -rf dist
18
+ uv build
19
+ HATCH_INDEX_USER=$(HATCH_INDEX_USER) HATCH_INDEX_AUTH=$(HATCH_INDEX_AUTH) uvx hatch publish
katsuji-0.1.1/PKG-INFO ADDED
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.5
2
+ Name: katsuji
3
+ Version: v0.1.1
4
+ Summary: Variable-width font tooling for a816 SNES projects: fonts, kerning, tiles, static text
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.13
8
+ Requires-Dist: a816>=1.1.0a44
9
+ Requires-Dist: numpy>=1.26
10
+ Requires-Dist: pillow>=10
11
+ Description-Content-Type: text/markdown
12
+
13
+ # katsuji
14
+
15
+ 活字, movable type. Variable-width font tooling for [a816](https://github.com/manz/a816) SNES projects.
16
+
17
+ ## Build a project's fonts and strings
18
+
19
+ `katsuji.toml` at the project root:
20
+
21
+ ```toml
22
+ [fonts.dialog]
23
+ png = "fonts/vwf.png"
24
+ cell = [8, 16]
25
+ table = "text/ff4fr.tbl"
26
+ output = "assets/font.dat"
27
+ widths = { "0xFF" = 3, "0xA0" = -1 } # negative trims, positive sets
28
+ kerning-candidates = ["Ta", "Te", "va"] # tested for collision kerning
29
+ kerning = { "tt" = 2 } # hand-tuned: pixels tighter
30
+
31
+ [fonts.menu]
32
+ png = "fonts/8x8vwf.png"
33
+ cell = [8, 8]
34
+ table = "text/ff4_menus.tbl"
35
+ output = "assets/menu_font.dat"
36
+ constants = "assets/menu_font.i" # a816 constants describing the file
37
+
38
+ [strings.items]
39
+ font = "menu"
40
+ file = "text/items.txt" # one string per line (or `texts = [...]`)
41
+ bpp = 2
42
+ ink = 3
43
+ paper = 1
44
+ blob = "assets/items_vwf.dat" # tiles, no string across a bank edge
45
+ index = "assets/items_vwf.idx" # 4 bytes per string: offset lo16, bank, tiles
46
+ ```
47
+
48
+ ```
49
+ katsuji build # writes every output, paths relative to katsuji.toml
50
+ katsuji banner "Tarot" --table text/ff4fr.tbl --font assets/font.dat
51
+ ```
52
+
53
+ ## Library
54
+
55
+ - `Atlas`: a font drawn as a PNG grid of cells.
56
+ - `find_kerning` / `pair_kerning`: pair kerning by collision (one diagonal touch, never side by side).
57
+ - `VwfFont`: the font file the a816 VWF runtime reads (1bpp rows + width per glyph, kerning pairs, height).
58
+ - `render` / `measure`: text set at build time exactly as the runtime sets it.
59
+ - `Wrapper`: word wrap by pixel width, the game's control codes as data.
60
+ - `Window` / `WindowBuilder`: fill text windows; a game's script rules drive them.
61
+ - `encode_tiles` / `colourize`: SNES 2bpp / 4bpp planar tiles.
62
+ - `text_tiles` / `pack_runs`: static strings pre-rendered to a bank-safe blob.
63
+
64
+ Grown from ff4's tooling: its five fonts rebuild byte for byte, its wrapping and dialog goldens pass.
65
+
66
+ ```
67
+ uv sync
68
+ make check
69
+ ```
@@ -0,0 +1,57 @@
1
+ # katsuji
2
+
3
+ 活字, movable type. Variable-width font tooling for [a816](https://github.com/manz/a816) SNES projects.
4
+
5
+ ## Build a project's fonts and strings
6
+
7
+ `katsuji.toml` at the project root:
8
+
9
+ ```toml
10
+ [fonts.dialog]
11
+ png = "fonts/vwf.png"
12
+ cell = [8, 16]
13
+ table = "text/ff4fr.tbl"
14
+ output = "assets/font.dat"
15
+ widths = { "0xFF" = 3, "0xA0" = -1 } # negative trims, positive sets
16
+ kerning-candidates = ["Ta", "Te", "va"] # tested for collision kerning
17
+ kerning = { "tt" = 2 } # hand-tuned: pixels tighter
18
+
19
+ [fonts.menu]
20
+ png = "fonts/8x8vwf.png"
21
+ cell = [8, 8]
22
+ table = "text/ff4_menus.tbl"
23
+ output = "assets/menu_font.dat"
24
+ constants = "assets/menu_font.i" # a816 constants describing the file
25
+
26
+ [strings.items]
27
+ font = "menu"
28
+ file = "text/items.txt" # one string per line (or `texts = [...]`)
29
+ bpp = 2
30
+ ink = 3
31
+ paper = 1
32
+ blob = "assets/items_vwf.dat" # tiles, no string across a bank edge
33
+ index = "assets/items_vwf.idx" # 4 bytes per string: offset lo16, bank, tiles
34
+ ```
35
+
36
+ ```
37
+ katsuji build # writes every output, paths relative to katsuji.toml
38
+ katsuji banner "Tarot" --table text/ff4fr.tbl --font assets/font.dat
39
+ ```
40
+
41
+ ## Library
42
+
43
+ - `Atlas`: a font drawn as a PNG grid of cells.
44
+ - `find_kerning` / `pair_kerning`: pair kerning by collision (one diagonal touch, never side by side).
45
+ - `VwfFont`: the font file the a816 VWF runtime reads (1bpp rows + width per glyph, kerning pairs, height).
46
+ - `render` / `measure`: text set at build time exactly as the runtime sets it.
47
+ - `Wrapper`: word wrap by pixel width, the game's control codes as data.
48
+ - `Window` / `WindowBuilder`: fill text windows; a game's script rules drive them.
49
+ - `encode_tiles` / `colourize`: SNES 2bpp / 4bpp planar tiles.
50
+ - `text_tiles` / `pack_runs`: static strings pre-rendered to a bank-safe blob.
51
+
52
+ Grown from ff4's tooling: its five fonts rebuild byte for byte, its wrapping and dialog goldens pass.
53
+
54
+ ```
55
+ uv sync
56
+ make check
57
+ ```
@@ -0,0 +1,15 @@
1
+ """Build-time version source for hatchling.
2
+
3
+ Reads the `VERSION` env var when set (the release pipeline injects the tag),
4
+ otherwise falls back to a dev placeholder so local builds work without it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+
11
+ DEV_VERSION = "0.0.0.dev0"
12
+
13
+
14
+ def get_version() -> str:
15
+ return os.environ.get("VERSION") or DEV_VERSION
@@ -0,0 +1,59 @@
1
+ [project]
2
+ name = "katsuji"
3
+ dynamic = ["version"]
4
+ description = "Variable-width font tooling for a816 SNES projects: fonts, kerning, tiles, static text"
5
+ readme = "README.md"
6
+ requires-python = ">=3.13"
7
+ license = "MIT"
8
+ dependencies = [
9
+ "numpy>=1.26",
10
+ "pillow>=10",
11
+ "a816>=1.1.0a44",
12
+ ]
13
+
14
+ [project.scripts]
15
+ katsuji = "katsuji.cli:main"
16
+
17
+ [dependency-groups]
18
+ dev = [
19
+ "pytest>=8",
20
+ "pytest-cov>=5",
21
+ "ruff>=0.16",
22
+ "mypy>=1.13",
23
+ ]
24
+
25
+ [build-system]
26
+ requires = ["hatchling"]
27
+ build-backend = "hatchling.build"
28
+
29
+ [tool.hatch.version]
30
+ source = "code"
31
+ path = "hatch_version.py"
32
+ expression = "get_version()"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/katsuji"]
36
+
37
+ [tool.ruff]
38
+ line-length = 120
39
+ target-version = "py313"
40
+ src = ["src", "tests"]
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "UP", "B", "SIM", "RUF"]
44
+
45
+ [tool.ruff.lint.per-file-ignores]
46
+ # ff4's dialog goldens, copied verbatim: their expected strings are long lines.
47
+ "tests/test_dialog_ff4.py" = ["E501"]
48
+
49
+ [tool.mypy]
50
+ strict = true
51
+ packages = ["katsuji", "tests"]
52
+ mypy_path = "src"
53
+
54
+ [[tool.mypy.overrides]]
55
+ module = ["script", "script.*"]
56
+ ignore_missing_imports = true
57
+
58
+ [tool.pytest.ini_options]
59
+ testpaths = ["tests"]
@@ -0,0 +1,23 @@
1
+ """Variable-width font tooling for a816 SNES projects."""
2
+
3
+ from katsuji.atlas import Atlas
4
+ from katsuji.formats import VwfFont
5
+ from katsuji.kerning import find_kerning, pair_kerning
6
+ from katsuji.render import measure, render
7
+ from katsuji.tiles import TileOrder, colourize, encode_tiles
8
+ from katsuji.wrap import Controls, Fixed, Wrapper
9
+
10
+ __all__ = [
11
+ "Atlas",
12
+ "Controls",
13
+ "Fixed",
14
+ "TileOrder",
15
+ "VwfFont",
16
+ "Wrapper",
17
+ "colourize",
18
+ "encode_tiles",
19
+ "find_kerning",
20
+ "measure",
21
+ "pair_kerning",
22
+ "render",
23
+ ]
@@ -0,0 +1,90 @@
1
+ """A font drawn as a PNG grid of fixed-size cells, one glyph per cell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ from numpy.typing import NDArray
10
+ from PIL import Image
11
+
12
+ Pixels = NDArray[np.uint8]
13
+ """A 2D array of 0 (paper) and 1 (ink), indexed `[row, column]`."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Atlas:
18
+ """Glyph cells laid out left to right, top to bottom.
19
+
20
+ `grid` atlases separate cells with a 1-pixel line (and start with one), so
21
+ a cell sits at `index * (size + 1) + 1`. Any non-zero pixel is ink.
22
+ """
23
+
24
+ pixels: Pixels
25
+ cell_width: int = 8
26
+ cell_height: int = 16
27
+ grid: bool = False
28
+
29
+ @classmethod
30
+ def open(cls, path: str | Path, cell_width: int = 8, cell_height: int = 16, grid: bool = False) -> Atlas:
31
+ image = np.array(Image.open(path))
32
+ if image.ndim == 3:
33
+ image = image[..., 0]
34
+ return cls((image != 0).astype(np.uint8), cell_width, cell_height, grid)
35
+
36
+ @property
37
+ def columns(self) -> int:
38
+ """Cells per row of the atlas."""
39
+ stride = self.cell_width + 1 if self.grid else self.cell_width
40
+ return int(self.pixels.shape[1] - (1 if self.grid else 0)) // stride
41
+
42
+ @property
43
+ def rows(self) -> int:
44
+ stride = self.cell_height + 1 if self.grid else self.cell_height
45
+ return int(self.pixels.shape[0] - (1 if self.grid else 0)) // stride
46
+
47
+ def __len__(self) -> int:
48
+ return self.columns * self.rows
49
+
50
+ def glyph(self, index: int) -> Pixels:
51
+ """The cell of glyph `index`, `cell_height` x `cell_width`."""
52
+ row, column = divmod(index, self.columns)
53
+ if self.grid:
54
+ x = column * (self.cell_width + 1) + 1
55
+ y = row * (self.cell_height + 1) + 1
56
+ else:
57
+ x = column * self.cell_width
58
+ y = row * self.cell_height
59
+ return self.pixels[y : y + self.cell_height, x : x + self.cell_width]
60
+
61
+ def ink_width(self, index: int) -> int:
62
+ """Columns up to and including the rightmost ink pixel (0 for a blank cell)."""
63
+ return ink_width(self.glyph(index))
64
+
65
+ def without_grid(self) -> Pixels:
66
+ """The atlas with its grid lines removed: cells packed edge to edge."""
67
+ lines = [
68
+ np.concatenate([self.glyph(row * self.columns + column) for column in range(self.columns)], axis=1)
69
+ for row in range(self.rows)
70
+ ]
71
+ return np.concatenate(lines, axis=0)
72
+
73
+
74
+ def ink_width(glyph: Pixels) -> int:
75
+ """Columns up to and including the rightmost ink pixel of `glyph`."""
76
+ columns = np.flatnonzero(glyph.any(axis=0))
77
+ return int(columns[-1]) + 1 if columns.size else 0
78
+
79
+
80
+ def ink_rows(glyph: Pixels) -> tuple[int, int]:
81
+ """First and last rows holding ink; the whole height for a blank glyph."""
82
+ rows = np.flatnonzero(glyph.any(axis=1))
83
+ if not rows.size:
84
+ return 0, glyph.shape[0] - 1
85
+ return int(rows[0]), int(rows[-1])
86
+
87
+
88
+ def save_png(pixels: Pixels, path: str | Path) -> None:
89
+ """Write 0/1 pixels as a black-on-white PNG."""
90
+ Image.fromarray(np.where(pixels != 0, 0, 255).astype(np.uint8)).save(path, format="PNG")
@@ -0,0 +1,59 @@
1
+ """Banners: text set with katsuji fonts, shown in the terminal or saved as a PNG.
2
+
3
+ A preview of what the runtime will draw, font switches included. Each run of
4
+ text between font switches is set with its font and the runs are joined edge
5
+ to edge, as ff4's banner did.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ from collections.abc import Sequence
12
+
13
+ import numpy as np
14
+
15
+ from katsuji.atlas import Pixels
16
+ from katsuji.formats import VwfFont
17
+ from katsuji.render import render
18
+ from katsuji.wrap import Controls
19
+
20
+
21
+ def runs(codes: bytes, controls: Controls) -> list[tuple[int, list[int]]]:
22
+ """`codes` split at font switches into `(font, glyph codes)`; fixed codes are skipped."""
23
+ out: list[tuple[int, list[int]]] = []
24
+ font = 0
25
+ current: list[int] = []
26
+ index = 0
27
+ while index < len(codes):
28
+ code = codes[index]
29
+ if code == controls.font_switch and index + 1 < len(codes):
30
+ if current:
31
+ out.append((font, current))
32
+ current = []
33
+ index += 1
34
+ font = codes[index]
35
+ elif code in controls.fixed:
36
+ index += controls.fixed[code].arguments
37
+ else:
38
+ current.append(code)
39
+ index += 1
40
+ if current:
41
+ out.append((font, current))
42
+ return out
43
+
44
+
45
+ def banner(fonts: Sequence[VwfFont], codes: bytes, controls: Controls, kerning: bool = True) -> Pixels:
46
+ """The pixels of `codes`, each run set with its font."""
47
+ pieces = []
48
+ for font_index, glyphs in runs(codes, controls):
49
+ font = fonts[font_index] if kerning else dataclasses.replace(fonts[font_index], kerning={})
50
+ pieces.append(render(font, glyphs))
51
+ if not pieces:
52
+ return np.zeros((0, 0), dtype=np.uint8)
53
+ height = max(piece.shape[0] for piece in pieces)
54
+ return np.concatenate([np.pad(piece, ((0, height - piece.shape[0]), (0, 0))) for piece in pieces], axis=1)
55
+
56
+
57
+ def to_blocks(pixels: Pixels) -> str:
58
+ """Two full blocks per ink pixel, two spaces per paper pixel."""
59
+ return "\n".join("".join("██" if value else " " for value in row) for row in pixels)
@@ -0,0 +1,97 @@
1
+ """Build everything a `katsuji.toml` describes: font files, string blobs, constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from script import Table
8
+
9
+ from katsuji.atlas import Atlas
10
+ from katsuji.config import Config, ConfigError, FontConfig, StringsConfig
11
+ from katsuji.formats import VwfFont
12
+ from katsuji.kerning import find_kerning
13
+ from katsuji.strings import index_entry, pack_runs, text_tiles
14
+
15
+
16
+ def build(config: Config) -> list[Path]:
17
+ """Write every output of `config`; returns the paths written."""
18
+ written: list[Path] = []
19
+ fonts = {name: _build_font(font, written) for name, font in config.fonts.items()}
20
+ for strings in config.strings.values():
21
+ _build_strings(strings, fonts[strings.font], config.fonts[strings.font], written)
22
+ return written
23
+
24
+
25
+ def _table(path: Path | None, where: str) -> Table:
26
+ if path is None:
27
+ raise ConfigError(f"[{where}] needs a `table` to encode its text")
28
+ return Table(str(path))
29
+
30
+
31
+ def _pair(table: Table, text: str, where: str) -> tuple[int, int]:
32
+ codes = table.to_bytes(text)
33
+ if len(codes) != 2:
34
+ raise ConfigError(f"[{where}] kerning pair {text!r} encodes to {len(codes)} codes, not 2")
35
+ return codes[0], codes[1]
36
+
37
+
38
+ def _build_font(config: FontConfig, written: list[Path]) -> VwfFont:
39
+ where = f"fonts.{config.name}"
40
+ atlas = Atlas.open(config.png, config.cell_width, config.cell_height, config.grid)
41
+ kerning: dict[tuple[int, int], int] = {}
42
+ if config.kerning_candidates or config.kerning:
43
+ table = _table(config.table, where)
44
+ candidates = (table.to_bytes(text) for text in config.kerning_candidates)
45
+ pairs = [(codes[0], codes[1]) for codes in candidates if len(codes) == 2]
46
+ kerning = find_kerning(atlas, pairs, config.default_kerning)
47
+ for text, pixels in config.kerning.items():
48
+ kerning[_pair(table, text, where)] = pixels
49
+ font = VwfFont.from_atlas(atlas, config.widths, kerning)
50
+ _write(config.output, font.encode(), written)
51
+ if config.constants:
52
+ prefix = config.name.upper()
53
+ _write_constants(
54
+ config.constants,
55
+ {
56
+ f"{prefix}_FONT_HEIGHT": font.height,
57
+ f"{prefix}_FONT_GLYPH_SIZE": font.height + 1,
58
+ f"{prefix}_FONT_KERNING_PAIRS": len(font.kerning),
59
+ },
60
+ written,
61
+ )
62
+ return font
63
+
64
+
65
+ def _build_strings(config: StringsConfig, font: VwfFont, font_config: FontConfig, written: list[Path]) -> None:
66
+ where = f"strings.{config.name}"
67
+ table = _table(config.table or font_config.table, where)
68
+ runs: list[bytes] = []
69
+ counts: list[int] = []
70
+ for text in config.texts:
71
+ tiles, columns = text_tiles(
72
+ font, table.to_bytes(text), config.bpp, config.ink, config.paper, config.gap, config.max_tiles
73
+ )
74
+ runs.append(tiles)
75
+ counts.append(columns)
76
+ blob, offsets = pack_runs(runs, config.bank_size)
77
+ _write(config.blob, blob, written)
78
+ _write(config.index, b"".join(index_entry(o, n) for o, n in zip(offsets, counts, strict=True)), written)
79
+ if config.constants:
80
+ prefix = config.name.upper()
81
+ _write_constants(
82
+ config.constants,
83
+ {f"{prefix}_COUNT": len(config.texts), f"{prefix}_MAX_TILES": max(counts, default=0)},
84
+ written,
85
+ )
86
+
87
+
88
+ def _write(path: Path, data: bytes, written: list[Path]) -> None:
89
+ path.parent.mkdir(parents=True, exist_ok=True)
90
+ path.write_bytes(data)
91
+ written.append(path)
92
+
93
+
94
+ def _write_constants(path: Path, constants: dict[str, int], written: list[Path]) -> None:
95
+ lines = ['"""Generated by katsuji build; do not edit."""', ""]
96
+ lines += [f"{name} = {value:#x}" if value > 9 else f"{name} = {value}" for name, value in constants.items()]
97
+ _write(path, ("\n".join(lines) + "\n").encode(), written)
@@ -0,0 +1,67 @@
1
+ """`katsuji` command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from script import Table
11
+
12
+ from katsuji.atlas import save_png
13
+ from katsuji.banner import banner, to_blocks
14
+ from katsuji.build import build
15
+ from katsuji.config import ConfigError, load
16
+ from katsuji.formats import VwfFont
17
+ from katsuji.wrap import Controls
18
+
19
+
20
+ def _code(value: str) -> int:
21
+ return int(value, 0)
22
+
23
+
24
+ def _parser() -> argparse.ArgumentParser:
25
+ parser = argparse.ArgumentParser(prog="katsuji", description="Variable-width font tooling for a816 projects.")
26
+ commands = parser.add_subparsers(dest="command", required=True)
27
+ show = commands.add_parser("banner", help="show text set with katsuji fonts")
28
+ show.add_argument("text")
29
+ show.add_argument("--table", required=True, type=Path, help="text table (.tbl) encoding TEXT")
30
+ show.add_argument(
31
+ "--font", required=True, action="append", type=Path, dest="fonts", help="font file; repeat for font switches"
32
+ )
33
+ show.add_argument("--font-switch", type=_code, help="code switching font, followed by the font index (ff4: 0xFE)")
34
+ show.add_argument("--no-kerning", action="store_true", help="ignore the fonts' kerning pairs")
35
+ show.add_argument("--png", type=Path, help="save the banner as a PNG instead of printing it")
36
+ make = commands.add_parser("build", help="build the fonts and strings a katsuji.toml describes")
37
+ make.add_argument("config", nargs="?", type=Path, default=Path("katsuji.toml"))
38
+ return parser
39
+
40
+
41
+ def main(argv: Sequence[str] | None = None) -> int:
42
+ args = _parser().parse_args(argv)
43
+ if args.command == "build":
44
+ return _build(args.config)
45
+ fonts = [VwfFont.decode(path.read_bytes()) for path in args.fonts]
46
+ codes = Table(str(args.table)).to_bytes(args.text)
47
+ pixels = banner(fonts, codes, Controls(font_switch=args.font_switch), kerning=not args.no_kerning)
48
+ if args.png:
49
+ save_png(pixels, args.png)
50
+ else:
51
+ print(to_blocks(pixels))
52
+ print(f"width: {pixels.shape[1]}", file=sys.stderr)
53
+ return 0
54
+
55
+
56
+ def _build(path: Path) -> int:
57
+ try:
58
+ for written in build(load(path)):
59
+ print(written)
60
+ except ConfigError as error:
61
+ print(f"katsuji: {error}", file=sys.stderr)
62
+ return 2
63
+ return 0
64
+
65
+
66
+ if __name__ == "__main__":
67
+ raise SystemExit(main())