Add bundled bus fonts and shared font favorites
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""Offline build of two independent bus fonts from Unicode bitmap masters."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from fontTools.fontBuilder import FontBuilder
|
||||
from fontTools.pens.ttGlyphPen import TTGlyphPen
|
||||
from bus_font_design import parse_bdf, generate_large, improve_small
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / "font_sources/walnut-bus-8x16"
|
||||
OUTPUT = ROOT / "app/fonts/bundled"
|
||||
LARGE_SOURCE = ROOT / "font_sources/walnut-bus-16x32"
|
||||
OLD_DIGEST = "d38bf29d6408a6f731865a1ca760a80b5156ebf6ab96d5ad8d649df28163564c"
|
||||
|
||||
|
||||
def gb2312_codepoints() -> set[int]:
|
||||
result = set(range(32, 127))
|
||||
for first in range(0xA1, 0xF8):
|
||||
for second in range(0xA1, 0xFF):
|
||||
try:
|
||||
cp = ord(bytes((first, second)).decode("gb2312"))
|
||||
if 0x4E00 <= cp <= 0x9FFF:
|
||||
result.add(cp)
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
result.update(map(ord, " ,。!?;:、‘’“”()《》【】…—¥℃→←↑↓"))
|
||||
return result
|
||||
|
||||
|
||||
def elongate(rows: list[int]) -> list[int]:
|
||||
"""Keep horizontal stems thin; extend vertical/diagonal stems between rows."""
|
||||
output = [0] * 16
|
||||
for y, row in enumerate(rows):
|
||||
output[2 * y] = row
|
||||
if y < 7:
|
||||
following = rows[y + 1]
|
||||
isolated = row & ~((row << 1) | (row >> 1))
|
||||
output[2 * y + 1] = (row & following) | (isolated & ((following << 1) | (following >> 1)))
|
||||
return output
|
||||
|
||||
|
||||
def seed() -> None:
|
||||
upstream = SOURCE / "upstream/fusion-pixel-8px-monospaced-zh_hans.bdf"
|
||||
original = parse_bdf(upstream)
|
||||
required = gb2312_codepoints()
|
||||
missing = sorted(required - original.keys())
|
||||
if missing:
|
||||
raise ValueError(f"upstream missing {len(missing)} target codepoints: {missing[:12]}")
|
||||
glyphs = {f"{cp:04X}": [f"{row:02X}" for row in elongate(original[cp])] for cp in sorted(required)}
|
||||
(SOURCE / "glyphs.json").write_text(json.dumps(glyphs, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def build_face(source: Path, width: int, height: int, output: Path) -> dict:
|
||||
masters = json.loads((source / "glyphs.json").read_text(encoding="utf-8"))
|
||||
overrides = json.loads((source / "revisions.json").read_text(encoding="utf-8"))
|
||||
for key, revision in overrides.items():
|
||||
masters[key] = revision["rows"]
|
||||
required = gb2312_codepoints()
|
||||
if not required <= {int(key, 16) for key in masters}:
|
||||
raise ValueError("incomplete GB2312/ASCII masters")
|
||||
builder = FontBuilder(1024, isTTF=True)
|
||||
order = [".notdef"] + [f"uni{key}" for key in sorted(masters)]
|
||||
builder.setupGlyphOrder(order)
|
||||
glyphs = {}
|
||||
for name in order:
|
||||
pen = TTGlyphPen(None)
|
||||
rows = [0] * height if name == ".notdef" else [int(row, 16) for row in masters[name[3:]]]
|
||||
if len(rows) != height or any(row < 0 or row >= 1 << width for row in rows):
|
||||
raise ValueError(f"invalid bitmap {name}")
|
||||
# Cancel shared cell edges, then trace the union boundary. Separate
|
||||
# touching rectangles produce 192-alpha seams in FreeType even on-grid.
|
||||
edges = set()
|
||||
for y, row in enumerate(rows):
|
||||
for x in range(width):
|
||||
if not row & (1 << (width - 1 - x)):
|
||||
continue
|
||||
step = 1024 // height
|
||||
left, right = x * step, (x + 1) * step
|
||||
bottom, top = (height - 1 - y) * step, (height - y) * step
|
||||
points = [(left, bottom), (left, top), (right, top), (right, bottom)]
|
||||
for a, b in zip(points, points[1:] + points[:1]):
|
||||
if (b, a) in edges:
|
||||
edges.remove((b, a))
|
||||
else:
|
||||
edges.add((a, b))
|
||||
outgoing = {}
|
||||
for a, b in edges:
|
||||
outgoing.setdefault(a, set()).add(b)
|
||||
while edges:
|
||||
a, b = min(edges)
|
||||
edges.remove((a, b))
|
||||
outgoing[a].remove(b)
|
||||
start = a
|
||||
pen.moveTo(a)
|
||||
while b != start:
|
||||
pen.lineTo(b)
|
||||
candidates = [(b, end) for end in sorted(outgoing[b])]
|
||||
if not candidates:
|
||||
raise ValueError(f"open outline {name}")
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
def turn(edge):
|
||||
nx, ny = edge[1][0] - b[0], edge[1][1] - b[1]
|
||||
cross = dx * ny - dy * nx
|
||||
return 0 if cross < 0 else 1 if dx * nx + dy * ny > 0 else 2
|
||||
a, b = min(candidates, key=turn)
|
||||
edges.remove((a, b))
|
||||
outgoing[a].remove(b)
|
||||
pen.closePath()
|
||||
glyphs[name] = pen.glyph()
|
||||
builder.setupGlyf(glyphs)
|
||||
builder.setupHorizontalMetrics({name: (512, getattr(glyphs[name], "xMin", 0)) for name in order})
|
||||
builder.setupHorizontalHeader(ascent=1024, descent=0, lineGap=0)
|
||||
builder.setupCharacterMap({int(key, 16): f"uni{key}" for key in masters})
|
||||
builder.setupNameTable({
|
||||
"familyName": f"类似公交窄体(推荐{height}px)", "styleName": "Regular",
|
||||
"uniqueFontIdentifier": f"SimilarBus{width}x{height}-2.0", "fullName": f"类似公交窄体(推荐{height}px)",
|
||||
"psName": f"SimilarBus{width}x{height}-Regular", "version": "Version 2.000",
|
||||
"copyright": ("Derived from GNU Unifont 18.0.01: Copyright (C) 1998-2026 Roman Czyborra, Paul Hardy, Qianqian Fang, Andrew Miller, Johnnie Weaver, David Corbett, Aella Chiana Moskopp, Rebecca Bettencourt, Minseo Lee, Ho-Seok Ee, et al.; Latin skeletons derived from project bus font."
|
||||
if height == 32 else "Derived from Fusion Pixel Font; see accompanying OFL and upstream licenses."),
|
||||
"licenseDescription": "SIL Open Font License, Version 1.1",
|
||||
"licenseInfoURL": "https://openfontlicense.org/",
|
||||
})
|
||||
builder.setupOS2(sTypoAscender=1024, sTypoDescender=0, sTypoLineGap=0,
|
||||
usWinAscent=1024, usWinDescent=0, fsType=0)
|
||||
builder.setupPost()
|
||||
builder.setupMaxp()
|
||||
builder.font.recalcTimestamp = False
|
||||
builder.font["head"].created = builder.font["head"].modified = 3864153600
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
target = output / f"SimilarBus{width}x{height}.ttf"
|
||||
builder.save(target)
|
||||
digest = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
manifest = {"id": f"builtin:walnut-bus-{width}x{height}", "file": target.name, "sha256": digest,
|
||||
"label": f"类似公交窄体(推荐{height}px)", "family": f"类似公交窄体(推荐{height}px)", "style": "Regular",
|
||||
"glyph_count": len(masters), "recommended_size": height, "cell": [width, height]}
|
||||
return manifest
|
||||
|
||||
|
||||
def build(output: Path = OUTPUT) -> dict:
|
||||
fonts = [build_face(SOURCE, 8, 16, output), build_face(LARGE_SOURCE, 16, 32, output)]
|
||||
manifest = {"version": 2, "fonts": fonts,
|
||||
"obsolete": [OLD_DIGEST], "aliases": {f"user:{OLD_DIGEST}:0": fonts[0]["id"]}}
|
||||
(output / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return manifest
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--seed", action="store_true", help="regenerate initial masters (discards master edits)")
|
||||
parser.add_argument("--design", action="store_true", help="generate structure-specific masters/overrides for both faces")
|
||||
parser.add_argument("--output", type=Path, default=OUTPUT)
|
||||
args = parser.parse_args()
|
||||
if args.seed:
|
||||
seed()
|
||||
if args.design:
|
||||
improve_small(SOURCE)
|
||||
generate_large(LARGE_SOURCE, SOURCE, gb2312_codepoints())
|
||||
print(json.dumps(build(args.output), ensure_ascii=False))
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Structure-specific grid design; no runtime rasterizer changes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
CLASSES = {
|
||||
"enclosure": "口日目田由甲申回国园圈围",
|
||||
"left_right": "站铁路湖树谢桥城明林河村",
|
||||
"stacked": "高花草重量喜音意章营售票",
|
||||
"diagonal": "人入大文交永木水火公交父",
|
||||
"dense": "鹤警藏赢鬻醐圜鲴醴曲瞒圈",
|
||||
"latin": "0O1Il5S8B2Z6G9gMWmw",
|
||||
}
|
||||
|
||||
|
||||
def parse_bdf(path: Path, width: int = 8, height: int = 8, ascent: int = 7) -> dict[int, list[int]]:
|
||||
result = {}
|
||||
codepoint = 0
|
||||
bounds = (0, 0, 0, 0)
|
||||
bitmap = False
|
||||
rows = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("ENCODING "):
|
||||
codepoint = int(line.split()[1])
|
||||
elif line.startswith("BBX "):
|
||||
bounds = tuple(map(int, line.split()[1:]))
|
||||
elif line == "BITMAP":
|
||||
rows, bitmap = [], True
|
||||
elif line == "ENDCHAR":
|
||||
bw, bh, bx, by = bounds
|
||||
grid = [0] * height
|
||||
top = ascent - bh - by
|
||||
for y, row in enumerate(rows):
|
||||
padding = ((bw + 7) // 8) * 8 - width + bx
|
||||
value = row >> padding if padding >= 0 else row << -padding
|
||||
if not 0 <= top + y < height or value >= 1 << width:
|
||||
raise ValueError(f"BDF outside {width}x{height}: U+{codepoint:04X}")
|
||||
grid[top + y] = value
|
||||
result[codepoint] = grid
|
||||
bitmap = False
|
||||
elif bitmap:
|
||||
rows.append(int(line, 16))
|
||||
return result
|
||||
|
||||
|
||||
def pixel_rows(grid):
|
||||
return [sum((1 << (len(row) - 1 - x)) for x, bit in enumerate(row) if bit) for row in grid]
|
||||
|
||||
|
||||
def grid_rows(rows, width):
|
||||
return [[bool(row & (1 << (width - 1 - x))) for x in range(width)] for row in rows]
|
||||
|
||||
|
||||
def design_large(rows, *, dense=False, diagonal=False):
|
||||
native = grid_rows(rows, 16)
|
||||
out = [[False] * 16 for _ in range(32)]
|
||||
for y in range(16):
|
||||
for x in range(15):
|
||||
if not native[y][x]:
|
||||
continue
|
||||
out[2 * y][x] = True
|
||||
vertical = (y > 0 and native[y - 1][x]) or (y < 15 and native[y + 1][x])
|
||||
# Dense horizontal bars are one row tall, with vertical stems
|
||||
# retained on the intervening row. Other strokes use two rows.
|
||||
out[2 * y + 1][x] = not dense or vertical or sum(native[y]) < 7
|
||||
isolated = (x == 0 or not native[y][x - 1]) and not native[y][x + 1]
|
||||
if vertical and isolated:
|
||||
if x < 14 and not native[y][x + 1] and (x == 13 or not native[y][x + 2]):
|
||||
out[2 * y][x + 1] = True
|
||||
out[2 * y + 1][x + 1] = True
|
||||
elif x > 1 and not native[y][x - 1] and not native[y][x - 2]:
|
||||
out[2 * y][x - 1] = True
|
||||
out[2 * y + 1][x - 1] = True
|
||||
if diagonal and y < 15:
|
||||
# Use an offset step on the intermediate row rather than
|
||||
# a uniform two-row stair; the crossing point stays narrow.
|
||||
for dx in (-1, 1):
|
||||
nx = x + dx
|
||||
if 0 <= nx < 15 and native[y + 1][nx] and not native[y + 1][x]:
|
||||
out[2 * y + 1][nx] = True
|
||||
return pixel_rows(out)
|
||||
|
||||
|
||||
def enclosure(char, width, height):
|
||||
grid = [[False] * width for _ in range(height)]
|
||||
thick = 2 if height == 32 else 1
|
||||
left, right = (1, 13) if height == 32 else (0, 6)
|
||||
top, bottom = (3, 28) if height == 32 else (1, 14)
|
||||
if height == 16 and char in "田由甲申":
|
||||
top, bottom = 2, 12 # leave room for the distinguishing top/bottom stems
|
||||
def box(x1, y1, x2, y2):
|
||||
for y in range(y1, y2 + 1):
|
||||
for x in range(x1, x2 + 1):
|
||||
grid[y][x] = True
|
||||
box(left, top, right, top + thick - 1)
|
||||
box(left, bottom - thick + 1, right, bottom)
|
||||
box(left, top, left + thick - 1, bottom)
|
||||
box(right - thick + 1, top, right, bottom)
|
||||
bars = {"日": [.5], "目": [.33, .67], "田": [.5], "由": [.5], "甲": [.5], "申": [.5]}
|
||||
for fraction in bars.get(char, []):
|
||||
y = round(top + (bottom - top) * fraction)
|
||||
box(left + thick, y, right - thick, y + thick - 1)
|
||||
if char in "田由甲申":
|
||||
middle = width // 2 - 1
|
||||
box(middle, 0 if char in "由申" else top + thick, middle + thick - 1,
|
||||
height - 2 if char in "甲申" else bottom - thick)
|
||||
if char == "回":
|
||||
inset = 4 if height == 32 else 2
|
||||
box(left + inset, top + inset, right - inset, top + inset + thick - 1)
|
||||
box(left + inset, bottom - inset - thick + 1, right - inset, bottom - inset)
|
||||
box(left + inset, top + inset, left + inset + thick - 1, bottom - inset)
|
||||
box(right - inset - thick + 1, top + inset, right - inset, bottom - inset)
|
||||
return pixel_rows(grid)
|
||||
|
||||
|
||||
def latin_large(rows):
|
||||
# Deliberately separate Latin design: 5-column skeleton expanded to 10
|
||||
# columns, horizontal stems 2 rows, vertical stems fill the pitch gaps.
|
||||
source = grid_rows(rows, 8)
|
||||
skeleton = [source[y][1:6] for y in range(0, 14, 2)]
|
||||
grid = [[False] * 16 for _ in range(32)]
|
||||
for y, row in enumerate(skeleton):
|
||||
yy = 2 + y * 4
|
||||
for x, bit in enumerate(row):
|
||||
if not bit:
|
||||
continue
|
||||
xx = 3 + x * 2
|
||||
for dy in (0, 1):
|
||||
for dx in (0, 1):
|
||||
grid[yy + dy][xx + dx] = True
|
||||
if y < 6 and skeleton[y + 1][x]:
|
||||
for dy in (2, 3):
|
||||
for dx in (0, 1):
|
||||
grid[yy + dy][xx + dx] = True
|
||||
elif y < 6:
|
||||
for nx in (x - 1, x + 1):
|
||||
if 0 <= nx < 5 and skeleton[y + 1][nx]:
|
||||
for dy in (2, 3):
|
||||
grid[yy + dy][xx + (1 if nx > x else 0)] = True
|
||||
return pixel_rows(grid)
|
||||
|
||||
|
||||
def latin_lower(rows):
|
||||
native = grid_rows(rows, 16)
|
||||
grid = [[False] * 16 for _ in range(32)]
|
||||
for y in range(16):
|
||||
for x in range(8):
|
||||
if native[y][x]:
|
||||
# Reserve a common side bearing and use the ten-column Latin
|
||||
# body rather than leaving lowercase stranded at the left.
|
||||
left = 3 + x * 10 // 8
|
||||
right = 3 + (x + 1) * 10 // 8
|
||||
for xx in range(left, right):
|
||||
grid[2 * y][xx] = grid[2 * y + 1][xx] = True
|
||||
return pixel_rows(grid)
|
||||
|
||||
|
||||
def generate_large(source_dir: Path, small_dir: Path, required: set[int]):
|
||||
native = parse_bdf(source_dir / "upstream/unifont-18.0.01.bdf", 16, 16, 14)
|
||||
missing = required - native.keys()
|
||||
if missing:
|
||||
raise ValueError(f"Unifont missing target codepoints: {sorted(missing)}")
|
||||
masters = {}
|
||||
revisions = {}
|
||||
for cp in sorted(required):
|
||||
rows = design_large(native[cp], dense=sum(row.bit_count() for row in native[cp]) > 115)
|
||||
masters[f"{cp:04X}"] = [f"{row:04X}" for row in rows]
|
||||
for kind, characters in CLASSES.items():
|
||||
if kind == "latin":
|
||||
continue
|
||||
for char in characters:
|
||||
key = f"{ord(char):04X}"
|
||||
if ord(char) not in required:
|
||||
continue
|
||||
rows = (enclosure(char, 16, 32) if char in "口日目田由甲申回" else
|
||||
design_large(native[ord(char)], dense=kind == "dense", diagonal=kind == "diagonal"))
|
||||
revisions[key] = {"rows": [f"{row:04X}" for row in rows], "class": kind,
|
||||
"method": "grid-redraw" if char in "口日目田由甲申回" else "structure-rule",
|
||||
"reason": {"enclosure": "重排包围框及内部留白。", "left_right": "分别增粗独立竖画,保留部件间空列。",
|
||||
"stacked": "横画保留双行厚度,竖画避开相邻笔画。", "diagonal": "重排斜画阶梯及交叉点。",
|
||||
"dense": "密集横画改单行,保留竖画连续及部件空隙。"}[kind]}
|
||||
small_revisions = json.loads((small_dir / "revisions.json").read_text(encoding="utf-8"))
|
||||
for key, revision in small_revisions.items():
|
||||
cp = int(key, 16)
|
||||
if 48 <= cp <= 57 or 65 <= cp <= 90 or chr(cp) in "il":
|
||||
rows = latin_large([int(row, 16) for row in revision["rows"]])
|
||||
revisions[key] = {"rows": [f"{row:04X}" for row in rows], "class": "latin", "method": "grid-redraw",
|
||||
"reason": "独立重排拉丁骨架,双列主笔画及26px字身,区分易混字符。"}
|
||||
for cp in range(ord("a"), ord("z") + 1):
|
||||
if chr(cp) not in "il":
|
||||
revisions[f"{cp:04X}"] = {"rows": [f"{row:04X}" for row in latin_lower(native[cp])],
|
||||
"class": "latin", "method": "structure-rule",
|
||||
"reason": "小写独立分配十列字身及侧边距,保留基线和下伸部。"}
|
||||
for filename, data in [("glyphs.json", masters), ("revisions.json", revisions),
|
||||
("classes.json", {kind: list(chars) for kind, chars in CLASSES.items()})]:
|
||||
(source_dir / filename).write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def improve_small(source_dir: Path):
|
||||
revisions = json.loads((source_dir / "revisions.json").read_text(encoding="utf-8"))
|
||||
masters = json.loads((source_dir / "glyphs.json").read_text(encoding="utf-8"))
|
||||
for kind in ("dense", "diagonal"):
|
||||
for char in CLASSES[kind]:
|
||||
key = f"{ord(char):04X}"
|
||||
rows = [int(row, 16) for row in masters[key]]
|
||||
for y in range(1, 15, 2):
|
||||
previous, following = rows[y - 1], rows[y + 1]
|
||||
if kind == "dense":
|
||||
common = previous & following
|
||||
interior = common & (common << 1) & (common >> 1)
|
||||
rows[y] &= ~interior
|
||||
elif previous.bit_count() <= 4 and following.bit_count() <= 4:
|
||||
rows[y] |= previous & ((following << 1) | (following >> 1))
|
||||
revisions[key] = {"rows": [f"{row:02X}" for row in rows], "class": kind, "method": "structure-rule",
|
||||
"reason": "密集处减少连续实心块,保留原横画与边缘竖画。" if kind == "dense" else "对稀疏撇捺补独立阶梯连接,保留交叉辨识。"}
|
||||
for char in "口日目田由甲申回":
|
||||
revisions[f"{ord(char):04X}"] = {"rows": [f"{row:02X}" for row in enclosure(char, 8, 16)],
|
||||
"class": "enclosure", "method": "grid-redraw",
|
||||
"reason": "使用16行重新安排包围框、内横画及贯穿竖画,避免仅扩展8行骨架。"}
|
||||
(source_dir / "revisions.json").write_text(json.dumps(revisions, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
@@ -0,0 +1,79 @@
|
||||
// Caller supplies an isolated browser context. No display/apply operation is performed.
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
export async function checkFontFavoritesBrowser(page, baseUrl, fontId = "default") {
|
||||
const errors = [];
|
||||
const onError = error => errors.push(error.message);
|
||||
page.on("pageerror", onError);
|
||||
const catalog = await (await page.request.get(`${baseUrl}/api/fonts`)).json();
|
||||
const originallyFavorite = catalog.favorite_ids.includes(fontId);
|
||||
const item = catalog.items.find(item => item.id === fontId);
|
||||
assert.ok(item);
|
||||
const update = favorite => page.request.put(`${baseUrl}/api/fonts/favorite`, { data: { id: fontId, favorite } });
|
||||
const rows = () => page.locator(".font-option-row").filter({ has: page.locator(`[data-font-id="${fontId}"]`) });
|
||||
const draft = () => page.evaluate(async () => JSON.stringify((await import("/static/scene-board.js")).getScene()));
|
||||
try {
|
||||
await update(false);
|
||||
await page.goto(`${baseUrl}/#canvas`);
|
||||
await page.locator("#new-text-layer").click();
|
||||
await page.locator("#create-text").click();
|
||||
const input = page.locator("#text-font");
|
||||
await input.click();
|
||||
assert.equal(await page.locator(".font-option-group").first().textContent(), "收藏字体");
|
||||
const before = await draft();
|
||||
const selected = await input.inputValue();
|
||||
await rows().locator("button").first().click();
|
||||
await page.waitForFunction(id => document.querySelectorAll(`[data-font-id="${id}"]`).length === 2, fontId);
|
||||
assert.equal(await rows().locator('[aria-pressed="true"]').count(), 2);
|
||||
assert.equal(await draft(), before);
|
||||
assert.equal(await input.inputValue(), selected);
|
||||
const ids = await page.locator('[role="option"]').evaluateAll(options => options.map(option => option.id));
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
await rows().locator("button").first().click();
|
||||
await page.locator(".font-favorite-dialog button").first().click();
|
||||
assert.equal(await rows().count(), 2);
|
||||
await rows().locator("button").first().click();
|
||||
await page.keyboard.press("Escape");
|
||||
assert.equal(await rows().count(), 2);
|
||||
await input.fill(item.family);
|
||||
assert.equal(await rows().count(), 2);
|
||||
await input.press("Home");
|
||||
await input.press("ArrowDown");
|
||||
const active = await input.getAttribute("aria-activedescendant");
|
||||
assert.equal(await page.locator(`[id="${active}"]`).count(), 1);
|
||||
await input.press("End");
|
||||
await input.press("Escape");
|
||||
await input.click();
|
||||
await input.press("Tab");
|
||||
assert.equal(await page.locator(".font-favorite-button:focus").count(), 1);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.locator(".font-favorite-dialog button").last().click();
|
||||
await page.waitForFunction(id => document.querySelectorAll(`[data-font-id="${id}"]`).length === 1, fontId);
|
||||
assert.equal(await draft(), before);
|
||||
// A failed save keeps both catalog and UI unchanged.
|
||||
await page.route("**/api/fonts/favorite", route => route.fulfill({ status: 500, contentType: "application/json", body: '{"detail":"injected failure"}' }));
|
||||
await rows().locator("button").click();
|
||||
await page.locator("#font-feedback[data-state=error]").waitFor();
|
||||
assert.equal(await rows().count(), 1);
|
||||
assert.equal(await rows().locator('[aria-pressed="false"]').count(), 1);
|
||||
await page.unroute("**/api/fonts/favorite");
|
||||
// Another browser changes the shared record; reopening reads it.
|
||||
await input.press("Escape");
|
||||
const other = await page.context().browser().newContext();
|
||||
try {
|
||||
const response = await other.request.put(`${baseUrl}/api/fonts/favorite`, { data: { id: fontId, favorite: true } });
|
||||
assert.equal(response.status(), 200);
|
||||
} finally { await other.close(); }
|
||||
await input.click();
|
||||
await page.waitForFunction(id => document.querySelectorAll(`[data-font-id="${id}"]`).length === 2, fontId);
|
||||
assert.equal(await draft(), before);
|
||||
const bounds = await page.locator("#text-font-options").boundingBox();
|
||||
assert.ok(bounds.x >= 0 && bounds.x + bounds.width <= page.viewportSize().width + 1);
|
||||
assert.deepEqual(errors, []);
|
||||
return { passed: true, width: page.viewportSize().width, duplicateOptions: true, sceneUnchanged: true, errors };
|
||||
} finally {
|
||||
await page.unroute("**/api/fonts/favorite");
|
||||
await update(originallyFavorite);
|
||||
page.off("pageerror", onError);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Compare actual layer output, including old 16px enlarged and new 32px."""
|
||||
from pathlib import Path
|
||||
import argparse
|
||||
import sys
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app.fonts.store import FontCatalog
|
||||
from app.display.text_renderer import TextOptions, render_text_layer
|
||||
from bus_font_design import CLASSES
|
||||
|
||||
|
||||
def compare(output: Path, old: Path):
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
catalog = FontCatalog(output / "data")
|
||||
columns = [(str(old),16,"OLD 16"), ("builtin:walnut-bus-8x16",16,"NEW 16"),
|
||||
(str(old),32,"OLD 16 x2"), ("builtin:walnut-bus-16x32",32,"NEW 32")]
|
||||
samples = ["高铁北站", "花鸟市场", "开往火车", "鹤警藏赢", "口日目田", "由甲申回", "国园圈围", "站铁路湖", "重量喜音", "人入大文", "0O1I", "l5S8", "B2Z6"]
|
||||
sheet = Image.new("RGB", (1056, len(samples) * 148), "#111111")
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
for y, text in enumerate(samples):
|
||||
for x, (font, size, label) in enumerate(columns):
|
||||
layer = render_text_layer(TextOptions(text, font=font, size=size), size=(64, 32), font_resolver=catalog.resolve)
|
||||
scaled = layer.resize((256,128), Image.Resampling.NEAREST)
|
||||
draw.text((x*264+2,y*148+2),label,fill="white")
|
||||
sheet.paste(scaled,(x*264,y*148+18),scaled.getchannel('A'))
|
||||
sheet.save(output / "four-way.png")
|
||||
for kind, characters in CLASSES.items():
|
||||
proof = Image.new("RGB", (len(characters)*48,96), "#111111")
|
||||
labels = ImageDraw.Draw(proof)
|
||||
for index, char in enumerate(characters):
|
||||
layer = render_text_layer(TextOptions(char,font="builtin:walnut-bus-16x32",size=32),size=(16,32),font_resolver=catalog.resolve)
|
||||
big=layer.resize((32,64),Image.Resampling.NEAREST)
|
||||
labels.text((index*48,2),f"{ord(char):04X}",fill="white")
|
||||
proof.paste(big,(index*48+4,20),big.getchannel('A'))
|
||||
proof.save(output / f"class-{kind}.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--old", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
compare(args.output,args.old)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Generate offline inspection sheets from the actual text-layer renderer."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app.display.text_renderer import TextOptions, render_text_layer
|
||||
from app.fonts.store import FontCatalog
|
||||
from build_bus_font import gb2312_codepoints
|
||||
|
||||
|
||||
def proof(output: Path, reference: Path | None = None, height: int = 16) -> None:
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
catalog = FontCatalog(output / "isolated-data")
|
||||
width = height // 2
|
||||
font_id = f"builtin:walnut-bus-{width}x{height}"
|
||||
cps = sorted(gb2312_codepoints())
|
||||
fingerprints = defaultdict(list)
|
||||
grey, blank, boundary = [], [], []
|
||||
densities = []
|
||||
for cp in cps:
|
||||
layer = render_text_layer(TextOptions(text=chr(cp), font=font_id, size=height),
|
||||
size=(width, height), font_resolver=catalog.resolve)
|
||||
alpha = layer.getchannel("A")
|
||||
values = set(alpha.tobytes())
|
||||
if values - {0, 255}:
|
||||
grey.append(cp)
|
||||
if not alpha.getbbox() and cp not in (32, 0x3000):
|
||||
blank.append(cp)
|
||||
if alpha.crop((width - 1, 0, width, height)).getbbox():
|
||||
boundary.append(cp)
|
||||
fingerprints[hashlib.sha256(alpha.tobytes()).hexdigest()].append(cp)
|
||||
densities.append((sum(value != 0 for value in alpha.tobytes()), f"{cp:04X}"))
|
||||
per_page = 256 if height == 32 else 512
|
||||
scale = 2 if height == 32 else 3
|
||||
cell_y = 84 if height == 32 else 64
|
||||
for page, start in enumerate(range(0, len(cps), per_page)):
|
||||
sheet = Image.new("RGB", (16 * 48, (per_page // 16) * cell_y), "#111111")
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
for index, cp in enumerate(cps[start:start + per_page]):
|
||||
x, y = (index % 16) * 48, (index // 16) * cell_y
|
||||
draw.text((x + 1, y), f"{cp:04X}", fill="#aaaaaa")
|
||||
layer = render_text_layer(TextOptions(text=chr(cp), font=font_id, size=height),
|
||||
size=(width, height), font_resolver=catalog.resolve)
|
||||
scaled = layer.resize((width * scale, height * scale), Image.Resampling.NEAREST)
|
||||
sheet.paste(scaled, (x + 8, y + 14), scaled.getchannel("A"))
|
||||
sheet.save(output / f"glyphs-{page + 1:02}.png")
|
||||
samples = ["高铁北站21路", "花鸟市场公交", "开往火车站", "鹤警藏赢鬻", "口日目田由甲", "未末土士人入", "0O1Il5S8B", "ABCxyz0123", ",。!?:;"]
|
||||
comparison = Image.new("RGB", (768, len(samples) * 160), "#111111")
|
||||
draw = ImageDraw.Draw(comparison)
|
||||
for row, text in enumerate(samples):
|
||||
for column, font in enumerate([font_id] + ([str(reference)] if reference else [])):
|
||||
layer = render_text_layer(TextOptions(text=text, font=font, size=height),
|
||||
size=(64, height), font_resolver=catalog.resolve)
|
||||
draw.text((column * 384 + 4, row * 160 + 2), f"BUS {width}x{height}" if column == 0 else f"REFERENCE {height}px", fill="white")
|
||||
scaled = layer.resize((256, height * 4), Image.Resampling.NEAREST)
|
||||
comparison.paste(scaled, (column * 384 + 4, row * 160 + 22), scaled.getchannel("A"))
|
||||
comparison.save(output / "comparison.png")
|
||||
result = {"glyph_count": len(cps), "grey": grey, "blank": blank,
|
||||
"right_edge_lit": boundary,
|
||||
"duplicates": [[f"{cp:04X}" for cp in group] for group in fingerprints.values() if len(group) > 1]}
|
||||
result["densest_32"] = [{"codepoint": cp, "lit_pixels": count, "density": count / 128}
|
||||
for count, cp in sorted(densities, reverse=True)[:32]]
|
||||
for item in result["densest_32"]:
|
||||
item["density"] = item["lit_pixels"] / (width * height)
|
||||
(output / "audit.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"grey": len(grey), "blank": len(blank), "right_edge_lit": len(boundary),
|
||||
"duplicate_groups": len(result["duplicates"])}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--reference", type=Path)
|
||||
parser.add_argument("--height", type=int, choices=(16, 32), default=16)
|
||||
args = parser.parse_args()
|
||||
proof(args.output, args.reference, args.height)
|
||||
Reference in New Issue
Block a user