Add bundled bus fonts and shared font favorites

This commit is contained in:
2026-10-12 15:00:40 +08:00
parent c9dd03c23b
commit 4be74a34f9
181 changed files with 2078575 additions and 76 deletions
@@ -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
@@ -0,0 +1,112 @@
from __future__ import annotations
import base64
import hashlib
import json
from pathlib import Path
import pytest
from app.fonts.store import FontCatalog, FontValidationError
from app.templates.store import TemplateStore, prepare_scene
from app.animations.store import AnimationStore
ID = "builtin:walnut-bus-8x16"
def scene(font=ID):
return prepare_scene(dict(version=1, width=64, height=64,
pixelRgb=base64.b64encode(bytes(64*64*3)).decode("ascii"),
elements=[dict(id="text",type="text",text="高铁路",font=font,size=16,x=0,y=8,align="left",color="#FFFFFF")]))
class ChangingFont:
def __init__(self, catalog):
self.catalog, self.version = catalog, 1
def render_version(self, identifier):
return str(self.version)*64 if identifier == ID else None
def resolve(self, identifier):
return self.catalog.resolve(ID if self.version == 1 else "builtin:walnut-bus-16x32")
def test_template_cache_rebuilt_without_revision_or_record_change(tmp_path):
owner=ChangingFont(FontCatalog(tmp_path))
store=TemplateStore(tmp_path, font_resolver=owner.resolve)
item=store.create("bus",scene())
path=store.thumbnail_path(item["id"])
record=store._record_path(item["id"])
before_bytes, before_record = path.read_bytes(), record.read_bytes()
assert "font_v=" in item["thumbnail_url"]
owner.version=2
restarted=TemplateStore(tmp_path,font_resolver=owner.resolve)
updated=restarted.list()["templates"][0]
assert updated["revision"] == item["revision"]
assert record.read_bytes() == before_record
assert path.read_bytes() != before_bytes
assert updated["thumbnail_url"] != item["thumbnail_url"]
stable=path.stat().st_mtime_ns
TemplateStore(tmp_path,font_resolver=owner.resolve)
assert path.stat().st_mtime_ns == stable
def test_animation_cache_rebuilt_without_scene_or_revision_change(tmp_path):
owner=ChangingFont(FontCatalog(tmp_path))
store=AnimationStore(tmp_path,font_resolver=owner.resolve)
item=store.create_from_frames("bus",[dict(scene=scene(),duration_ms=100)])
frame=store.get(item["id"])["frames"][0]
stored_frame=store._read(item["id"])["frames"][0]
path=store._thumb_dir(item["id"])/stored_frame["thumbnail"]
record=store._path(item["id"])
before_bytes, before_record = path.read_bytes(), record.read_bytes()
owner.version=2
restarted=AnimationStore(tmp_path,font_resolver=owner.resolve)
updated=restarted.get(item["id"])
assert updated["revision"] == item["revision"]
assert record.read_bytes() == before_record
assert path.read_bytes() != before_bytes
assert updated["frames"][0]["thumbnail_url"] != frame["thumbnail_url"]
stable=path.stat().st_mtime_ns
AnimationStore(tmp_path,font_resolver=owner.resolve)
assert path.stat().st_mtime_ns == stable
def test_unrelated_thumbnails_untouched(tmp_path):
owner=ChangingFont(FontCatalog(tmp_path))
store=TemplateStore(tmp_path,font_resolver=owner.resolve)
item=store.create("normal",scene("default"))
path=store.thumbnail_path(item["id"])
stable=path.read_bytes(),path.stat().st_mtime_ns
assert "font_v=" not in item["thumbnail_url"]
owner.version=2
TemplateStore(tmp_path,font_resolver=owner.resolve)
assert (path.read_bytes(),path.stat().st_mtime_ns) == stable
def test_exact_obsolete_retirement_and_corruption_preservation(tmp_path, monkeypatch):
import app.fonts.store as module
bundle=Path(module.__file__).parent/'bundled'
registration=json.loads((bundle/'manifest.json').read_text(encoding='utf-8'))
old_payload=b'old-font-fixture'
old_digest=hashlib.sha256(old_payload).hexdigest()
registration['obsolete']=[old_digest]
registration['aliases']={f'user:{old_digest}:0':ID}
# Substitute only the registration reader, retaining real bundled font
# files and checksum validation; fixture files never need old private state.
real_read=Path.read_text
def read(path,*args,**kwargs):
if path == bundle/'manifest.json':return json.dumps(registration)
return real_read(path,*args,**kwargs)
monkeypatch.setattr(Path,'read_text',read)
root=tmp_path/'fonts';root.mkdir()
old=root/f'{old_digest}.font';old.write_bytes(old_payload)
unrelated=root/'unregistered.font';unrelated.write_bytes(b'keep')
catalog=FontCatalog(tmp_path)
assert not old.exists()
assert unrelated.read_bytes() == b'keep'
assert catalog.resolve(f'user:{old_digest}:0') == catalog.resolve(ID)
old.write_bytes(b'corrupt')
with pytest.raises(FontValidationError,match='preserved'):
FontCatalog(tmp_path)
assert old.read_bytes() == b'corrupt'
@@ -0,0 +1,81 @@
from concurrent.futures import ThreadPoolExecutor
import json
import pytest
from fastapi.testclient import TestClient
from app.fonts.store import FontCatalog, FontValidationError
from app.main import create_app
def test_api_validation_and_persistence(tmp_path):
app = create_app(project_root=tmp_path, driver_kind="mock")
client = TestClient(app)
items = client.get("/api/fonts").json()["items"]
assert client.get("/api/fonts").json()["favorite_ids"] == []
ids = [item["id"] for item in items[:3]]
for identifier in ids:
assert client.put("/api/fonts/favorite", json={"id": identifier, "favorite": True}).status_code == 200
path = app.state.font_catalog.root / "favorites.json"
before = path.read_bytes()
client.put("/api/fonts/favorite", json={"id": ids[0], "favorite": True})
assert path.read_bytes() == before
assert FontCatalog(path.parent.parent).document()["favorite_ids"] == ids
for payload in ({"id": "default", "favorite": "true"}, {"id": "default", "favorite": 1}, {"id": "", "favorite": True}, {"id": "default", "favorite": True, "extra": 1}):
assert client.put("/api/fonts/favorite", json=payload).status_code == 422
assert client.put("/api/fonts/favorite", json={"id": "missing", "favorite": True}).status_code == 404
assert client.put("/api/fonts/favorite", json={"id": "missing", "favorite": False}).status_code == 200
client.put("/api/fonts/favorite", json={"id": ids[0], "favorite": False})
assert client.get("/api/fonts").json()["favorite_ids"] == ids[1:]
def test_concurrent_merge_and_write_failure(tmp_path, monkeypatch):
catalog = FontCatalog(tmp_path)
ids = [item["id"] for item in catalog.document()["items"][:3]]
with ThreadPoolExecutor(max_workers=3) as pool:
list(pool.map(lambda identifier: catalog.set_favorite(identifier, True), ids))
assert set(catalog.document()["favorite_ids"]) == set(ids)
before = (catalog.root / "favorites.json").read_bytes()
def fail(*args):
raise OSError("injected write failure")
monkeypatch.setattr("app.fonts.store.atomic_write_bytes", fail)
with pytest.raises(OSError):
catalog.set_favorite(ids[0], False)
assert set(catalog.document()["favorite_ids"]) == set(ids)
assert (catalog.root / "favorites.json").read_bytes() == before
@pytest.mark.parametrize("content", [b"broken", b'{"schema_version":2,"favorite_ids":[]}', b'{"schema_version":true,"favorite_ids":[]}', b'{"schema_version":1,"favorite_ids":["x","x"]}', b'{"schema_version":1,"favorite_ids":[],"extra":1}'])
def test_invalid_file_preserved(tmp_path, content):
(tmp_path / "fonts").mkdir()
path = tmp_path / "fonts/favorites.json"
path.write_bytes(content)
with pytest.raises(FontValidationError):
FontCatalog(tmp_path)
assert path.read_bytes() == content
def test_stale_ids_preserved_and_removable(tmp_path):
catalog = FontCatalog(tmp_path)
path = catalog.root / "favorites.json"
path.write_text(json.dumps({"schema_version": 1, "favorite_ids": ["stale"]}), encoding="utf-8")
catalog = FontCatalog(tmp_path)
assert catalog.document()["favorite_ids"] == ["stale"]
assert not any(item["id"] == "stale" for item in catalog.document()["items"])
assert catalog.set_favorite("stale", False) == []
def test_same_family_styles_are_independent(tmp_path):
from test_fonts import build_test_font
app = create_app(project_root=tmp_path, driver_kind="mock")
client = TestClient(app)
ids = []
for style in ["Bold", "Italic"]:
response = client.post("/api/fonts/import?filename=style.ttf", content=build_test_font("Same Family", style), headers={"Content-Type": "application/octet-stream"})
assert response.status_code == 201
ids.append(response.json()["fonts"][0]["id"])
client.put("/api/fonts/favorite", json={"id": ids[0], "favorite": True})
assert client.get("/api/fonts").json()["favorite_ids"] == [ids[0]]
client.put("/api/fonts/favorite", json={"id": ids[1], "favorite": True})
client.put("/api/fonts/favorite", json={"id": ids[0], "favorite": False})
assert client.get("/api/fonts").json()["favorite_ids"] == [ids[1]]
@@ -21,7 +21,7 @@ test("font catalog normalization, grouping, and search are stable", () => {
{ id: "bad", source: "unknown" },
]);
assert.deepEqual(items.map((item) => item.id), ["default", "user:abc:0", "system:def"]);
assert.deepEqual(groupFontItems(items).map((group) => group.label), ["自动字体", "已导入字体", "系统字体"]);
assert.deepEqual(groupFontItems(items).map((group) => group.label), ["收藏字体", "自动字体", "已导入字体", "系统字体"]);
assert.deepEqual(filterFontItems(items, "宋体").map((item) => item.id), ["user:abc:0"]);
assert.deepEqual(filterFontItems(items, "bold").map((item) => item.id), ["system:def"]);
assert.equal(filterFontItems(items, "没有").length, 0);
@@ -39,3 +39,16 @@ test("font picker implements commit-only searchable combobox keyboard behavior",
assert.match(source, /onCommit\?\.\(item\)/);
assert.doesNotMatch(source, /onCommit\?\.\([^)]*query/);
});
test("favorites duplicate the original font faces and preserve catalog order", () => {
const items = normalizeFontItems([
{ id: "default", source: "automatic" },
{ id: "bold", family: "Same", style: "Bold", source: "system" },
{ id: "italic", family: "Same", style: "Italic", source: "system" },
]);
const groups = groupFontItems(items, ["italic", "bold", "missing"]);
assert.deepEqual(groups[0].items.map(item => item.id), ["bold", "italic"]);
assert.deepEqual(groups[2].items.map(item => item.id), ["bold", "italic"]);
assert.equal(groupFontItems(items)[0].items.length, 0);
assert.deepEqual(groupFontItems(filterFontItems(items, "Italic"), ["italic"])[0].items.map(item => item.id), ["italic"]);
});
+4 -4
View File
@@ -89,8 +89,8 @@ def test_font_catalog_import_duplicate_restart_and_render(tmp_path: Path) -> Non
assert str(tmp_path) not in created.text
stored = list((tmp_path / "data" / "fonts").glob("*.font"))
assert len(stored) == 1
assert stored[0].stem in imported["id"]
assert len(stored) == 3 # imported test face plus two bundled bus fonts
assert any(path.stem in imported["id"] for path in stored)
duplicate = client.post(
"/api/fonts/import?filename=renamed.otf",
@@ -99,7 +99,7 @@ def test_font_catalog_import_duplicate_restart_and_render(tmp_path: Path) -> Non
)
assert duplicate.status_code == 200
assert duplicate.json() == {"created": False, "fonts": [imported]}
assert len(list((tmp_path / "data" / "fonts").glob("*.font"))) == 1
assert len(list((tmp_path / "data" / "fonts").glob("*.font"))) == 3
preview = client.post("/api/preview/text-layer", json={
"text": "AB",
@@ -163,7 +163,7 @@ def test_font_import_rejects_type_content_and_size_without_residue(tmp_path: Pat
)
assert oversized.status_code == 413
assert not list(root.glob(".upload-*.tmp"))
assert not list(root.glob("*.font"))
assert len(list(root.glob("*.font"))) == 2 # bundled bus fonts are preserved
def test_invalid_persisted_font_is_preserved_and_reported(tmp_path: Path) -> None: