pezlie 0.1.0__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.
@@ -0,0 +1,12 @@
1
+ .playwright-mcp/
2
+ *.png
3
+ spike/cel/node_modules/
4
+ spike/cel/.venv/
5
+ spike/cel/results.*.json
6
+ bakery/.venv/
7
+ __pycache__/
8
+ node_modules/
9
+ hosts/demo/.venv/
10
+ hosts/demo/out/
11
+ wall/dist/
12
+ !docs/screenshots/*.png
pezlie-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 orochi235
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.
pezlie-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.5
2
+ Name: pezlie
3
+ Version: 0.1.0
4
+ Summary: Bake per-item renders into a mip chain of sprite sheets, and serve them
5
+ Project-URL: Repository, https://github.com/orochi235/pezlie
6
+ Author: orochi235
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: pillow>=10
11
+ Provides-Extra: routes
12
+ Requires-Dist: fastapi>=0.110; extra == 'routes'
13
+ Provides-Extra: test
14
+ Requires-Dist: fastapi>=0.110; extra == 'test'
15
+ Requires-Dist: httpx>=0.27; extra == 'test'
16
+ Requires-Dist: pytest>=8; extra == 'test'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # bakery
20
+
21
+ Bakes one slot's renders into the wall's mip chain, and serves it. A slot is one
22
+ set of renders over the whole corpus; each item gets a 128px loose tile, and
23
+ every item's 8px and 32px tiles are composed onto one sheet per level, with a
24
+ JSON manifest beside each sheet.
25
+
26
+ It knows nothing about what the items are. brick-icons is the first host.
27
+
28
+ ```bash
29
+ pip install pezlie
30
+ pip install 'pezlie[routes]' # with the FastAPI routes
31
+ ```
32
+
33
+ It needs `resvg` on `PATH` for SVG renders. The wall that draws what it bakes is
34
+ [`pezlie` on npm](https://www.npmjs.com/package/pezlie).
35
+
36
+ ## What a host supplies
37
+
38
+ - **The renders for a slot** — `Render(id, path, sha)` per item that has one.
39
+ SVG goes through `resvg`; anything else is opened with Pillow.
40
+ - **The full order** — every item id, drawn or not. A cell's index on a sheet
41
+ is its position in this list, so the host must send the same order to
42
+ `compose` and to the wall's feed, or every sprite lands off by one.
43
+ `compose` refuses a list that repeats an id.
44
+ - **For the routes** — a slot-name-to-directory lookup, a lookup naming the
45
+ render file for an item in a slot, and the root those files must sit under.
46
+
47
+ ## What it promises
48
+
49
+ - **Ink, never ground.** Tiles and sheets are transparent where there is no
50
+ ink. The wall paints the ground.
51
+ - **One writer per slot.** `bake_item`, `compose` and `bake_slot` hold a lock
52
+ on `<slot>/.bake.lock` and raise `BakeInProgress` instead of interleaving.
53
+ - **Freshness by sha.** An item whose sha matches `baked.json`, with tiles in
54
+ the current format, is not rebaked. The manifest carries the sha map, so the
55
+ wall can tell a stale cell from a fresh one.
56
+
57
+ ```python
58
+ from pezlie.batch import Render, bake_slot
59
+ bake_slot(renders, out="thumbs/occt", order=all_ids)
60
+
61
+ from pezlie.routes import render_router, thumbs_router
62
+ app.include_router(thumbs_router(slot_dir), prefix="/api/thumbs")
63
+ app.include_router(render_router(render_file, root, known_slot), prefix="/api/corpus/render")
64
+ ```
65
+
66
+ ## Develop
67
+
68
+ Needs `resvg` on `PATH`.
69
+
70
+ ```bash
71
+ uv venv bakery/.venv --python 3.14
72
+ uv pip install --python bakery/.venv/bin/python -e 'bakery[test]'
73
+ bakery/.venv/bin/python -m pytest bakery -q
74
+ ```
75
+
76
+ `tests/test_parity.py` bakes the same inputs through brick-icons'
77
+ `brick_icons/thumbs.py` and through this package and compares bytes. It looks
78
+ for a brick-icons checkout beside pezlie, or at `$BRICK_ICONS`, and skips
79
+ without one.
pezlie-0.1.0/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # bakery
2
+
3
+ Bakes one slot's renders into the wall's mip chain, and serves it. A slot is one
4
+ set of renders over the whole corpus; each item gets a 128px loose tile, and
5
+ every item's 8px and 32px tiles are composed onto one sheet per level, with a
6
+ JSON manifest beside each sheet.
7
+
8
+ It knows nothing about what the items are. brick-icons is the first host.
9
+
10
+ ```bash
11
+ pip install pezlie
12
+ pip install 'pezlie[routes]' # with the FastAPI routes
13
+ ```
14
+
15
+ It needs `resvg` on `PATH` for SVG renders. The wall that draws what it bakes is
16
+ [`pezlie` on npm](https://www.npmjs.com/package/pezlie).
17
+
18
+ ## What a host supplies
19
+
20
+ - **The renders for a slot** — `Render(id, path, sha)` per item that has one.
21
+ SVG goes through `resvg`; anything else is opened with Pillow.
22
+ - **The full order** — every item id, drawn or not. A cell's index on a sheet
23
+ is its position in this list, so the host must send the same order to
24
+ `compose` and to the wall's feed, or every sprite lands off by one.
25
+ `compose` refuses a list that repeats an id.
26
+ - **For the routes** — a slot-name-to-directory lookup, a lookup naming the
27
+ render file for an item in a slot, and the root those files must sit under.
28
+
29
+ ## What it promises
30
+
31
+ - **Ink, never ground.** Tiles and sheets are transparent where there is no
32
+ ink. The wall paints the ground.
33
+ - **One writer per slot.** `bake_item`, `compose` and `bake_slot` hold a lock
34
+ on `<slot>/.bake.lock` and raise `BakeInProgress` instead of interleaving.
35
+ - **Freshness by sha.** An item whose sha matches `baked.json`, with tiles in
36
+ the current format, is not rebaked. The manifest carries the sha map, so the
37
+ wall can tell a stale cell from a fresh one.
38
+
39
+ ```python
40
+ from pezlie.batch import Render, bake_slot
41
+ bake_slot(renders, out="thumbs/occt", order=all_ids)
42
+
43
+ from pezlie.routes import render_router, thumbs_router
44
+ app.include_router(thumbs_router(slot_dir), prefix="/api/thumbs")
45
+ app.include_router(render_router(render_file, root, known_slot), prefix="/api/corpus/render")
46
+ ```
47
+
48
+ ## Develop
49
+
50
+ Needs `resvg` on `PATH`.
51
+
52
+ ```bash
53
+ uv venv bakery/.venv --python 3.14
54
+ uv pip install --python bakery/.venv/bin/python -e 'bakery[test]'
55
+ bakery/.venv/bin/python -m pytest bakery -q
56
+ ```
57
+
58
+ `tests/test_parity.py` bakes the same inputs through brick-icons'
59
+ `brick_icons/thumbs.py` and through this package and compares bytes. It looks
60
+ for a brick-icons checkout beside pezlie, or at `$BRICK_ICONS`, and skips
61
+ without one.
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "pezlie"
3
+ version = "0.1.0"
4
+ description = "Bake per-item renders into a mip chain of sprite sheets, and serve them"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [{ name = "orochi235" }]
9
+ requires-python = ">=3.11"
10
+ dependencies = ["pillow>=10"]
11
+
12
+ [project.urls]
13
+ Repository = "https://github.com/orochi235/pezlie"
14
+
15
+ [project.optional-dependencies]
16
+ routes = ["fastapi>=0.110"]
17
+ test = ["pytest>=8", "fastapi>=0.110", "httpx>=0.27"]
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/pezlie"]
25
+
26
+ [tool.pytest.ini_options]
27
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ """Bake per-item renders into a mip chain of sprite sheets, and serve them."""
@@ -0,0 +1,132 @@
1
+ """Rasterize an item at every level, and compose the levels into sheets.
2
+
3
+ An item's cell is its position in the host's full order, so a render landing
4
+ later fills the cell it already had rather than renumbering the sheet.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from collections import Counter
10
+ from pathlib import Path
11
+
12
+ from PIL import Image
13
+
14
+ from pezlie.lock import slot_lock
15
+ from pezlie.sheet import GROUND, LEVELS, LOOSE_LEVEL, SHEET_LEVELS, geometry
16
+ from pezlie.sidecar import BAKED, baked_shas, write_json
17
+
18
+ #: WebP q90 halves sheet-32 against PNG, and the wall fetches it on every open.
19
+ THUMB_EXT = "webp"
20
+ THUMB_SAVE = {"format": "WEBP", "quality": 90, "method": 4}
21
+
22
+
23
+ def bake_item(item_id: str, render: Path | str, out: Path | str,
24
+ sha: str) -> list[int]:
25
+ """Rasterize one item at every level. Returns the levels written; an
26
+ unchanged sha writes nothing."""
27
+ out = Path(out)
28
+ with slot_lock(out):
29
+ return _bake_item(item_id, Path(render), out, sha)
30
+
31
+
32
+ def compose(out: Path | str, order: list[str]) -> list[Path]:
33
+ """Paste every baked tile onto its sheet at its index in `order`.
34
+
35
+ `order` is every item, drawn or not: an index is a position in the corpus.
36
+ """
37
+ out = Path(out)
38
+ with slot_lock(out):
39
+ return _compose(out, order)
40
+
41
+
42
+ def _bake_item(item_id: str, render: Path, out: Path, sha: str) -> list[int]:
43
+ shas = baked_shas(out)
44
+ # The sha covers the render, not the encoding: a tile in an old format is a miss.
45
+ if shas.get(item_id) == sha and all(
46
+ (out / str(level) / f"{item_id}.{THUMB_EXT}").is_file()
47
+ for level in LEVELS):
48
+ return []
49
+ drawn = _drawn(item_id, render, out)
50
+ for level in LEVELS:
51
+ path = out / str(level) / f"{item_id}.{THUMB_EXT}"
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ _square(drawn, level).save(path, **THUMB_SAVE)
54
+ write_json(out / BAKED, {**shas, item_id: sha})
55
+ return list(LEVELS)
56
+
57
+
58
+ def _compose(out: Path, order: list[str]) -> list[Path]:
59
+ repeated = [item_id for item_id, n in Counter(order).items() if n > 1]
60
+ if repeated:
61
+ raise ValueError(f"order repeats {len(repeated)} id(s), first {repeated[:3]}: "
62
+ "every cell after one lands a place off")
63
+ shas = baked_shas(out)
64
+ written = []
65
+ for level in SHEET_LEVELS:
66
+ g = geometry(len(order), level)
67
+ sheet = Image.new("RGBA", (g.size, g.size), (0, 0, 0, 0))
68
+ for index, item_id in enumerate(order):
69
+ tile = out / str(level) / f"{item_id}.{THUMB_EXT}"
70
+ if not tile.is_file():
71
+ continue
72
+ with Image.open(tile) as img:
73
+ cell = img.convert("RGBA")
74
+ x0, y0, _, _ = g.cell_box(index)
75
+ sheet.paste(cell, (x0, y0))
76
+ if g.gutter:
77
+ _replicate_edges(sheet, cell, x0, y0, g.gutter)
78
+ path = out / f"sheet-{level}.{THUMB_EXT}"
79
+ sheet.save(path, **THUMB_SAVE)
80
+ write_json(out / f"sheet-{level}.json", {
81
+ "level": level, "gutter": g.gutter, "pitch": g.pitch,
82
+ "cols": g.cols, "rows": g.rows, "count": len(order),
83
+ "size": g.size, "baked": shas,
84
+ })
85
+ written.append(path)
86
+ return written
87
+
88
+
89
+ def _drawn(item_id: str, render: Path, out: Path) -> Image.Image:
90
+ """The render at `LOOSE_LEVEL` wide, as RGBA.
91
+
92
+ resvg has no letterbox flag, and passing both -w and -h stretches, so it is
93
+ asked for a width and `_square` pads. A raster render skips resvg, which
94
+ rejects one as "not an UTF-8 encoding" -- reading like a corrupt file.
95
+ """
96
+ if render.suffix.lower() != ".svg":
97
+ with Image.open(render) as img:
98
+ return img.convert("RGBA")
99
+ wide = out / f".{item_id}.wide.png"
100
+ proc = subprocess.run(
101
+ ["resvg", "--width", str(LOOSE_LEVEL), str(render), str(wide)],
102
+ capture_output=True, text=True)
103
+ if proc.returncode != 0 or not wide.is_file():
104
+ raise RuntimeError(f"resvg failed on {item_id}: "
105
+ f"{(proc.stderr or proc.stdout).strip()[:200]}")
106
+ try:
107
+ with Image.open(wide) as img:
108
+ return img.convert("RGBA")
109
+ finally:
110
+ wide.unlink(missing_ok=True)
111
+
112
+
113
+ def _replicate_edges(sheet: Image.Image, cell: Image.Image,
114
+ x0: int, y0: int, gutter: int) -> None:
115
+ """Pad a cell with its own edge pixels, or each mip reduction averages it
116
+ against its neighbor and the wall reads as halos."""
117
+ w, h = cell.size
118
+ for d in range(1, gutter + 1):
119
+ sheet.paste(cell.crop((0, 0, w, 1)), (x0, y0 - d))
120
+ sheet.paste(cell.crop((0, h - 1, w, h)), (x0, y0 + h + d - 1))
121
+ sheet.paste(cell.crop((0, 0, 1, h)), (x0 - d, y0))
122
+ sheet.paste(cell.crop((w - 1, 0, w, h)), (x0 + w + d - 1, y0))
123
+
124
+
125
+ def _square(drawn: Image.Image, level: int) -> Image.Image:
126
+ """Fit a render, centered, inside a `GROUND` square of `level` px."""
127
+ scale = level / max(drawn.size)
128
+ size = (max(1, round(drawn.width * scale)), max(1, round(drawn.height * scale)))
129
+ cell = Image.new("RGBA", (level, level), GROUND)
130
+ fitted = drawn.resize(size, Image.LANCZOS)
131
+ cell.paste(fitted, ((level - size[0]) // 2, (level - size[1]) // 2), fitted)
132
+ return cell
@@ -0,0 +1,43 @@
1
+ """Bake one slot: every render the host has for it, then the sheets."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Callable, Iterable
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from pezlie.bake import _bake_item, _compose
9
+ from pezlie.lock import slot_lock
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Render:
14
+ id: str
15
+ path: Path
16
+ sha: str
17
+
18
+
19
+ def bake_slot(renders: Iterable[Render], out: Path | str, order: list[str],
20
+ log: Callable[[str], None] = print) -> tuple[int, int]:
21
+ """Returns (baked, total). One line per render as it completes.
22
+
23
+ A render that is missing or will not rasterize is reported and skipped:
24
+ an interrupted fetch leaves zero-byte files, and one of them is not a
25
+ reason to abandon the thousands after it.
26
+ """
27
+ out, renders = Path(out), list(renders)
28
+ total, baked = len(renders), 0
29
+ with slot_lock(out):
30
+ for i, r in enumerate(renders, 1):
31
+ if not r.path.is_file():
32
+ log(f" {i}/{total} {r.id} MISSING {r.path}")
33
+ continue
34
+ try:
35
+ made = _bake_item(r.id, r.path, out, r.sha)
36
+ except Exception as e: # noqa: BLE001
37
+ log(f" {i}/{total} {r.id} UNREADABLE {type(e).__name__}: {e}")
38
+ continue
39
+ baked += bool(made)
40
+ log(f" {i}/{total} {r.id} {'baked' if made else 'fresh'}")
41
+ for path in _compose(out, order):
42
+ log(f" wrote {path}")
43
+ return baked, total
@@ -0,0 +1,30 @@
1
+ """One writer per slot. Two bakers over one directory interleave their
2
+ sidecar writes and leave a sheet that matches neither."""
3
+ from __future__ import annotations
4
+
5
+ import fcntl
6
+ import os
7
+ from collections.abc import Iterator
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+
11
+ LOCK = ".bake.lock"
12
+
13
+
14
+ class BakeInProgress(RuntimeError):
15
+ """Another bake holds the slot. Refused rather than queued: a baker that
16
+ waits would compose a sheet from whatever the other left behind."""
17
+
18
+
19
+ @contextmanager
20
+ def slot_lock(out: Path) -> Iterator[None]:
21
+ out.mkdir(parents=True, exist_ok=True)
22
+ fd = os.open(out / LOCK, os.O_CREAT | os.O_RDWR, 0o644)
23
+ try:
24
+ try:
25
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
26
+ except BlockingIOError:
27
+ raise BakeInProgress(f"another bake holds {out / LOCK}") from None
28
+ yield
29
+ finally:
30
+ os.close(fd)
@@ -0,0 +1,90 @@
1
+ """Mountable routes for a baked slot and the renders behind it.
2
+
3
+ app.include_router(thumbs_router(slot_dir), prefix="/api/thumbs")
4
+ app.include_router(render_router(render_file, root, known_slot),
5
+ prefix="/api/corpus/render")
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+
13
+ from fastapi import APIRouter, HTTPException
14
+ from fastapi.responses import FileResponse, JSONResponse
15
+
16
+ MEDIA_TYPES = {".svg": "image/svg+xml", ".png": "image/png", ".webp": "image/webp"}
17
+
18
+
19
+ def _tile(base: Path, stem: str) -> Path | None:
20
+ """The file for `stem` in whichever format it was baked: slots baked
21
+ before WebP are PNG, so a request's extension is a name, not a format."""
22
+ for ext in ("webp", "png"):
23
+ path = base / f"{stem}.{ext}"
24
+ if path.is_file():
25
+ return path
26
+ return None
27
+
28
+
29
+ def thumbs_router(slot_dir: Callable[[str], Path | None]) -> APIRouter:
30
+ """`slot_dir` maps a slot name to its bake directory, or None for a slot
31
+ the host does not have."""
32
+ router = APIRouter()
33
+
34
+ def _slot(slot: str) -> Path:
35
+ base = slot_dir(slot)
36
+ if base is None:
37
+ raise HTTPException(400, f"no such slot: {slot}")
38
+ return base
39
+
40
+ @router.get("/{slot}/sheet-{level}.{ext}")
41
+ def get_sheet(slot: str, level: int, ext: str):
42
+ base = _slot(slot)
43
+ if ext == "json":
44
+ path = base / f"sheet-{level}.json"
45
+ if not path.is_file():
46
+ raise HTTPException(404, "no such sheet manifest")
47
+ manifest = json.loads(path.read_text())
48
+ # The image is rewritten at a URL the client never varies, so a
49
+ # browser can hold last week's atlas against this manifest.
50
+ image = _tile(base, f"sheet-{level}")
51
+ if image is not None:
52
+ manifest["version"] = str(int(image.stat().st_mtime))
53
+ return JSONResponse(manifest)
54
+ path = _tile(base, f"sheet-{level}")
55
+ if path is None:
56
+ raise HTTPException(404, "no such sheet")
57
+ return FileResponse(path)
58
+
59
+ @router.get("/{slot}/{level}/{name}")
60
+ def get_tile(slot: str, level: int, name: str):
61
+ if "/" in name or ".." in name or not name.endswith((".png", ".webp")):
62
+ raise HTTPException(400, "bad thumbnail path")
63
+ path = _tile(_slot(slot) / str(level), Path(name).stem)
64
+ if path is None:
65
+ raise HTTPException(404, "no such thumbnail")
66
+ return FileResponse(path)
67
+
68
+ return router
69
+
70
+
71
+ def render_router(render_file: Callable[[str, str], Path | None], root: Path,
72
+ known_slot: Callable[[str], bool]) -> APIRouter:
73
+ """`render_file` names the render the host holds for an item in a slot.
74
+ Only its answer reaches the filesystem, and it must resolve inside `root`."""
75
+ router = APIRouter()
76
+ store = Path(root).resolve()
77
+
78
+ @router.get("/{slot}/{item_id}.svg")
79
+ def get_render(slot: str, item_id: str):
80
+ if not known_slot(slot):
81
+ raise HTTPException(400, f"no such slot: {slot}")
82
+ found = render_file(slot, item_id)
83
+ path = found.resolve() if found is not None else None
84
+ if path is None or store not in path.parents or not path.is_file():
85
+ raise HTTPException(404, "no such render")
86
+ # `.svg` is the wall's URL for a render, not a claim about the bytes.
87
+ return FileResponse(path, media_type=MEDIA_TYPES.get(
88
+ path.suffix, "application/octet-stream"))
89
+
90
+ return router
@@ -0,0 +1,52 @@
1
+ """Where a cell sits on a sheet: the level chain and the grid geometry."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from dataclasses import dataclass
6
+
7
+ SHEET_LEVELS = (8, 32)
8
+ LOOSE_LEVEL = 128
9
+ #: The coarsest sheet ends the mip chain, so it cannot bleed and needs no
10
+ #: padding. Every finer sheet does.
11
+ GUTTER = 2
12
+ LEVELS = (*SHEET_LEVELS, LOOSE_LEVEL)
13
+
14
+ #: The bake owns the ink and the wall owns the ground. A baked ground makes
15
+ #: the zoom rungs disagree, and a cell changes shade on one wheel notch.
16
+ GROUND = (0, 0, 0, 0)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Geometry:
21
+ count: int
22
+ level: int
23
+ cols: int
24
+ rows: int
25
+ gutter: int
26
+
27
+ @property
28
+ def pitch(self) -> int:
29
+ return self.level + 2 * self.gutter
30
+
31
+ @property
32
+ def size(self) -> int:
33
+ return self.cols * self.pitch
34
+
35
+ def cell_box(self, index: int) -> tuple[int, int, int, int]:
36
+ """The cell's (left, top, right, bottom) on the sheet, gutters excluded."""
37
+ if not 0 <= index < self.cols * self.rows:
38
+ raise IndexError(f"cell {index} is outside a {self.cols}x{self.rows} grid")
39
+ col, row = index % self.cols, index // self.cols
40
+ x = col * self.pitch + self.gutter
41
+ y = row * self.pitch + self.gutter
42
+ return (x, y, x + self.level, y + self.level)
43
+
44
+
45
+ def geometry(count: int, level: int) -> Geometry:
46
+ if level not in SHEET_LEVELS:
47
+ raise ValueError(f"{level} is not a sheet level; sheets are {SHEET_LEVELS}")
48
+ cols = max(1, math.ceil(math.sqrt(count)))
49
+ # cols >= sqrt(count) keeps rows <= cols, so the square sheet never crops.
50
+ rows = max(1, math.ceil(count / cols))
51
+ gutter = 0 if level == min(SHEET_LEVELS) else GUTTER
52
+ return Geometry(count=count, level=level, cols=cols, rows=rows, gutter=gutter)
@@ -0,0 +1,32 @@
1
+ """The slot's JSON sidecars: which sha each item was baked from."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+ BAKED = "baked.json"
9
+
10
+
11
+ def read_json(path: Path) -> dict:
12
+ """An unreadable sidecar is a cache miss, not a crash: baking again
13
+ recovers every value in it."""
14
+ if not path.is_file():
15
+ return {}
16
+ try:
17
+ return json.loads(path.read_text())
18
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
19
+ return {}
20
+
21
+
22
+ def write_json(path: Path, data: dict) -> None:
23
+ """Whole or not at all: `write_text` truncates first, and a reader that
24
+ arrives mid-write takes the empty file for the truth."""
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ tmp = path.with_suffix(f".{os.getpid()}.tmp")
27
+ tmp.write_text(json.dumps(data, sort_keys=True))
28
+ os.replace(tmp, path)
29
+
30
+
31
+ def baked_shas(out: Path | str) -> dict[str, str]:
32
+ return read_json(Path(out) / BAKED)
@@ -0,0 +1,11 @@
1
+ import pytest
2
+
3
+ SVG = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 170">'
4
+ '<rect x="0" y="0" width="256" height="170" fill="black"/></svg>')
5
+
6
+
7
+ @pytest.fixture
8
+ def svg(tmp_path):
9
+ path = tmp_path / "render.svg"
10
+ path.write_text(SVG)
11
+ return path
@@ -0,0 +1,119 @@
1
+ import json
2
+
3
+ import pytest
4
+ from PIL import Image
5
+
6
+ from pezlie import bake
7
+ from pezlie.sheet import LEVELS, geometry
8
+ from pezlie.sidecar import BAKED, baked_shas, write_json
9
+
10
+
11
+ def test_it_rasterizes_every_level_for_one_item(tmp_path, svg):
12
+ out = tmp_path / "slot"
13
+ assert sorted(bake.bake_item("3001", svg, out, sha="abc123")) == [8, 32, 128]
14
+ for level in (8, 32, 128):
15
+ with Image.open(out / str(level) / f"3001.{bake.THUMB_EXT}") as img:
16
+ assert img.size == (level, level)
17
+
18
+
19
+ def test_a_baked_cell_is_letterboxed_square_with_ink_and_no_ground(tmp_path, svg):
20
+ out = tmp_path / "slot"
21
+ bake.bake_item("3001", svg, out, sha="abc123")
22
+ with Image.open(out / "128" / f"3001.{bake.THUMB_EXT}") as img:
23
+ rgba = img.convert("RGBA")
24
+ assert rgba.size == (128, 128)
25
+ assert rgba.getpixel((2, 2))[3] == 0
26
+ assert rgba.getpixel((64, 64))[3] == 255
27
+
28
+
29
+ def test_it_bakes_a_raster_render_without_going_near_resvg(tmp_path):
30
+ src = tmp_path / "3001.webp"
31
+ Image.new("RGBA", (256, 170), (0, 0, 0, 255)).save(src, "WEBP")
32
+ out = tmp_path / "slot"
33
+ assert sorted(bake.bake_item("3001", src, out, sha="abc123")) == [8, 32, 128]
34
+ with Image.open(out / "128" / f"3001.{bake.THUMB_EXT}") as img:
35
+ assert img.convert("RGBA").getpixel((2, 2))[3] == 0
36
+ assert img.convert("RGBA").getpixel((64, 64))[3] == 255
37
+
38
+
39
+ def test_it_skips_an_item_whose_sha_is_unchanged(tmp_path, svg):
40
+ out = tmp_path / "slot"
41
+ bake.bake_item("3001", svg, out, sha="abc123")
42
+ assert bake.bake_item("3001", svg, out, sha="abc123") == []
43
+ assert bake.bake_item("3001", svg, out, sha="different") != []
44
+
45
+
46
+ def test_the_baked_sha_is_readable_back(tmp_path, svg):
47
+ out = tmp_path / "slot"
48
+ bake.bake_item("3001", svg, out, sha="abc123")
49
+ assert baked_shas(out) == {"3001": "abc123"}
50
+
51
+
52
+ def _baked(tmp_path, svg, ids):
53
+ out = tmp_path / "slot"
54
+ for item_id in ids:
55
+ bake.bake_item(item_id, svg, out, sha=f"sha-{item_id}")
56
+ return out
57
+
58
+
59
+ def test_the_sheet_is_one_page_sized_from_the_item_count(tmp_path, svg):
60
+ out = _baked(tmp_path, svg, ["a", "b", "c"])
61
+ bake.compose(out, order=["a", "b", "c", "d"])
62
+ for level in (8, 32):
63
+ with Image.open(out / f"sheet-{level}.{bake.THUMB_EXT}") as img:
64
+ assert img.size == (geometry(4, level).size,) * 2
65
+
66
+
67
+ def test_the_manifest_names_the_geometry_and_what_is_baked(tmp_path, svg):
68
+ out = _baked(tmp_path, svg, ["a", "c"])
69
+ bake.compose(out, order=["a", "b", "c", "d"])
70
+ m = json.loads((out / "sheet-32.json").read_text())
71
+ assert (m["level"], m["gutter"], m["pitch"], m["cols"], m["count"]) == (32, 2, 36, 2, 4)
72
+ assert m["baked"] == {"a": "sha-a", "c": "sha-c"}
73
+
74
+
75
+ def test_an_item_with_no_tile_leaves_its_cell_empty(tmp_path, svg):
76
+ out = _baked(tmp_path, svg, ["a"])
77
+ bake.compose(out, order=["a", "b"])
78
+ g = geometry(2, 32)
79
+ with Image.open(out / f"sheet-32.{bake.THUMB_EXT}") as img:
80
+ assert img.crop(g.cell_box(0)).getextrema()[3][1] > 0
81
+ assert img.crop(g.cell_box(1)).getextrema()[3][1] == 0
82
+
83
+
84
+ def test_the_gutter_replicates_the_cell_edge(tmp_path, svg):
85
+ out = _baked(tmp_path, svg, ["a"])
86
+ bake.compose(out, order=["a", "b"])
87
+ x0, y0, _, _ = geometry(2, 32).cell_box(0)
88
+ with Image.open(out / f"sheet-32.{bake.THUMB_EXT}") as img:
89
+ assert img.getpixel((x0 - 1, y0)) == img.getpixel((x0, y0))
90
+
91
+
92
+ def test_a_truncated_sidecar_is_a_cache_miss_not_a_crash(tmp_path):
93
+ (tmp_path / BAKED).write_text("")
94
+ assert baked_shas(tmp_path) == {}
95
+ (tmp_path / BAKED).write_text("{oh no")
96
+ assert baked_shas(tmp_path) == {}
97
+
98
+
99
+ def test_a_sidecar_is_written_whole_or_not_at_all(tmp_path):
100
+ write_json(tmp_path / "x.json", {"a": "1"})
101
+ assert json.loads((tmp_path / "x.json").read_text()) == {"a": "1"}
102
+ assert not list(tmp_path.glob("*.tmp"))
103
+
104
+
105
+ def test_a_format_change_rebakes_rather_than_composing_missing_tiles(
106
+ tmp_path, svg, monkeypatch):
107
+ out = tmp_path / "slot"
108
+ assert bake.bake_item("3001", svg, out, sha="abc") == list(LEVELS)
109
+ assert bake.bake_item("3001", svg, out, sha="abc") == []
110
+ monkeypatch.setattr(bake, "THUMB_EXT", "png")
111
+ monkeypatch.setattr(bake, "THUMB_SAVE", {"format": "PNG"})
112
+ assert bake.bake_item("3001", svg, out, sha="abc") == list(LEVELS)
113
+ for level in LEVELS:
114
+ assert (out / str(level) / "3001.png").is_file()
115
+
116
+
117
+ def test_compose_refuses_an_order_that_repeats_an_id(tmp_path):
118
+ with pytest.raises(ValueError, match="repeats 1 id"):
119
+ bake.compose(tmp_path, order=["a", "b", "a"])
@@ -0,0 +1,49 @@
1
+ import pytest
2
+
3
+ from pezlie.batch import Render, bake_slot
4
+ from pezlie.lock import BakeInProgress, slot_lock
5
+ from pezlie.sidecar import baked_shas
6
+
7
+
8
+ def test_it_bakes_every_render_and_composes_the_sheets(tmp_path, svg):
9
+ out, lines = tmp_path / "slot", []
10
+ got = bake_slot([Render("a", svg, "s1"), Render("b", svg, "s2")], out,
11
+ order=["a", "b", "c"], log=lines.append)
12
+ assert got == (2, 2)
13
+ assert baked_shas(out) == {"a": "s1", "b": "s2"}
14
+ assert (out / "sheet-32.webp").is_file()
15
+ assert lines[0] == " 1/2 a baked"
16
+
17
+
18
+ def test_a_second_run_bakes_nothing(tmp_path, svg):
19
+ out, lines = tmp_path / "slot", []
20
+ renders = [Render("a", svg, "s1")]
21
+ bake_slot(renders, out, order=["a"], log=lambda _: None)
22
+ assert bake_slot(renders, out, order=["a"], log=lines.append) == (0, 1)
23
+ assert lines[0] == " 1/1 a fresh"
24
+
25
+
26
+ def test_a_missing_render_is_reported_and_the_slot_goes_on(tmp_path, svg):
27
+ out, lines = tmp_path / "slot", []
28
+ got = bake_slot([Render("gone", tmp_path / "nope.svg", "s"), Render("a", svg, "s")],
29
+ out, order=["gone", "a"], log=lines.append)
30
+ assert got == (1, 2)
31
+ assert "MISSING" in lines[0]
32
+ assert (out / "sheet-8.webp").is_file()
33
+
34
+
35
+ def test_an_unreadable_render_does_not_abandon_the_rest(tmp_path, svg):
36
+ empty = tmp_path / "empty.svg"
37
+ empty.write_text("")
38
+ out, lines = tmp_path / "slot", []
39
+ got = bake_slot([Render("bad", empty, "s"), Render("a", svg, "s")],
40
+ out, order=["bad", "a"], log=lines.append)
41
+ assert got == (1, 2)
42
+ assert "UNREADABLE" in lines[0]
43
+
44
+
45
+ def test_it_refuses_a_slot_another_bake_holds(tmp_path, svg):
46
+ out = tmp_path / "slot"
47
+ with slot_lock(out):
48
+ with pytest.raises(BakeInProgress):
49
+ bake_slot([Render("a", svg, "s")], out, order=["a"], log=lambda _: None)
@@ -0,0 +1,23 @@
1
+ import pytest
2
+
3
+ from pezlie import bake
4
+ from pezlie.lock import BakeInProgress, slot_lock
5
+
6
+
7
+ def test_compose_refuses_while_another_bake_holds_the_slot(tmp_path):
8
+ with slot_lock(tmp_path):
9
+ with pytest.raises(BakeInProgress):
10
+ bake.compose(tmp_path, order=["a"])
11
+
12
+
13
+ def test_bake_item_refuses_while_another_bake_holds_the_slot(tmp_path, svg):
14
+ out = tmp_path / "slot"
15
+ with slot_lock(out):
16
+ with pytest.raises(BakeInProgress):
17
+ bake.bake_item("a", svg, out, sha="x")
18
+
19
+
20
+ def test_a_failed_bake_releases_the_slot(tmp_path):
21
+ with pytest.raises(ValueError):
22
+ bake.compose(tmp_path, order=["a", "a"])
23
+ assert bake.compose(tmp_path, order=["a"])
@@ -0,0 +1,65 @@
1
+ """The lift renamed things and moved nothing: bakery and the brick-icons file
2
+ it came from bake the same inputs to the same bytes. Skips without a
3
+ brick-icons checkout beside pezlie, or at $BRICK_ICONS."""
4
+ import importlib.util
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+ from PIL import Image
11
+
12
+ from pezlie import bake
13
+
14
+ LEGACY = (Path(os.environ.get("BRICK_ICONS")
15
+ or Path(__file__).resolve().parents[3] / "brick-icons")
16
+ / "brick_icons" / "thumbs.py")
17
+
18
+
19
+ def _legacy():
20
+ if not LEGACY.is_file():
21
+ pytest.skip(f"no brick-icons thumbs.py at {LEGACY}")
22
+ spec = importlib.util.spec_from_file_location("legacy_thumbs", LEGACY)
23
+ module = importlib.util.module_from_spec(spec)
24
+ sys.modules[spec.name] = module # @dataclass looks its module up here
25
+ spec.loader.exec_module(module)
26
+ return module
27
+
28
+
29
+ def _renders(root: Path) -> dict[str, Path]:
30
+ root.mkdir()
31
+ wide = root / "wide.svg"
32
+ wide.write_text('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 170">'
33
+ '<rect x="20" y="10" width="200" height="150" fill="#c33"/>'
34
+ '<circle cx="128" cy="85" r="60" fill="#36c"/></svg>')
35
+ tall = root / "tall.svg"
36
+ tall.write_text('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 240">'
37
+ '<path d="M0 240 L45 0 L90 240 Z" fill="#2a2"/></svg>')
38
+ raster = root / "raster.webp"
39
+ img = Image.new("RGBA", (256, 170))
40
+ for x in range(256):
41
+ for y in range(0, 170, 3):
42
+ img.putpixel((x, y), (x, 255 - x, y, 255))
43
+ img.save(raster, "WEBP")
44
+ return {"a": wide, "b": tall, "c": raster}
45
+
46
+
47
+ def _files(root: Path) -> dict[str, bytes]:
48
+ return {str(p.relative_to(root)): p.read_bytes()
49
+ for p in sorted(root.rglob("*")) if p.is_file() and p.name != ".bake.lock"}
50
+
51
+
52
+ def test_a_slot_bakes_to_the_same_bytes_as_the_code_it_was_lifted_from(tmp_path):
53
+ legacy = _legacy()
54
+ renders = _renders(tmp_path / "src")
55
+ order = ["a", "never-drawn", "b", "c"]
56
+
57
+ for item_id, path in renders.items():
58
+ legacy.bake_part(item_id, path, tmp_path / "legacy", sha=f"sha-{item_id}")
59
+ bake.bake_item(item_id, path, tmp_path / "lifted", sha=f"sha-{item_id}")
60
+ legacy.compose(tmp_path / "legacy", order)
61
+ bake.compose(tmp_path / "lifted", order)
62
+
63
+ before, after = _files(tmp_path / "legacy"), _files(tmp_path / "lifted")
64
+ assert sorted(before) == sorted(after)
65
+ assert [name for name in before if before[name] != after[name]] == []
@@ -0,0 +1,90 @@
1
+ import pytest
2
+ from fastapi import FastAPI
3
+ from fastapi.testclient import TestClient
4
+ from PIL import Image
5
+
6
+ from pezlie.routes import render_router, thumbs_router
7
+
8
+
9
+ @pytest.fixture
10
+ def client(tmp_path):
11
+ slot = tmp_path / "thumbs" / "naive"
12
+ (slot / "128").mkdir(parents=True)
13
+ Image.new("RGBA", (128, 128)).save(slot / "128" / "3001.webp", "WEBP")
14
+ Image.new("RGBA", (8, 8)).save(slot / "sheet-8.webp", "WEBP")
15
+ (slot / "sheet-8.json").write_text('{"level": 8}')
16
+ (slot / "baked.json").write_text("{}")
17
+
18
+ store = tmp_path / "store"
19
+ (store / "naive").mkdir(parents=True)
20
+ (store / "naive" / "3001.svg").write_text("<svg viewBox='0 0 256 170'></svg>")
21
+ (store / "naive" / "3002.webp").write_bytes(b"RIFF\x00\x00\x00\x00WEBPVP8 ")
22
+ (tmp_path / "outside.svg").write_text("<svg>not in the store</svg>")
23
+ renders = {("naive", "3001"): store / "naive" / "3001.svg",
24
+ ("naive", "3002"): store / "naive" / "3002.webp",
25
+ ("naive", "escape"): store / ".." / "outside.svg"}
26
+
27
+ slots = {"naive"}
28
+ app = FastAPI()
29
+ app.include_router(thumbs_router(
30
+ lambda s: tmp_path / "thumbs" / s if s in slots else None), prefix="/api/thumbs")
31
+ app.include_router(render_router(
32
+ lambda s, i: renders.get((s, i)), store, lambda s: s in slots),
33
+ prefix="/api/corpus/render")
34
+ return TestClient(app)
35
+
36
+
37
+ def test_a_loose_tile_is_served_whatever_extension_was_asked(client):
38
+ r = client.get("/api/thumbs/naive/128/3001.png")
39
+ assert r.status_code == 200
40
+ assert r.headers["content-type"] == "image/webp"
41
+
42
+
43
+ def test_a_sheet_is_served(client):
44
+ assert client.get("/api/thumbs/naive/sheet-8.webp").status_code == 200
45
+
46
+
47
+ def test_a_manifest_carries_its_image_version(client):
48
+ body = client.get("/api/thumbs/naive/sheet-8.json").json()
49
+ assert body["level"] == 8
50
+ assert body["version"].isdigit()
51
+
52
+
53
+ def test_a_missing_manifest_is_404(client):
54
+ assert client.get("/api/thumbs/naive/sheet-32.json").status_code == 404
55
+
56
+
57
+ def test_an_unknown_slot_is_400(client):
58
+ assert client.get("/api/thumbs/nonsense/128/3001.png").status_code == 400
59
+
60
+
61
+ def test_a_tile_route_serves_images_only(client):
62
+ assert client.get("/api/thumbs/naive/128/baked.json").status_code == 400
63
+
64
+
65
+ def test_a_tile_route_refuses_traversal(client):
66
+ assert client.get(
67
+ "/api/thumbs/naive/128/..%2F..%2Fbaked.json").status_code in (400, 404)
68
+
69
+
70
+ def test_a_render_is_served(client):
71
+ r = client.get("/api/corpus/render/naive/3001.svg")
72
+ assert r.status_code == 200
73
+ assert "<svg" in r.text
74
+
75
+
76
+ def test_a_render_is_typed_by_what_it_is_not_by_its_url(client):
77
+ r = client.get("/api/corpus/render/naive/3002.svg")
78
+ assert r.headers["content-type"] == "image/webp"
79
+
80
+
81
+ def test_an_unknown_render_is_404(client):
82
+ assert client.get("/api/corpus/render/naive/9999.svg").status_code == 404
83
+
84
+
85
+ def test_a_render_in_an_unknown_slot_is_400(client):
86
+ assert client.get("/api/corpus/render/nonsense/3001.svg").status_code == 400
87
+
88
+
89
+ def test_a_render_outside_the_store_is_404(client):
90
+ assert client.get("/api/corpus/render/naive/escape.svg").status_code == 404
@@ -0,0 +1,58 @@
1
+ import pytest
2
+
3
+ from pezlie import sheet
4
+
5
+
6
+ def test_levels_are_the_two_sheets_and_the_loose_one():
7
+ assert sheet.SHEET_LEVELS == (8, 32)
8
+ assert sheet.LOOSE_LEVEL == 128
9
+
10
+
11
+ def test_the_grid_is_square_enough_to_hold_every_item():
12
+ g = sheet.geometry(24591, level=32)
13
+ assert (g.cols, g.rows) == (157, 157)
14
+
15
+
16
+ def test_the_coarsest_level_has_no_gutter():
17
+ assert sheet.geometry(100, level=8).gutter == 0
18
+ assert sheet.geometry(100, level=32).gutter == 2
19
+
20
+
21
+ def test_pitch_is_the_cell_plus_both_gutters():
22
+ g = sheet.geometry(100, level=32)
23
+ assert g.pitch == 36
24
+ assert g.size == g.cols * 36
25
+
26
+
27
+ def test_a_cell_lands_row_major_inside_its_gutter():
28
+ g = sheet.geometry(100, level=32) # cols == 10
29
+ assert g.cell_box(0) == (2, 2, 34, 34)
30
+ assert g.cell_box(1) == (38, 2, 70, 34)
31
+ assert g.cell_box(10) == (2, 38, 34, 70)
32
+
33
+
34
+ def test_an_index_past_the_grid_is_an_error():
35
+ g = sheet.geometry(4, level=8)
36
+ with pytest.raises(IndexError):
37
+ g.cell_box(g.cols * g.rows)
38
+
39
+
40
+ def test_the_loose_level_is_not_a_sheet():
41
+ with pytest.raises(ValueError):
42
+ sheet.geometry(100, level=sheet.LOOSE_LEVEL)
43
+
44
+
45
+ def test_a_square_sheet_never_crops_an_uneven_grid():
46
+ g = sheet.geometry(82, level=32)
47
+ assert (g.cols, g.rows) == (10, 9)
48
+ assert g.cell_box(81)[3] <= g.size
49
+
50
+
51
+ def test_a_tiny_corpus_still_has_a_grid():
52
+ for count in (0, 1):
53
+ g = sheet.geometry(count, level=8)
54
+ assert (g.cols, g.rows) == (1, 1)
55
+
56
+
57
+ def test_the_ground_is_transparent():
58
+ assert sheet.GROUND == (0, 0, 0, 0)