ophtml 0.3.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.
- ophtml-0.3.0/PKG-INFO +41 -0
- ophtml-0.3.0/README.md +31 -0
- ophtml-0.3.0/ophtml.egg-info/PKG-INFO +41 -0
- ophtml-0.3.0/ophtml.egg-info/SOURCES.txt +31 -0
- ophtml-0.3.0/ophtml.egg-info/dependency_links.txt +1 -0
- ophtml-0.3.0/ophtml.egg-info/entry_points.txt +5 -0
- ophtml-0.3.0/ophtml.egg-info/requires.txt +1 -0
- ophtml-0.3.0/ophtml.egg-info/top_level.txt +1 -0
- ophtml-0.3.0/ps2ui_bake/__init__.py +31 -0
- ophtml-0.3.0/ps2ui_bake/__main__.py +3 -0
- ophtml-0.3.0/ps2ui_bake/arena.py +77 -0
- ophtml-0.3.0/ps2ui_bake/atlas.py +154 -0
- ophtml-0.3.0/ps2ui_bake/caps.py +139 -0
- ophtml-0.3.0/ps2ui_bake/check.py +729 -0
- ophtml-0.3.0/ps2ui_bake/cli.py +343 -0
- ophtml-0.3.0/ps2ui_bake/clip.py +60 -0
- ophtml-0.3.0/ps2ui_bake/fontgen.py +140 -0
- ophtml-0.3.0/ps2ui_bake/gs.py +68 -0
- ophtml-0.3.0/ps2ui_bake/ninepatch.py +176 -0
- ophtml-0.3.0/ps2ui_bake/pen.py +67 -0
- ophtml-0.3.0/ps2ui_bake/preview.py +335 -0
- ophtml-0.3.0/ps2ui_bake/project.py +224 -0
- ophtml-0.3.0/ps2ui_bake/ps2ui.py +377 -0
- ophtml-0.3.0/ps2ui_bake/quads.py +740 -0
- ophtml-0.3.0/ps2ui_bake/rounding.py +67 -0
- ophtml-0.3.0/ps2ui_bake/serve.py +844 -0
- ophtml-0.3.0/ps2ui_bake/serve_page.html +714 -0
- ophtml-0.3.0/ps2ui_bake/uib.py +649 -0
- ophtml-0.3.0/ps2ui_bake/vram.py +232 -0
- ophtml-0.3.0/pyproject.toml +44 -0
- ophtml-0.3.0/setup.cfg +4 -0
- ophtml-0.3.0/tests/test_baker.py +4008 -0
- ophtml-0.3.0/tests/test_serve.py +728 -0
ophtml-0.3.0/PKG-INFO
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ophtml
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Bakes the ps2ui intermediate representation into .uib blobs for the PlayStation 2 runtime
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/coffeedevsolutions/OPHTML
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: Pillow>=9
|
|
10
|
+
|
|
11
|
+
# ophtml
|
|
12
|
+
|
|
13
|
+
The Python half of the OPHTML toolchain, published to PyPI as
|
|
14
|
+
`ophtml`. It provides the `ps2ui`, `ps2ui-bake`, `ps2ui-check` and
|
|
15
|
+
`ps2ui-fontgen` commands. OPHTML is the product, ps2ui is the format
|
|
16
|
+
and the tools that speak it.
|
|
17
|
+
|
|
18
|
+
Second stage of the [ps2ui toolchain](https://github.com/coffeedevsolutions/OPHTML/blob/main/README.md): turns the
|
|
19
|
+
`ui.json` IR produced by `@ophtml/layout` into a `.uib` blob the C99
|
|
20
|
+
runtime replays on the PlayStation 2, plus PNG previews rendered by
|
|
21
|
+
replaying that same blob.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
PYTHONPATH=. python3 -m ps2ui_bake ui.json -o ui.uib --preview out.png
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`ps2ui serve` puts that same replay behind a localhost page with
|
|
28
|
+
arrow-key navigation, screen and theme switching, four aspect modes and
|
|
29
|
+
click-to-inspect over the command list. `--uib blob.uib` serves any
|
|
30
|
+
`.uib` with no project and no Node. It renders through the previewer
|
|
31
|
+
rather than in the browser, so it shows what the console draws; it is
|
|
32
|
+
not a substitute for running on one. See the repository README.
|
|
33
|
+
|
|
34
|
+
`pip install -e .` from this directory puts `ps2ui`, `ps2ui-bake`,
|
|
35
|
+
`ps2ui-check` and `ps2ui-fontgen` on `PATH` as bare commands, pointed at
|
|
36
|
+
the checkout, so the `PYTHONPATH=` prefix is only needed when nothing is
|
|
37
|
+
installed, which is the case CI runs in.
|
|
38
|
+
|
|
39
|
+
See `docs/format-uib.md` at the repository root for the file format,
|
|
40
|
+
and `ps2ui_bake/rounding.py` for the numeric rules shared with the
|
|
41
|
+
layout stage.
|
ophtml-0.3.0/README.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# ophtml
|
|
2
|
+
|
|
3
|
+
The Python half of the OPHTML toolchain, published to PyPI as
|
|
4
|
+
`ophtml`. It provides the `ps2ui`, `ps2ui-bake`, `ps2ui-check` and
|
|
5
|
+
`ps2ui-fontgen` commands. OPHTML is the product, ps2ui is the format
|
|
6
|
+
and the tools that speak it.
|
|
7
|
+
|
|
8
|
+
Second stage of the [ps2ui toolchain](https://github.com/coffeedevsolutions/OPHTML/blob/main/README.md): turns the
|
|
9
|
+
`ui.json` IR produced by `@ophtml/layout` into a `.uib` blob the C99
|
|
10
|
+
runtime replays on the PlayStation 2, plus PNG previews rendered by
|
|
11
|
+
replaying that same blob.
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
PYTHONPATH=. python3 -m ps2ui_bake ui.json -o ui.uib --preview out.png
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`ps2ui serve` puts that same replay behind a localhost page with
|
|
18
|
+
arrow-key navigation, screen and theme switching, four aspect modes and
|
|
19
|
+
click-to-inspect over the command list. `--uib blob.uib` serves any
|
|
20
|
+
`.uib` with no project and no Node. It renders through the previewer
|
|
21
|
+
rather than in the browser, so it shows what the console draws; it is
|
|
22
|
+
not a substitute for running on one. See the repository README.
|
|
23
|
+
|
|
24
|
+
`pip install -e .` from this directory puts `ps2ui`, `ps2ui-bake`,
|
|
25
|
+
`ps2ui-check` and `ps2ui-fontgen` on `PATH` as bare commands, pointed at
|
|
26
|
+
the checkout, so the `PYTHONPATH=` prefix is only needed when nothing is
|
|
27
|
+
installed, which is the case CI runs in.
|
|
28
|
+
|
|
29
|
+
See `docs/format-uib.md` at the repository root for the file format,
|
|
30
|
+
and `ps2ui_bake/rounding.py` for the numeric rules shared with the
|
|
31
|
+
layout stage.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ophtml
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Bakes the ps2ui intermediate representation into .uib blobs for the PlayStation 2 runtime
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/coffeedevsolutions/OPHTML
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: Pillow>=9
|
|
10
|
+
|
|
11
|
+
# ophtml
|
|
12
|
+
|
|
13
|
+
The Python half of the OPHTML toolchain, published to PyPI as
|
|
14
|
+
`ophtml`. It provides the `ps2ui`, `ps2ui-bake`, `ps2ui-check` and
|
|
15
|
+
`ps2ui-fontgen` commands. OPHTML is the product, ps2ui is the format
|
|
16
|
+
and the tools that speak it.
|
|
17
|
+
|
|
18
|
+
Second stage of the [ps2ui toolchain](https://github.com/coffeedevsolutions/OPHTML/blob/main/README.md): turns the
|
|
19
|
+
`ui.json` IR produced by `@ophtml/layout` into a `.uib` blob the C99
|
|
20
|
+
runtime replays on the PlayStation 2, plus PNG previews rendered by
|
|
21
|
+
replaying that same blob.
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
PYTHONPATH=. python3 -m ps2ui_bake ui.json -o ui.uib --preview out.png
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`ps2ui serve` puts that same replay behind a localhost page with
|
|
28
|
+
arrow-key navigation, screen and theme switching, four aspect modes and
|
|
29
|
+
click-to-inspect over the command list. `--uib blob.uib` serves any
|
|
30
|
+
`.uib` with no project and no Node. It renders through the previewer
|
|
31
|
+
rather than in the browser, so it shows what the console draws; it is
|
|
32
|
+
not a substitute for running on one. See the repository README.
|
|
33
|
+
|
|
34
|
+
`pip install -e .` from this directory puts `ps2ui`, `ps2ui-bake`,
|
|
35
|
+
`ps2ui-check` and `ps2ui-fontgen` on `PATH` as bare commands, pointed at
|
|
36
|
+
the checkout, so the `PYTHONPATH=` prefix is only needed when nothing is
|
|
37
|
+
installed, which is the case CI runs in.
|
|
38
|
+
|
|
39
|
+
See `docs/format-uib.md` at the repository root for the file format,
|
|
40
|
+
and `ps2ui_bake/rounding.py` for the numeric rules shared with the
|
|
41
|
+
layout stage.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
ophtml.egg-info/PKG-INFO
|
|
4
|
+
ophtml.egg-info/SOURCES.txt
|
|
5
|
+
ophtml.egg-info/dependency_links.txt
|
|
6
|
+
ophtml.egg-info/entry_points.txt
|
|
7
|
+
ophtml.egg-info/requires.txt
|
|
8
|
+
ophtml.egg-info/top_level.txt
|
|
9
|
+
ps2ui_bake/__init__.py
|
|
10
|
+
ps2ui_bake/__main__.py
|
|
11
|
+
ps2ui_bake/arena.py
|
|
12
|
+
ps2ui_bake/atlas.py
|
|
13
|
+
ps2ui_bake/caps.py
|
|
14
|
+
ps2ui_bake/check.py
|
|
15
|
+
ps2ui_bake/cli.py
|
|
16
|
+
ps2ui_bake/clip.py
|
|
17
|
+
ps2ui_bake/fontgen.py
|
|
18
|
+
ps2ui_bake/gs.py
|
|
19
|
+
ps2ui_bake/ninepatch.py
|
|
20
|
+
ps2ui_bake/pen.py
|
|
21
|
+
ps2ui_bake/preview.py
|
|
22
|
+
ps2ui_bake/project.py
|
|
23
|
+
ps2ui_bake/ps2ui.py
|
|
24
|
+
ps2ui_bake/quads.py
|
|
25
|
+
ps2ui_bake/rounding.py
|
|
26
|
+
ps2ui_bake/serve.py
|
|
27
|
+
ps2ui_bake/serve_page.html
|
|
28
|
+
ps2ui_bake/uib.py
|
|
29
|
+
ps2ui_bake/vram.py
|
|
30
|
+
tests/test_baker.py
|
|
31
|
+
tests/test_serve.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Pillow>=9
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ps2ui_bake
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""ps2ui-bake: turns the ui.json IR into a .uib blob the PS2 runtime replays.
|
|
2
|
+
|
|
3
|
+
Stages inside this package:
|
|
4
|
+
|
|
5
|
+
fontgen TTF -> metrics JSON (the seam shared with @ophtml/layout)
|
|
6
|
+
atlas glyph atlases per (weight, size), 8-bit coverage + CLUT
|
|
7
|
+
ninepatch rounded-rect chrome -> 9-sliced RGBA patches
|
|
8
|
+
quads IR commands -> flat GS quad records
|
|
9
|
+
uib binary .uib writer / reader
|
|
10
|
+
preview replays a baked blob to PNG, montages focus states
|
|
11
|
+
|
|
12
|
+
The one rule: everything the console would otherwise compute happens here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# THE ONE PLACE THIS PACKAGE'S VERSION IS WRITTEN. pyproject.toml reads
|
|
16
|
+
# it (`dynamic = ["version"]`), because from the 0.2.0 release onward
|
|
17
|
+
# this file said 0.1.0 while pyproject said 0.2.0 -- two numbers for one
|
|
18
|
+
# package, neither read by anything, so neither could be wrong out loud.
|
|
19
|
+
#
|
|
20
|
+
# 0.3.0 is a release and names the tag `v0.3.0`. It carried `.dev0`
|
|
21
|
+
# until that tag existed, because there is no 0.2.0 tag and never was:
|
|
22
|
+
# the tree was past that release by four format moves (v4-v7) and was
|
|
23
|
+
# not yet the next one. What a prerelease does and does NOT protect
|
|
24
|
+
# against is in docs/releasing.md -- pip's exclusion of prereleases
|
|
25
|
+
# lapses when no stable version exists, which is why the first PyPI
|
|
26
|
+
# upload has to be a real release rather than a reserved name.
|
|
27
|
+
# tools/check-versions.py holds all of it together, and its rule 8
|
|
28
|
+
# fails this line the moment it names a release nothing has tagged.
|
|
29
|
+
__version__ = "0.3.0"
|
|
30
|
+
|
|
31
|
+
from .rounding import round_half_up, css_alpha_to_gs, gs_alpha_to_css # noqa: F401
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The v6 arena requirement, computed from a baked blob.
|
|
2
|
+
|
|
3
|
+
`ps2ui_load` no longer sizes itself from PS2UI_MAX_* ceilings: it carves
|
|
4
|
+
one caller-provided block, and the caller has to know how big it is
|
|
5
|
+
before running anything. This is the host-side mirror of `arena_compute`
|
|
6
|
+
in runtime/ps2ui.c, so `ps2ui-bake` and `ps2ui-check` can print the
|
|
7
|
+
number an integrator needs to declare a static buffer.
|
|
8
|
+
|
|
9
|
+
THE NUMBER IS TARGET-DEPENDENT, which is the only subtle thing here.
|
|
10
|
+
The arena holds an array of gsKit's GSTEXTURE, and that struct contains
|
|
11
|
+
two pointers (`u32 *Mem`, `u32 *Clut`). On the EE they are 4 bytes; on a
|
|
12
|
+
64-bit host they are 8, so the same blob needs a different arena in the
|
|
13
|
+
sample ELF than in the host test suite. Reporting one number without
|
|
14
|
+
saying which target it belongs to would be wrong roughly half the time,
|
|
15
|
+
so both are computed and the console one is the headline -- that is
|
|
16
|
+
where the static buffer actually lives.
|
|
17
|
+
|
|
18
|
+
test_arena_matches_runtime proves this file agrees with the C: two
|
|
19
|
+
implementations that agree is evidence, one restating the other's
|
|
20
|
+
comments is not.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# GSTEXTURE, from runtime/vendor/gsKit/gsInit.h (u32 Width, u32 Height,
|
|
24
|
+
# u8 PSM, u8 ClutPSM, u32 TBW, u32 *Mem, u32 *Clut, u32 Vram,
|
|
25
|
+
# u32 VramClut, u32 Filter, u8 ClutStorageMode, u8 Delayed), laid out by
|
|
26
|
+
# the ordinary C rules rather than by a remembered total.
|
|
27
|
+
_GSTEXTURE_FIELDS = [
|
|
28
|
+
(4, 4), (4, 4), # Width, Height
|
|
29
|
+
(1, 1), (1, 1), # PSM, ClutPSM
|
|
30
|
+
(4, 4), # TBW
|
|
31
|
+
("ptr", "ptr"), ("ptr", "ptr"), # Mem, Clut
|
|
32
|
+
(4, 4), (4, 4), (4, 4), # Vram, VramClut, Filter
|
|
33
|
+
(1, 1), (1, 1), # ClutStorageMode, Delayed
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
EE_PTR = 4 # mips64r5900el-ps2-elf: 32-bit pointers
|
|
37
|
+
HOST64_PTR = 8
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def sizeof_gstexture(ptr_size: int = EE_PTR) -> int:
|
|
41
|
+
off = 0
|
|
42
|
+
align_max = 1
|
|
43
|
+
for size, align in _GSTEXTURE_FIELDS:
|
|
44
|
+
if size == "ptr":
|
|
45
|
+
size = align = ptr_size
|
|
46
|
+
off = (off + align - 1) // align * align
|
|
47
|
+
off += size
|
|
48
|
+
align_max = max(align_max, align)
|
|
49
|
+
return (off + align_max - 1) // align_max * align_max
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def arena_size(uib, ptr_size: int = EE_PTR) -> int:
|
|
53
|
+
"""Bytes ps2ui_load needs for `uib`, for a target with this pointer
|
|
54
|
+
size. Mirrors arena_compute's regions in the same order."""
|
|
55
|
+
total = 0
|
|
56
|
+
total += len(uib.cluts) * 256 * 4 # permuted CLUTs
|
|
57
|
+
total += len(uib.textures) * sizeof_gstexture(ptr_size)
|
|
58
|
+
total += len(uib.slots) * 4 # slot_off[]
|
|
59
|
+
total += ((len(uib.focus) + 31) // 32) * 4 # hidden bits
|
|
60
|
+
total += len(uib.screens) * 2 # screen_focus[]
|
|
61
|
+
for sl in uib.slots:
|
|
62
|
+
total += sl["capacity"] + 1 # slot text
|
|
63
|
+
total += len(uib.slots) # slot_is_set
|
|
64
|
+
return total
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def breakdown(uib, ptr_size: int = EE_PTR) -> list:
|
|
68
|
+
"""(region, bytes) pairs, for a report that says where it went."""
|
|
69
|
+
return [
|
|
70
|
+
("permuted CLUTs", len(uib.cluts) * 256 * 4),
|
|
71
|
+
("texture handles", len(uib.textures) * sizeof_gstexture(ptr_size)),
|
|
72
|
+
("slot text", sum(sl["capacity"] + 1 for sl in uib.slots)),
|
|
73
|
+
("slot offsets", len(uib.slots) * 4),
|
|
74
|
+
("visibility bits", ((len(uib.focus) + 31) // 32) * 4),
|
|
75
|
+
("screen focus", len(uib.screens) * 2),
|
|
76
|
+
("slot set flags", len(uib.slots)),
|
|
77
|
+
]
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Glyph atlas baking.
|
|
2
|
+
|
|
3
|
+
One atlas per (weight, size) pair used by the document. Glyphs are
|
|
4
|
+
rasterized by FreeType (via Pillow) at the exact pixel size the layout
|
|
5
|
+
measured — never scaled on the GS, where bilinear filtering would turn
|
|
6
|
+
14px text into soup.
|
|
7
|
+
|
|
8
|
+
The atlas is PSMT8: each texel is a coverage value 0..255 indexing a
|
|
9
|
+
CLUT of white-with-alpha-ramp, and the runtime tints by vertex color
|
|
10
|
+
modulation. One atlas therefore serves every text color in the UI.
|
|
11
|
+
|
|
12
|
+
Glyph advances are re-derived from the *metrics JSON*, not from what
|
|
13
|
+
FreeType reports at this size — layout already positioned every line
|
|
14
|
+
using metrics advances, and the bitmap must follow the measurement, not
|
|
15
|
+
the other way around. The bitmap's own bearing places ink within the
|
|
16
|
+
advance box.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
23
|
+
|
|
24
|
+
from .rounding import glyph_advance_px, kern_px
|
|
25
|
+
|
|
26
|
+
ATLAS_WIDTH = 256 # GS-friendly; height grows in shelf packing
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Glyph:
|
|
31
|
+
codepoint: int
|
|
32
|
+
u: int
|
|
33
|
+
v: int
|
|
34
|
+
w: int
|
|
35
|
+
h: int
|
|
36
|
+
bearing_x: int # ink offset from pen x
|
|
37
|
+
bearing_y: int # ink offset from the line-box top, measured via the
|
|
38
|
+
# METRICS ascent — see AtlasBuilder.add (backlog B2)
|
|
39
|
+
advance: int # px, from metrics — matches layout exactly
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Atlas:
|
|
44
|
+
weight: int
|
|
45
|
+
size: int
|
|
46
|
+
image: "Image.Image"
|
|
47
|
+
glyphs: dict = field(default_factory=dict) # codepoint -> Glyph
|
|
48
|
+
line_ascent: int = 0
|
|
49
|
+
line_descent: int = 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AtlasBuilder:
|
|
53
|
+
"""Shelf-packs glyphs for one (weight, size)."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, ttf_path: str, metrics_path: str, weight: int, size: int):
|
|
56
|
+
self.weight = weight
|
|
57
|
+
self.size = size
|
|
58
|
+
self.font = ImageFont.truetype(ttf_path, size)
|
|
59
|
+
with open(metrics_path, encoding="utf-8") as fh:
|
|
60
|
+
self.metrics = json.load(fh)
|
|
61
|
+
self.image = Image.new("L", (ATLAS_WIDTH, 64), 0)
|
|
62
|
+
self.draw = ImageDraw.Draw(self.image)
|
|
63
|
+
self.glyphs = {}
|
|
64
|
+
self.shelf_x = 1
|
|
65
|
+
self.shelf_y = 1
|
|
66
|
+
self.shelf_h = 0
|
|
67
|
+
ascent, descent = self.font.getmetrics()
|
|
68
|
+
self.ascent = ascent
|
|
69
|
+
self.descent = descent
|
|
70
|
+
# The layout stage positioned every line box using the METRICS
|
|
71
|
+
# ascent (units -> px via the shared rounding rule). Pillow's
|
|
72
|
+
# per-size ascent can differ by a pixel, so ink placement must
|
|
73
|
+
# go through the metrics value or text drifts off its measured
|
|
74
|
+
# box (backlog B2).
|
|
75
|
+
self.metrics_ascent_px = glyph_advance_px(self.metrics["ascent"], size)
|
|
76
|
+
self.kerning = self.metrics.get("kerning", {})
|
|
77
|
+
|
|
78
|
+
def kern(self, prev_cp, cp: int) -> int:
|
|
79
|
+
"""Pixel kern applied before `cp` because `prev_cp` precedes it.
|
|
80
|
+
|
|
81
|
+
Mirrors layout's Font.kernPx, including that the first glyph of
|
|
82
|
+
a run is never kerned."""
|
|
83
|
+
if prev_cp is None:
|
|
84
|
+
return 0
|
|
85
|
+
return kern_px(self.kerning.get(f"{prev_cp},{cp}", 0), self.size)
|
|
86
|
+
|
|
87
|
+
def _grow(self, needed_h: int):
|
|
88
|
+
if needed_h <= self.image.height:
|
|
89
|
+
return
|
|
90
|
+
h = self.image.height
|
|
91
|
+
while h < needed_h:
|
|
92
|
+
h *= 2
|
|
93
|
+
grown = Image.new("L", (ATLAS_WIDTH, h), 0)
|
|
94
|
+
grown.paste(self.image, (0, 0))
|
|
95
|
+
self.image = grown
|
|
96
|
+
self.draw = ImageDraw.Draw(self.image)
|
|
97
|
+
|
|
98
|
+
def add(self, ch: str) -> Glyph:
|
|
99
|
+
cp = ord(ch)
|
|
100
|
+
if cp in self.glyphs:
|
|
101
|
+
return self.glyphs[cp]
|
|
102
|
+
|
|
103
|
+
adv_units = self.metrics["advances"].get(str(cp), self.metrics["missing"])
|
|
104
|
+
advance = glyph_advance_px(adv_units, self.size)
|
|
105
|
+
|
|
106
|
+
# Rasterize into a scratch tile and crop to ink.
|
|
107
|
+
pad = 2
|
|
108
|
+
tile_w = advance + self.size + 2 * pad
|
|
109
|
+
tile_h = self.ascent + self.descent + 2 * pad
|
|
110
|
+
tile = Image.new("L", (tile_w, tile_h), 0)
|
|
111
|
+
ImageDraw.Draw(tile).text((pad, pad), ch, font=self.font, fill=255)
|
|
112
|
+
bbox = tile.getbbox()
|
|
113
|
+
if bbox is None: # space & friends: advance with no ink
|
|
114
|
+
g = Glyph(cp, 0, 0, 0, 0, 0, 0, advance)
|
|
115
|
+
self.glyphs[cp] = g
|
|
116
|
+
return g
|
|
117
|
+
x0, y0, x1, y1 = bbox
|
|
118
|
+
ink = tile.crop(bbox)
|
|
119
|
+
w, h = ink.size
|
|
120
|
+
|
|
121
|
+
if self.shelf_x + w + 1 > ATLAS_WIDTH:
|
|
122
|
+
self.shelf_y += self.shelf_h + 1
|
|
123
|
+
self.shelf_x = 1
|
|
124
|
+
self.shelf_h = 0
|
|
125
|
+
self._grow(self.shelf_y + h + 1)
|
|
126
|
+
self.image.paste(ink, (self.shelf_x, self.shelf_y))
|
|
127
|
+
|
|
128
|
+
# Pillow's default anchor puts the FreeType ascender at y=pad,
|
|
129
|
+
# so (y0 - pad) is ink-below-PIL-ascender. Re-express it as
|
|
130
|
+
# ink-below-baseline, then hang it from the metrics ascent that
|
|
131
|
+
# layout actually measured with.
|
|
132
|
+
ink_from_baseline = (y0 - pad) - self.ascent
|
|
133
|
+
g = Glyph(
|
|
134
|
+
codepoint=cp,
|
|
135
|
+
u=self.shelf_x, v=self.shelf_y, w=w, h=h,
|
|
136
|
+
bearing_x=x0 - pad,
|
|
137
|
+
bearing_y=self.metrics_ascent_px + ink_from_baseline,
|
|
138
|
+
advance=advance,
|
|
139
|
+
)
|
|
140
|
+
self.shelf_x += w + 1
|
|
141
|
+
self.shelf_h = max(self.shelf_h, h)
|
|
142
|
+
self.glyphs[cp] = g
|
|
143
|
+
return g
|
|
144
|
+
|
|
145
|
+
def build(self) -> Atlas:
|
|
146
|
+
# Snap height up to the next multiple of 8 lest odd heights upset
|
|
147
|
+
# GS transfer alignment.
|
|
148
|
+
h = (self.image.height + 7) & ~7
|
|
149
|
+
if h != self.image.height:
|
|
150
|
+
self._grow(h)
|
|
151
|
+
atlas = Atlas(self.weight, self.size, self.image, dict(self.glyphs))
|
|
152
|
+
atlas.line_ascent = self.ascent
|
|
153
|
+
atlas.line_descent = self.descent
|
|
154
|
+
return atlas
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Runtime limits the bake has to know about (backlog B10).
|
|
2
|
+
|
|
3
|
+
Originally four table-count ceilings that ps2ui_load() rejected with
|
|
4
|
+
PS2UI_ERR_TOO_MANY. Three of them -- textures, slots, screens -- no
|
|
5
|
+
longer exist: once the v6 arena made the context size itself from the
|
|
6
|
+
blob, those numbers bounded nothing the blob's own size did not
|
|
7
|
+
already bound, and 16 slots was a real obstacle to a real UI (the UC-3
|
|
8
|
+
scoping fixture measures 28 on one OPL-class screen). The runtime now
|
|
9
|
+
refuses on arithmetic it cannot do rather than on a number ps2ui.h
|
|
10
|
+
picked; see arena_compute.
|
|
11
|
+
|
|
12
|
+
What remains is PS2UI_MAX_SCISSOR_DEPTH, which is genuine fixed
|
|
13
|
+
storage: ps2ui_render keeps a scissor stack that deep, and a blob
|
|
14
|
+
nesting `overflow: hidden` past it draws the inner subtree under the
|
|
15
|
+
outer clip. It fails soft, on a television, so the bake refuses it
|
|
16
|
+
here instead.
|
|
17
|
+
|
|
18
|
+
The value is parsed out of runtime/ps2ui.h when it is reachable, so
|
|
19
|
+
raising it in the header raises it here too. FALLBACK covers the
|
|
20
|
+
pip-installed case where the runtime source is absent;
|
|
21
|
+
test_caps_match_header proves the two agree.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
FALLBACK = {
|
|
28
|
+
# The last one. It bounds how deep `overflow: hidden` may nest
|
|
29
|
+
# before ps2ui_render runs out of scissor stack -- real storage,
|
|
30
|
+
# unlike the table counts that used to sit beside it. The regex
|
|
31
|
+
# already matched it; without the key here, caps.update silently
|
|
32
|
+
# dropped it and nothing checked the depth at all.
|
|
33
|
+
"PS2UI_MAX_SCISSOR_DEPTH": 8,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_DEFINE = re.compile(r"^#define\s+(PS2UI_MAX_\w+)\s+(\d+)", re.M)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def header_path() -> str:
|
|
40
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
41
|
+
return os.path.normpath(os.path.join(here, "..", "..", "..", "runtime", "ps2ui.h"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def parse_header(path: str = None) -> dict:
|
|
45
|
+
"""Caps from ps2ui.h, or FALLBACK when the header is not reachable."""
|
|
46
|
+
path = path or header_path()
|
|
47
|
+
try:
|
|
48
|
+
with open(path, encoding="utf-8") as fh:
|
|
49
|
+
src = fh.read()
|
|
50
|
+
except OSError:
|
|
51
|
+
return dict(FALLBACK)
|
|
52
|
+
found = {m.group(1): int(m.group(2)) for m in _DEFINE.finditer(src)}
|
|
53
|
+
caps = dict(FALLBACK)
|
|
54
|
+
caps.update({k: v for k, v in found.items() if k in FALLBACK})
|
|
55
|
+
return caps
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def max_scissor_depth(records) -> int:
|
|
59
|
+
"""Deepest SCISSOR_PUSH nesting in a command list.
|
|
60
|
+
|
|
61
|
+
The runtime keeps a fixed stack and cannot report an overflow. It
|
|
62
|
+
fails soft — the too-deep subtree draws under the enclosing clip —
|
|
63
|
+
but "your dialog is not clipped" is a poor thing to discover on a
|
|
64
|
+
television, so the bake refuses it instead.
|
|
65
|
+
"""
|
|
66
|
+
from .quads import OP_SCISSOR_POP, OP_SCISSOR_PUSH
|
|
67
|
+
depth = peak = 0
|
|
68
|
+
for rec in records:
|
|
69
|
+
if rec.op == OP_SCISSOR_PUSH:
|
|
70
|
+
depth += 1
|
|
71
|
+
peak = max(peak, depth)
|
|
72
|
+
elif rec.op == OP_SCISSOR_POP and depth > 0:
|
|
73
|
+
depth -= 1
|
|
74
|
+
return peak
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check(textures, cluts, slots, screens, caps: dict = None, records=None):
|
|
78
|
+
"""Returns (errors, caps). Each error names the cap, the value, and
|
|
79
|
+
the header constant to raise if the limit is the wrong one."""
|
|
80
|
+
caps = caps or parse_header()
|
|
81
|
+
errors = []
|
|
82
|
+
|
|
83
|
+
if records is not None:
|
|
84
|
+
# >= because the runtime's guard is `depth + 1 >= MAX`, so the
|
|
85
|
+
# last usable slot is MAX - 1.
|
|
86
|
+
peak = max_scissor_depth(records)
|
|
87
|
+
limit = caps["PS2UI_MAX_SCISSOR_DEPTH"]
|
|
88
|
+
if peak >= limit:
|
|
89
|
+
errors.append(
|
|
90
|
+
f"scissor nesting: {peak} levels reaches "
|
|
91
|
+
f"PS2UI_MAX_SCISSOR_DEPTH = {limit}. ps2ui_render has a "
|
|
92
|
+
f"fixed stack and cannot report an overflow, so the "
|
|
93
|
+
f"deepest subtree would draw under its parent's clip "
|
|
94
|
+
f"instead of its own. Flatten the nesting or raise "
|
|
95
|
+
f"PS2UI_MAX_SCISSOR_DEPTH in runtime/ps2ui.h."
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Textures, slots and screens used to be checked against ceilings
|
|
99
|
+
# here. They are not bounded by a number any more -- only by the
|
|
100
|
+
# format's own uint16 count fields, which the writer below cannot
|
|
101
|
+
# exceed without failing to pack the header at all. What replaced
|
|
102
|
+
# the ceilings is an arena the runtime refuses to carve if it does
|
|
103
|
+
# not fit the target's address space, and the bake already reports
|
|
104
|
+
# that arena in bytes, which is the number an integrator can act on.
|
|
105
|
+
for what, count in (("textures", len(textures)), ("slots", len(slots)),
|
|
106
|
+
("screens", len(screens)), ("cluts", len(cluts))):
|
|
107
|
+
if count > 0xFFFF:
|
|
108
|
+
errors.append(
|
|
109
|
+
f"{what}: {count} does not fit the format's uint16 count "
|
|
110
|
+
f"field. This is the format's own limit, not a runtime one."
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Capacity used to be checked against PS2UI_SLOT_BUFSZ, the
|
|
114
|
+
# runtime's fixed per-slot buffer. There is no such buffer any more
|
|
115
|
+
# (v6 resource model): the runtime sizes each slot's storage from
|
|
116
|
+
# the capacity declared here, so a large capacity costs arena bytes
|
|
117
|
+
# rather than being unloadable. The remaining bound is the format's
|
|
118
|
+
# own -- capacity is a uint16 field.
|
|
119
|
+
for sl in slots:
|
|
120
|
+
if sl["capacity"] > 0xFFFF:
|
|
121
|
+
errors.append(
|
|
122
|
+
f"slot {sl['name']!r}: capacity {sl['capacity']} does not fit "
|
|
123
|
+
f"the format's uint16 capacity field."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return errors, caps
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def summary(textures, cluts, slots, screens, caps: dict) -> str:
|
|
130
|
+
# Counts, not fractions. "15/16 slots" was a useful line while the
|
|
131
|
+
# denominator was a wall you could hit; printing a fraction of
|
|
132
|
+
# 65535 would just be a number with a decorative second half, and
|
|
133
|
+
# the figure that actually constrains a UI now -- the arena -- is
|
|
134
|
+
# printed on its own line by cli.py.
|
|
135
|
+
del caps
|
|
136
|
+
return (
|
|
137
|
+
f" runtime tables: {len(textures)} textures, {len(cluts)} CLUTs, "
|
|
138
|
+
f"{len(slots)} slots, {len(screens)} screens"
|
|
139
|
+
)
|