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.
- katsuji-0.1.1/.github/workflows/check.yml +13 -0
- katsuji-0.1.1/.github/workflows/release.yml +19 -0
- katsuji-0.1.1/.gitignore +8 -0
- katsuji-0.1.1/LICENSE +21 -0
- katsuji-0.1.1/Makefile +19 -0
- katsuji-0.1.1/PKG-INFO +69 -0
- katsuji-0.1.1/README.md +57 -0
- katsuji-0.1.1/hatch_version.py +15 -0
- katsuji-0.1.1/pyproject.toml +59 -0
- katsuji-0.1.1/src/katsuji/__init__.py +23 -0
- katsuji-0.1.1/src/katsuji/atlas.py +90 -0
- katsuji-0.1.1/src/katsuji/banner.py +59 -0
- katsuji-0.1.1/src/katsuji/build.py +97 -0
- katsuji-0.1.1/src/katsuji/cli.py +67 -0
- katsuji-0.1.1/src/katsuji/config.py +164 -0
- katsuji-0.1.1/src/katsuji/dialog.py +116 -0
- katsuji-0.1.1/src/katsuji/formats.py +99 -0
- katsuji-0.1.1/src/katsuji/kerning.py +120 -0
- katsuji-0.1.1/src/katsuji/render.py +59 -0
- katsuji-0.1.1/src/katsuji/strings.py +63 -0
- katsuji-0.1.1/src/katsuji/tiles.py +78 -0
- katsuji-0.1.1/src/katsuji/widths.py +25 -0
- katsuji-0.1.1/src/katsuji/wrap.py +120 -0
- katsuji-0.1.1/tests/__init__.py +0 -0
- katsuji-0.1.1/tests/conftest.py +20 -0
- katsuji-0.1.1/tests/ff4_dialog.py +147 -0
- katsuji-0.1.1/tests/fixtures/ff4/8x8vwf.png +0 -0
- katsuji-0.1.1/tests/fixtures/ff4/bold_font.dat +0 -0
- katsuji-0.1.1/tests/fixtures/ff4/bold_vwf.png +0 -0
- katsuji-0.1.1/tests/fixtures/ff4/book_font.dat +0 -0
- katsuji-0.1.1/tests/fixtures/ff4/book_vwf.png +0 -0
- katsuji-0.1.1/tests/fixtures/ff4/ff4_menus.tbl +126 -0
- katsuji-0.1.1/tests/fixtures/ff4/ff4fr.tbl +137 -0
- katsuji-0.1.1/tests/fixtures/ff4/font.dat +0 -0
- katsuji-0.1.1/tests/fixtures/ff4/menu_font.dat +0 -0
- katsuji-0.1.1/tests/fixtures/ff4/vwf.png +0 -0
- katsuji-0.1.1/tests/fixtures/ff4/wicked_font.dat +0 -0
- katsuji-0.1.1/tests/fixtures/ff4/wicked_vwf.png +0 -0
- katsuji-0.1.1/tests/fixtures/ff4/wrap_goldens.json +4802 -0
- katsuji-0.1.1/tests/test_atlas.py +68 -0
- katsuji-0.1.1/tests/test_banner.py +70 -0
- katsuji-0.1.1/tests/test_build.py +164 -0
- katsuji-0.1.1/tests/test_dialog.py +64 -0
- katsuji-0.1.1/tests/test_dialog_ff4.py +310 -0
- katsuji-0.1.1/tests/test_ff4_fonts.py +73 -0
- katsuji-0.1.1/tests/test_formats.py +81 -0
- katsuji-0.1.1/tests/test_kerning.py +72 -0
- katsuji-0.1.1/tests/test_render.py +51 -0
- katsuji-0.1.1/tests/test_strings.py +62 -0
- katsuji-0.1.1/tests/test_tiles.py +72 -0
- katsuji-0.1.1/tests/test_wrap.py +103 -0
- katsuji-0.1.1/uv.lock +633 -0
|
@@ -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 }}
|
katsuji-0.1.1/.gitignore
ADDED
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
|
+
```
|
katsuji-0.1.1/README.md
ADDED
|
@@ -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())
|