488 lines
18 KiB
Python
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
|