from __future__ import annotations from io import BytesIO from pathlib import Path from fastapi.testclient import TestClient from fontTools.fontBuilder import FontBuilder from fontTools.pens.ttGlyphPen import TTGlyphPen from PIL import Image from app.fonts.store import MAX_FONT_UPLOAD_BYTES, FontCatalog from app.main import create_app def build_test_font(family: str = "Codex Test Sans", style: str = "Regular") -> bytes: builder = FontBuilder(1000, isTTF=True) glyph_order = [".notdef", "space", "A", "B"] builder.setupGlyphOrder(glyph_order) glyphs = {} empty = TTGlyphPen(None) glyphs[".notdef"] = empty.glyph() glyphs["space"] = TTGlyphPen(None).glyph() for name, inset in (("A", 100), ("B", 180)): pen = TTGlyphPen(None) pen.moveTo((inset, 0)) pen.lineTo((800 - inset, 0)) pen.lineTo((800 - inset, 800)) pen.lineTo((inset, 800)) pen.closePath() glyphs[name] = pen.glyph() builder.setupGlyf(glyphs) builder.setupHorizontalMetrics({name: (900, 0) for name in glyph_order}) builder.setupHorizontalHeader(ascent=800, descent=-200) builder.setupCharacterMap({32: "space", 65: "A", 66: "B"}) builder.setupNameTable({ "familyName": family, "styleName": style, "uniqueFontIdentifier": f"{family}-{style}", "fullName": f"{family} {style}", "psName": f"{family.replace(' ', '')}-{style.replace(' ', '')}", }) builder.setupOS2( sTypoAscender=800, sTypoDescender=-200, usWinAscent=800, usWinDescent=200, ) builder.setupPost() builder.setupMaxp() output = BytesIO() builder.save(output) return output.getvalue() def test_font_catalog_import_duplicate_restart_and_render(tmp_path: Path) -> None: font_bytes = build_test_font() app = create_app(project_root=tmp_path, driver_kind="mock") client = TestClient(app) initial = client.get("/api/fonts") assert initial.status_code == 200 document = initial.json() assert document["items"][0] == { "id": "default", "label": "自动多语言字体", "family": "自动多语言字体", "style": "自动回退", "source": "automatic", } assert document["accepted_extensions"] == [".ttf", ".otf", ".ttc", ".otc"] assert document["max_upload_bytes"] == MAX_FONT_UPLOAD_BYTES assert str(tmp_path) not in initial.text created = client.post( "/api/fonts/import?filename=Codex%20Test.ttf", content=font_bytes, headers={"Content-Type": "application/octet-stream"}, ) assert created.status_code == 201 payload = created.json() assert payload["created"] is True assert len(payload["fonts"]) == 1 imported = payload["fonts"][0] assert imported["id"].startswith("user:") assert imported["family"] == "Codex Test Sans" assert imported["source"] == "imported" 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"] duplicate = client.post( "/api/fonts/import?filename=renamed.otf", content=font_bytes, headers={"Content-Type": "application/octet-stream"}, ) assert duplicate.status_code == 200 assert duplicate.json() == {"created": False, "fonts": [imported]} assert len(list((tmp_path / "data" / "fonts").glob("*.font"))) == 1 preview = client.post("/api/preview/text-layer", json={ "text": "AB", "font": imported["id"], "size": 18, "x": 0, "y": 0, "align": "left", "color": "#FFFFFF", }) assert preview.status_code == 200 assert Image.open(BytesIO(preview.content)).getchannel("A").getbbox() is not None restarted = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) items = restarted.get("/api/fonts").json()["items"] assert imported in items restarted_preview = restarted.post("/api/preview/text-layer", json={ "text": "A", "font": imported["id"], "size": 18, "x": 0, "y": 0, "align": "left", "color": "#FFFFFF", }) assert restarted_preview.status_code == 200 def test_font_import_rejects_type_content_and_size_without_residue(tmp_path: Path) -> None: client = TestClient(create_app(project_root=tmp_path, driver_kind="mock")) root = tmp_path / "data" / "fonts" wrong_content_type = client.post( "/api/fonts/import?filename=test.ttf", content=b"not-a-font", headers={"Content-Type": "text/plain"}, ) assert wrong_content_type.status_code == 415 wrong_extension = client.post( "/api/fonts/import?filename=test.woff2", content=b"not-a-font", headers={"Content-Type": "application/octet-stream"}, ) assert wrong_extension.status_code == 415 damaged = client.post( "/api/fonts/import?filename=test.ttf", content=b"not-a-font", headers={"Content-Type": "application/octet-stream"}, ) assert damaged.status_code == 422 oversized = client.post( "/api/fonts/import?filename=test.ttf", content=b"x", headers={ "Content-Type": "application/octet-stream", "Content-Length": str(MAX_FONT_UPLOAD_BYTES + 1), }, ) assert oversized.status_code == 413 assert not list(root.glob(".upload-*.tmp")) assert not list(root.glob("*.font")) def test_invalid_persisted_font_is_preserved_and_reported(tmp_path: Path) -> None: root = tmp_path / "data" / "fonts" root.mkdir(parents=True) damaged = root / f"{'a' * 64}.font" damaged.write_bytes(b"damaged") catalog = FontCatalog(tmp_path / "data") document = catalog.document() assert damaged.read_bytes() == b"damaged" assert document["warnings"] == [f"已保留但忽略损坏字体:{damaged.name}"] assert all(item["source"] != "imported" for item in document["items"])