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
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user