Files
2026-07-13 13:05:52 +08:00

294 lines
10 KiB
Python

"""End-to-end render verification for kami templates.
Splits out from build.py: renders each template via WeasyPrint, 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.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
from highlight import highlight_code_blocks
from optional_deps import (
MissingDepError,
require_pypdf_reader,
require_weasyprint_html,
)
from shared import DIAGRAMS, EXAMPLES, TEMPLATES, default_example_pdfs, load_checks_thresholds
# Primary fonts expected in embedded PDF font names
CN_PRIMARY_FONTS = {"TsangerJinKai02"}
EN_PRIMARY_FONTS = {"Charter"}
KO_PRIMARY_FONTS = {"Source-Han-Serif-K", "SourceHanSerifK"}
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 _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,
*,
infer_author_fn,
set_pdf_metadata_fn,
) -> list[str]:
"""Render `source` to a PDF, then run page-count and font checks.
`infer_author_fn` and `set_pdf_metadata_fn` are passed in by the caller
(build.py) so this module avoids a circular import on those helpers.
"""
issues: list[str] = []
src = src_dir / source
if not src.exists():
issues.append(f"source not found: {src}")
return issues
try:
HTML = require_weasyprint_html()
PdfReader = require_pypdf_reader()
except MissingDepError as exc:
issues.append(str(exc))
return issues
EXAMPLES.mkdir(parents=True, exist_ok=True)
out = EXAMPLES / f"{name}.pdf"
# 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")
html_text = src.read_text(encoding="utf-8")
html_text = highlight_code_blocks(html_text)
HTML(string=html_text, base_url=str(src.parent)).write_pdf(str(out))
# Set PDF metadata (only replaces placeholders, preserves filled values)
set_pdf_metadata_fn(out, author=infer_author_fn())
# page count check
n = len(PdfReader(str(out)).pages)
if 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, scan_file_fn) -> list[str]:
"""Lint a browser-only template via the caller-provided scan_file."""
src = TEMPLATES / source
if not src.exists():
return [f"source not found: {src}"]
findings = scan_file_fn(src)
if findings:
return [f"{len(findings)} template violation(s)"]
return []
def verify_all(
target: str | None,
*,
html_targets,
screen_targets,
diagram_targets,
pptx_targets,
verify_slides_fn,
scan_file_fn,
scan_density_fn,
infer_author_fn,
set_pdf_metadata_fn,
) -> int:
"""Drive verification across the requested target set.
All registries and helper callbacks are passed in to keep this module free
of circular imports back into build.py.
"""
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:
screen_targets_to_run[target] = screen_targets[target]
elif target in diagram_targets:
targets_to_run[target] = (diagram_targets[target], 0, DIAGRAMS)
elif target in pptx_targets:
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.items():
screen_targets_to_run[name] = src
for name, src in diagram_targets.items():
targets_to_run[name] = (src, 0, DIAGRAMS)
for name in pptx_targets:
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 = verify_slides_fn(name)
else:
source, max_pages, src_dir = config
issues = verify_target(
name, source, max_pages, src_dir,
infer_author_fn=infer_author_fn,
set_pdf_metadata_fn=set_pdf_metadata_fn,
)
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, scan_file_fn)
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_fn(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