Files
2026-08-23 21:25:14 +08:00

488 lines
18 KiB
Python

"""End-to-end render verification for kami templates.
Renders each template through render.render_pdf, validates page-count against
per-template ceilings, inspects embedded PDF fonts to warn when only a
fallback is used, and runs an advisory density scan over the rendered
examples when invoked for the full suite. Self-sufficient: registries come
from shared, the render pipeline from render, the density scan from checks.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
from checks import _resume_balance_issues, scan_density
from lint import scan_file
from optional_deps import MissingDepError, require_pymupdf, require_pypdf_reader
from render import build_slides, render_pdf
from shared import (
DIAGRAMS,
EXAMPLES,
ROOT,
TEMPLATES,
build_targets,
default_example_pdfs,
diagram_targets,
load_checks_thresholds,
pptx_targets,
rel_to_root,
screen_targets,
)
# Primary fonts expected in embedded PDF font names
CN_PRIMARY_FONTS = {"TsangerJinKai02"}
EN_PRIMARY_FONTS = {"Charter"}
KO_PRIMARY_FONTS = {"Source-Han-Serif-K", "SourceHanSerifK"}
# Serif families that keep a CJK page readable when the primary is unavailable.
# Falling through to one of these is a degradation, not a defect: the page still
# carries the serif texture every size, tracking and leading value was tuned for.
CJK_SERIF_MARKERS = (
"TsangerJinKai",
"SourceHanSerif",
"NotoSerifCJK",
"NotoSerifSC",
"NotoSerifTC",
"NotoSerifJP",
"NotoSerifKR",
"Songti",
"STSong",
"SimSun",
"STZhongsong",
"STKaiti",
"KaiTi",
"MSung",
"MingLiU",
"HiraginoMincho",
"HiraMinPro",
"YuMincho",
)
# A CJK run needs at least this many ideographs before its dominant font is
# worth judging: a stray glyph in an otherwise Latin document proves nothing.
MIN_CJK_CHARS_TO_JUDGE = 20
RECOGNIZABLE_FALLBACK_FONT_MARKERS = (
"Georgia",
"Palatino",
"PT-Serif",
"PTSerif",
"TsangerJinKai",
"YuMincho",
"Hiragino",
"SourceHan",
"Noto",
"Charter",
"Songti",
"DejaVu",
"Liberation",
)
def show_fonts(pdf: Path) -> None:
if not pdf.exists():
return
try:
out = subprocess.run(["pdffonts", str(pdf)], capture_output=True, text=True, check=False)
if out.returncode == 0:
print("--- pdffonts ---")
print(out.stdout.rstrip())
except FileNotFoundError:
pass # pdffonts not installed; silent
def _pdf_font_names(pdf_path: Path) -> set[str]:
def _resolve_pdf_obj(obj):
if obj is None:
return None
try:
return obj.get_object() if hasattr(obj, "get_object") else obj
except Exception:
return obj
try:
PdfReader = require_pypdf_reader()
reader = PdfReader(str(pdf_path))
fonts: set[str] = set()
for page in reader.pages:
resources = _resolve_pdf_obj(page.get("/Resources"))
if resources is None or not hasattr(resources, "get"):
continue
font_dict = _resolve_pdf_obj(resources.get("/Font"))
if font_dict is None or not hasattr(font_dict, "values"):
continue
for obj in font_dict.values():
resolved = _resolve_pdf_obj(obj)
if resolved is None or not hasattr(resolved, "get"):
continue
base = resolved.get("/BaseFont")
if base:
fonts.add(str(base).lstrip("/"))
return fonts
except Exception as exc:
print(f" WARN: could not read font names from PDF: {exc}")
return set()
def _normalize_font_name(name: str) -> str:
"""Reduce a PDF BaseFont entry to comparable letters and digits.
Embedded names arrive subset-prefixed and punctuated in every combination
('ABCDEF+NotoSerifCJKsc-Regular', 'Noto Serif CJK SC'), so markers are
matched against a stripped, lowercased form instead of the raw string.
"""
return re.sub(r"[^a-z0-9]", "", name.lower())
def _classify_cjk_font(font_name: str) -> str:
"""Return 'primary', 'serif' or 'other' for one font name.
'other' covers both a system sans substitution and a family nobody here
recognizes. Naming which of the two it is would need a second marker table
to maintain, and would change nothing: both fail, both take the same fix,
and the message already prints the family that drew the text.
"""
normalized = _normalize_font_name(font_name)
if any(_normalize_font_name(m) in normalized for m in CN_PRIMARY_FONTS | KO_PRIMARY_FONTS):
return "primary"
if any(_normalize_font_name(m) in normalized for m in CJK_SERIF_MARKERS):
return "serif"
return "other"
def _cjk_font_usage(pdf_path: Path) -> dict[str, int]:
"""Return {font name: ideographs it drew} across the whole document.
Judging the font table alone cannot tell which family actually set the body
text: a document can embed a serif for two glyphs and a sans for the other
three thousand. Walking spans attributes every ideograph to the font that
drew it, so the verdict is about the text a reader sees.
"""
fitz = require_pymupdf()
per_font: dict[str, int] = {}
with fitz.open(str(pdf_path)) as doc:
for page in doc:
for block in page.get_text("dict").get("blocks", []):
for line in block.get("lines", []):
for span in line.get("spans", []):
ideographs = sum(
1 for ch in span.get("text", "")
if "㐀" <= ch <= "鿿" or "가" <= ch <= "힯"
)
if ideographs:
font = span.get("font", "") or "(unnamed)"
per_font[font] = per_font.get(font, 0) + ideographs
return per_font
# A second CJK family this far into the text is per-glyph fontconfig fallback,
# not a design decision: it splits single words down the middle. Below it, a
# stray symbol picked up elsewhere is not worth a failure.
MIXED_FAMILY_MIN_SHARE = 0.05
MIXED_FAMILY_MIN_CHARS = 3
# Weight and style words a PDF appends to the family it subsets. Bold body text
# is a second BaseFont entry off one family (TsangerJinKai02 plus
# TsangerJinKai02-Medium), which the mixed-family rule must not read as two
# typefaces. Longest first so 'semibold' strips before 'bold'.
# Two-letter abbreviations (Md, Rg, Bd) are deliberately absent: no font seen
# here uses them, and they are short enough to bite a real family name.
_FONT_STYLE_SUFFIXES = (
"extralight", "semibold", "demibold", "oblique", "regular", "medium",
"italic", "mediu", "light", "black", "heavy", "roman", "book", "bold",
"thin", "lig",
)
# Numeric weight markers: TsangerJinKai ships W04/W05, other foundries use a
# three-digit CSS weight. Both name one family at two weights.
_FONT_WEIGHT_CODE = re.compile(r"(?:w\d{2}|\d{3})$")
def _font_family_key(name: str) -> str:
"""Reduce a font name to its family, dropping trailing weight/style words."""
key = _normalize_font_name(name)
changed = True
while changed:
changed = False
for suffix in _FONT_STYLE_SUFFIXES:
if key.endswith(suffix) and len(key) > len(suffix):
key = key[: -len(suffix)]
changed = True
break
stripped = _FONT_WEIGHT_CODE.sub("", key)
if stripped != key and stripped:
key = stripped
changed = True
return key
FONT_RECOVERY_HINT = (
" fix: bash scripts/ensure-fonts.sh (installs a CJK serif into the user font dir), "
"or `brew install --cask font-source-han-serif-sc` on macOS, "
"or `apt-get install fonts-noto-cjk` on Linux, then render again"
)
def check_fonts(paths: list[str]) -> int:
"""CLI: --check-fonts doc.pdf [more.pdf ...]
Deterministic gate on the one failure a perceptual pass reliably misses.
A missing CJK serif does not produce fallback boxes; it silently swaps in a
sans that still reads, so both the agent and the author sign off on a page
whose typography is no longer the system's. The rendered PDF's own span
table settles it: whichever font drew the body ideographs is the verdict.
"""
files = [p for p in paths if not p.startswith("-")]
if not files:
print("ERROR: usage: --check-fonts path/to/doc.pdf [more.pdf ...]")
return 2
failures = 0
for raw in files:
pdf = Path(raw)
if not pdf.is_absolute():
pdf = ROOT / pdf
rel = rel_to_root(pdf)
if not pdf.exists():
print(f"ERROR: {raw}: file not found")
failures += 1
continue
try:
usage = _cjk_font_usage(pdf)
except MissingDepError as exc:
print(f"ERROR: {exc}")
return 2
except Exception as exc:
print(f"ERROR: {rel}: could not read text spans: {exc}")
failures += 1
continue
count = sum(usage.values())
if not usage or count < MIN_CJK_CHARS_TO_JUDGE:
print(f"OK: {rel}: no CJK body text to judge ({count} ideograph(s))")
continue
# Collapse weight variants into their family before counting families.
families: dict[str, tuple[str, int]] = {}
for name, chars in usage.items():
key = _font_family_key(name)
label, total = families.get(key, (name, 0))
if chars > usage.get(label, 0):
label = name
families[key] = (label, total + chars)
dominant = max(families, key=lambda k: families[k][1])
font, font_chars = families[dominant]
others = [
(label, chars) for key, (label, chars) in families.items()
if key != dominant
and chars >= MIXED_FAMILY_MIN_CHARS
and chars / count >= MIXED_FAMILY_MIN_SHARE
]
if others:
detail = ", ".join(f"{name} ({chars})" for name, chars in sorted(others))
print(f"ERROR: {rel}: CJK text split across families: {font} ({font_chars}), {detail}")
print(" per-glyph fallback breaks single words across two typefaces; "
"one page carries one serif")
print(FONT_RECOVERY_HINT)
failures += 1
continue
verdict = _classify_cjk_font(font)
if verdict == "primary":
print(f"OK: {rel}: CJK body text in {font} ({count} ideographs)")
elif verdict == "serif":
print(f"WARN: {rel}: CJK body text in {font}, a serif fallback, not the kami primary")
print(" acceptable, but the page is not the reference rendering")
else:
print(f"ERROR: {rel}: CJK body text in {font}, not a CJK serif")
print(" the parchment metrics are tuned for serif stroke density; a sans "
"substitution reads heavier and flatter at the same size, with no "
"fallback box to make it obvious")
print(FONT_RECOVERY_HINT)
failures += 1
return 0 if failures == 0 else 1
def _check_font_sources(html_path: Path) -> list[str]:
"""Return list of local @font-face src files that are missing on disk."""
text = html_path.read_text(encoding="utf-8", errors="replace")
missing: list[str] = []
for url in re.findall(r"""url\(["']?([^"')]+)["']?\)""", text):
if url.startswith(("http://", "https://", "data:", "#")):
continue
resolved = (html_path.parent / url).resolve()
if not resolved.exists():
missing.append(url)
return missing
def verify_target(name: str, source: str, max_pages: int, src_dir: Path) -> list[str]:
"""Render `source` to a PDF, then run page-count and font checks."""
issues: list[str] = []
src = src_dir / source
if not src.exists():
issues.append(f"source not found: {src}")
return issues
# Warn about missing local font files before rendering
missing_fonts = _check_font_sources(src)
if missing_fonts:
for mf in missing_fonts:
print(f" [FONT MISS] {name}: {mf} not found")
print(f" [FONT MISS] Repo fix: git checkout -- assets/fonts (commercial TTFs are tracked)")
print(f" [FONT MISS] Skill recovery (downloads to the user font dir, not the skill): bash scripts/ensure-fonts.sh")
print(f" [FONT MISS] Fallback: brew install --cask font-source-han-serif-sc")
out = EXAMPLES / f"{name}.pdf"
try:
n = render_pdf(src, out)
except MissingDepError as exc:
issues.append(str(exc))
return issues
# Resume templates share the balance gate's exact two-page contract. Keep
# fill and gap checks in the filled-document flow: source templates contain
# placeholder copy, so their rendered density is not an authoring signal.
if "resume" in name:
cfg = load_checks_thresholds()["resume_balance"]
page_issues = _resume_balance_issues(
[],
n,
float(cfg["min_fill_pct"]),
float(cfg["max_fill_pct"]),
float(cfg["max_gap_pct"]),
)
issues.extend(f"page count: {issue}" for issue in page_issues)
elif max_pages and n > max_pages:
over = n - max_pages
hint = ""
if "resume" in name and over == 1:
hint = '; add class="resume--dense" to <body> or tighten .proj-text line-height to 1.38'
issues.append(f"page overflow: {n} pages (limit {max_pages}){hint}")
# font check
embedded = _pdf_font_names(out)
fallback_present = any(
kw in font for font in embedded
for kw in RECOGNIZABLE_FALLBACK_FONT_MARKERS
)
# Diagram templates are language-neutral and often rely on fallback stacks,
# so only enforce that at least one recognizable serif/sans fallback exists.
is_diagram = src_dir == DIAGRAMS
if is_diagram:
if not fallback_present:
issues.append(f"no recognizable font embedded in {out.name}")
return issues
is_en = name.endswith("-en")
is_ko = name.endswith("-ko")
expected = EN_PRIMARY_FONTS if is_en else (KO_PRIMARY_FONTS if is_ko else CN_PRIMARY_FONTS)
if not any(exp in font_name for exp in expected for font_name in embedded):
primary = next(iter(expected))
if not fallback_present:
issues.append(f"no recognizable font embedded in {out.name}")
elif os.environ.get("KAMI_ALLOW_FALLBACK_ONLY"):
# CI / headless boxes never have commercial fonts (TsangerJinKai02,
# Charter). Treat "primary missing, fallback present" as a warning
# there so CI can still gate page-count regressions.
print(f" WARN: {name}: primary font ({primary}) not embedded; using fallback")
else:
issues.append(f"primary font ({primary}) not embedded; using fallback")
return issues
def verify_screen_target(name: str, source: str) -> list[str]:
"""Lint a browser-only template via lint.scan_file."""
src = TEMPLATES / source
if not src.exists():
return [f"source not found: {src}"]
findings = scan_file(src)
if findings:
return [f"{len(findings)} template violation(s)"]
return []
def verify_all(target: str | None) -> int:
"""Drive verification across the requested target set."""
html_targets = build_targets()
screen_targets_map = screen_targets()
diagram_targets_map = diagram_targets()
pptx_targets_map = pptx_targets()
targets_to_run: dict[str, tuple[str, int, Path] | None] = {}
screen_targets_to_run: dict[str, str] = {}
if target:
if target in html_targets:
src, mp = html_targets[target]
targets_to_run[target] = (src, mp, TEMPLATES)
elif target in screen_targets_map:
screen_targets_to_run[target] = screen_targets_map[target]
elif target in diagram_targets_map:
targets_to_run[target] = (diagram_targets_map[target], 0, DIAGRAMS)
elif target in pptx_targets_map:
targets_to_run[target] = None
else:
print(f"ERROR: unknown target: {target}")
return 2
else:
for name, (src, mp) in html_targets.items():
targets_to_run[name] = (src, mp, TEMPLATES)
for name, src in screen_targets_map.items():
screen_targets_to_run[name] = src
for name, src in diagram_targets_map.items():
targets_to_run[name] = (src, 0, DIAGRAMS)
for name in pptx_targets_map:
targets_to_run[name] = None
failures = 0
rows: list[tuple[str, str]] = []
for name, config in targets_to_run.items():
if config is None:
issues = [] if build_slides(name) else ["slides build failed"]
else:
source, max_pages, src_dir = config
issues = verify_target(name, source, max_pages, src_dir)
if issues:
rows.append((f"ERROR: {name}", "; ".join(issues)))
failures += 1
else:
rows.append((f"OK: {name}", "ok"))
for name, source in screen_targets_to_run.items():
issues = verify_screen_target(name, source)
if issues:
rows.append((f"ERROR: {name}", "; ".join(issues)))
failures += 1
else:
rows.append((f"OK: {name}", "static HTML template"))
for status, detail in rows:
print(f"{status}: {detail}")
if target is None:
pdfs = default_example_pdfs()
if pdfs:
print()
print("Density scan (advisory):")
scan = scan_density(pdfs)
if scan is not None:
sparse, warn, _, scanned = scan
if sparse + warn == 0:
print(f" OK: no density issues across {scanned} PDF(s)")
else:
density_cfg = load_checks_thresholds()["density"]
sparse_pct_disp = int(round(float(density_cfg["sparse_pct"]) * 100))
warn_pct_disp = int(round(float(density_cfg["warn_pct"]) * 100))
if sparse:
print(f" {sparse} SPARSE page(s) (>{sparse_pct_disp}% trailing whitespace) across {scanned} PDF(s)")
if warn:
print(f" {warn} WARN page(s) (>{warn_pct_disp}%) across {scanned} PDF(s)")
print(" (advisory: re-author with SKILL.md Step 4.1 merge rule. Does not fail --verify.)")
return 0 if failures == 0 else 1