136 lines
7.1 KiB
Python
136 lines
7.1 KiB
Python
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
|