Add bundled bus fonts and shared font favorites
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
from io import BytesIO
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from fontTools.ttLib import TTFont
|
||||
from PIL import Image
|
||||
import pytest
|
||||
|
||||
from app.display.text_renderer import TextOptions, render_text_layer
|
||||
from app.fonts.store import FontCatalog, FontValidationError
|
||||
from app.main import create_app
|
||||
from app.templates.store import TemplateStore, prepare_scene, render_scene_image
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
from build_bus_font import build, gb2312_codepoints
|
||||
|
||||
ID = "builtin:walnut-bus-8x16"
|
||||
BUNDLE = ROOT / "app/fonts/bundled"
|
||||
|
||||
|
||||
def test_reproducible_font_and_coverage(tmp_path):
|
||||
manifest = build(tmp_path / "build")
|
||||
assert manifest == json.loads((BUNDLE / "manifest.json").read_text(encoding="utf-8"))
|
||||
for item in manifest["fonts"]:
|
||||
assert (tmp_path / "build" / item["file"]).read_bytes() == (BUNDLE / item["file"]).read_bytes()
|
||||
with TTFont(BUNDLE / item["file"]) as font:
|
||||
cmap = font.getBestCmap()
|
||||
assert gb2312_codepoints() <= cmap.keys()
|
||||
assert len([cp for cp in cmap if 0x4E00 <= cp <= 0x9FFF]) == 6763
|
||||
assert all(font["hmtx"][name][0] == 512 for name in cmap.values())
|
||||
assert font["name"].getBestFamilyName() == item["label"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width,height", [(8,16), (16,32)])
|
||||
def test_all_glyphs_binary_pixels_and_cell_bounds(tmp_path, width, height):
|
||||
catalog = FontCatalog(tmp_path)
|
||||
source = ROOT / f"font_sources/walnut-bus-{width}x{height}"
|
||||
masters = json.loads((source / "glyphs.json").read_text(encoding="utf-8"))
|
||||
revisions = json.loads((source / "revisions.json").read_text(encoding="utf-8"))
|
||||
for cp in sorted(gb2312_codepoints()):
|
||||
image = render_text_layer(TextOptions(chr(cp), font=f"builtin:walnut-bus-{width}x{height}", size=height),
|
||||
size=(width + 8, height + 4), font_resolver=catalog.resolve)
|
||||
alpha = image.getchannel("A")
|
||||
assert set(alpha.tobytes()) <= {0, 255}, f"U+{cp:04X} grey"
|
||||
if cp not in (32, 0x3000):
|
||||
assert alpha.getbbox(), f"U+{cp:04X} blank"
|
||||
assert not alpha.crop((width - 1, 0, width + 8, height + 4)).getbbox(), f"U+{cp:04X} exceeds ink columns"
|
||||
assert not alpha.crop((0, height, width + 8, height + 4)).getbbox()
|
||||
key = f"{cp:04X}"
|
||||
rows = revisions[key]["rows"] if key in revisions else masters[key]
|
||||
expected = bytes(255 if int(row, 16) & (1 << (width - 1 - x)) else 0 for row in rows for x in range(width))
|
||||
assert alpha.crop((0, 0, width, height)).tobytes() == expected, f"U+{cp:04X} differs from master"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("height,factor", [(16,2), (16,3), (16,4), (32,2)])
|
||||
def test_integer_scaling(height, factor, tmp_path):
|
||||
catalog = FontCatalog(tmp_path)
|
||||
identifier = f"builtin:walnut-bus-{height // 2}x{height}"
|
||||
for char in "高铁路鹤警藏0O1Il5S8B口日目田由甲申回":
|
||||
small = render_text_layer(TextOptions(char, font=identifier, size=height), size=(height // 2, height), font_resolver=catalog.resolve)
|
||||
large = render_text_layer(TextOptions(char, font=identifier, size=height * factor),
|
||||
size=(height // 2 * factor, height * factor), font_resolver=catalog.resolve)
|
||||
assert large.tobytes() == small.resize(large.size, Image.Resampling.NEAREST).tobytes()
|
||||
|
||||
|
||||
def test_catalog_restart_legacy_digest_and_corrupt_preservation(tmp_path):
|
||||
catalog = FontCatalog(tmp_path)
|
||||
registration = json.loads((BUNDLE / "manifest.json").read_text(encoding="utf-8"))
|
||||
manifest = registration["fonts"][0]
|
||||
face = catalog.resolve(ID)[0]
|
||||
original = Path(face.path).read_bytes()
|
||||
entry = next(item for item in catalog.document()["items"] if item["id"] == ID)
|
||||
assert entry["source"] == "system" and "16px" in entry["label"]
|
||||
assert catalog.resolve(f"user:{manifest['sha256']}:0") == (face,)
|
||||
assert catalog.resolve(next(iter(registration["aliases"]))) == (face,)
|
||||
assert FontCatalog(tmp_path).resolve(ID) == (face,)
|
||||
Path(face.path).write_bytes(b"corrupt")
|
||||
with pytest.raises(FontValidationError, match="preserved"):
|
||||
FontCatalog(tmp_path)
|
||||
assert Path(face.path).read_bytes() == b"corrupt"
|
||||
assert hashlib.sha256(original).hexdigest() == manifest["sha256"]
|
||||
|
||||
|
||||
def test_api_preview_placement_alignment_and_import(tmp_path):
|
||||
client = TestClient(create_app(project_root=tmp_path, driver_kind="mock"))
|
||||
assert any(item["id"] == ID for item in client.get("/api/fonts").json()["items"])
|
||||
request = {"text": "高铁北站21路", "font": ID, "size": 16, "x": 0, "y": 0, "color": "#FF0000"}
|
||||
response = client.post("/api/preview/text-layer", json=request)
|
||||
assert response.status_code == 200
|
||||
image = Image.open(BytesIO(response.content)).convert("RGBA")
|
||||
for align, x in [("left", 8), ("center", 36), ("right", 64)]:
|
||||
result = client.post("/api/preview/text-layer", json={**request, "x": x, "y": 16, "align": align})
|
||||
assert result.status_code == 200
|
||||
assert Image.open(BytesIO(result.content)).getchannel("A").getbbox()
|
||||
imported = client.post("/api/fonts/import?filename=bus.ttf", content=(BUNDLE / "SimilarBus8x16.ttf").read_bytes(),
|
||||
headers={"Content-Type": "application/octet-stream"})
|
||||
assert imported.status_code == 200
|
||||
assert imported.json()["fonts"][0]["id"] == ID
|
||||
masks = [render_text_layer(TextOptions(char, font=ID, size=16), size=(8, 16),
|
||||
font_resolver=client.app.state.font_catalog.resolve).getchannel("A").tobytes()
|
||||
for char in "0O1Il5S8B"]
|
||||
assert len(set(masks)) == len(masks)
|
||||
|
||||
|
||||
def test_bus_template_reload_layer_order_clip_and_missing_glyph_fallback(tmp_path):
|
||||
catalog = FontCatalog(tmp_path / "data")
|
||||
legacy = dict(version=1, width=64, height=64,
|
||||
pixelRgb=base64.b64encode(bytes([10, 20, 30]) * 4096).decode("ascii"),
|
||||
elements=[dict(id="bus", type="text", text="高铁北站21路", font=ID,
|
||||
size=16, x=0, y=8, align="left", color="#FF0000")])
|
||||
scene = prepare_scene(legacy)
|
||||
store = TemplateStore(tmp_path / "templates", font_resolver=catalog.resolve)
|
||||
saved = store.create("bus", scene)
|
||||
reloaded = TemplateStore(tmp_path / "templates", font_resolver=FontCatalog(tmp_path / "data").resolve).get(saved["id"])
|
||||
assert reloaded["scene"] == scene
|
||||
full = render_scene_image(scene, font_resolver=catalog.resolve)
|
||||
assert full.getpixel((63, 63)) == (10, 20, 30)
|
||||
scene["layers"][1]["elements"][0]["clip"] = [0, 0, 1, 1]
|
||||
clipped = render_scene_image(scene, font_resolver=catalog.resolve)
|
||||
assert clipped.getpixel((32, 12)) == (10, 20, 30)
|
||||
assert clipped.tobytes() != full.tobytes()
|
||||
from app.display.text_renderer import resolve_font_face
|
||||
try:
|
||||
face = resolve_font_face(ID, "高あ", font_resolver=catalog.resolve)
|
||||
except ValueError as error:
|
||||
assert "Unicode" in str(error) # hosts without a covering fallback fail clearly
|
||||
else:
|
||||
assert face != catalog.resolve(ID)[0] # whole sequence, never mixed fragments
|
||||
Reference in New Issue
Block a user