615 lines
20 KiB
Python
615 lines
20 KiB
Python
"""PDF-side and content-shape checks for kami documents.
|
|
|
|
Splits out from build.py:
|
|
- check_placeholders: scan filled HTML for unreplaced `{{...}}` tokens.
|
|
- check_orphans: scan rendered PDFs for short trailing lines (typographic orphans).
|
|
- check_density: scan rendered PDFs for pages with too much trailing whitespace.
|
|
- check_resume_balance: scan resume PDFs for exact two-page balance.
|
|
- check_rhythm: scan slides Python source for monotonous deck sequences.
|
|
|
|
Density scanning uses a parchment-aware pixel sweep. The hot path is
|
|
vectorized with NumPy when available and falls back to a pure-Python loop.
|
|
Thresholds and DPI live in `references/checks_thresholds.json`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from html.parser import HTMLParser
|
|
from pathlib import Path
|
|
|
|
from optional_deps import MissingDepError, require_pymupdf, require_pypdf_reader
|
|
from shared import (
|
|
PARCHMENT_RGB,
|
|
ROOT,
|
|
default_example_pdfs,
|
|
load_checks_thresholds,
|
|
rel_to_root,
|
|
)
|
|
|
|
PLACEHOLDER = re.compile(r"\{\{[^}]+\}\}")
|
|
MARKDOWN_THEMATIC_BREAK = re.compile(r"^\s*[-*_]{3,}\s*$")
|
|
MARKDOWN_RESIDUE_MARKERS = (
|
|
("markdown thematic break", MARKDOWN_THEMATIC_BREAK),
|
|
("unconverted bold marker", re.compile(r"\*\*")),
|
|
("unconverted inline-code marker", re.compile(r"`")),
|
|
)
|
|
|
|
# Parchment background RGB for pixel comparison (sourced from shared.PARCHMENT_RGB).
|
|
_BG_R, _BG_G, _BG_B = PARCHMENT_RGB
|
|
_BG_TOLERANCE = 10
|
|
|
|
|
|
def check_placeholders(paths: list[str]) -> int:
|
|
if not paths:
|
|
print("ERROR: provide at least one HTML file to scan")
|
|
return 2
|
|
|
|
failures = 0
|
|
for raw in paths:
|
|
path = Path(raw)
|
|
if not path.is_absolute():
|
|
path = ROOT / path
|
|
if not path.exists():
|
|
print(f"ERROR: {raw}: file not found")
|
|
failures += 1
|
|
continue
|
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
hits = list(dict.fromkeys(PLACEHOLDER.findall(text)))
|
|
rel = rel_to_root(path)
|
|
if hits:
|
|
print(f"ERROR: {rel}: unfilled placeholder(s): {', '.join(hits)}")
|
|
failures += 1
|
|
else:
|
|
print(f"OK: {rel}: no placeholders")
|
|
|
|
return 0 if failures == 0 else 1
|
|
|
|
|
|
# ---------- markdown residue check ----------
|
|
|
|
class _VisibleTextParser(HTMLParser):
|
|
"""Extract visible text from filled HTML while skipping code-like blocks."""
|
|
|
|
_SKIP_TAGS = {"code", "pre", "script", "style"}
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__(convert_charrefs=True)
|
|
self._skip_depth = 0
|
|
self.parts: list[str] = []
|
|
|
|
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
if tag in self._SKIP_TAGS:
|
|
self._skip_depth += 1
|
|
|
|
def handle_endtag(self, tag: str) -> None:
|
|
if tag in self._SKIP_TAGS and self._skip_depth > 0:
|
|
self._skip_depth -= 1
|
|
|
|
def handle_data(self, data: str) -> None:
|
|
if self._skip_depth == 0:
|
|
self.parts.append(data)
|
|
|
|
|
|
def _visible_html_text(raw: str) -> str:
|
|
parser = _VisibleTextParser()
|
|
parser.feed(raw)
|
|
return "\n".join(parser.parts)
|
|
|
|
|
|
def _markdown_residue_issues(text: str, *, page: int | None = None) -> list[str]:
|
|
issues: list[str] = []
|
|
for line_no, line in enumerate(text.splitlines(), start=1):
|
|
for label, pattern in MARKDOWN_RESIDUE_MARKERS:
|
|
if pattern.search(line):
|
|
where = f"p{page}" if page is not None else f"line {line_no}"
|
|
sample = " ".join(line.strip().split())[:80]
|
|
issues.append(f"{where}: {label}: {sample!r}")
|
|
return issues
|
|
|
|
|
|
def _markdown_text_chunks(path: Path) -> tuple[list[tuple[int | None, str]], str | None]:
|
|
suffix = path.suffix.lower()
|
|
if suffix == ".pdf":
|
|
try:
|
|
PdfReader = require_pypdf_reader()
|
|
except MissingDepError as exc:
|
|
return [], str(exc)
|
|
try:
|
|
reader = PdfReader(str(path))
|
|
except Exception as exc:
|
|
return [], f"could not read PDF text: {exc}"
|
|
return [
|
|
(index, page.extract_text() or "")
|
|
for index, page in enumerate(reader.pages, start=1)
|
|
], None
|
|
|
|
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
if suffix in {".html", ".htm"}:
|
|
return [(None, _visible_html_text(raw))], None
|
|
return [(None, raw)], None
|
|
|
|
|
|
def check_markdown_residue(paths: list[str]) -> int:
|
|
"""Scan filled HTML/PDF outputs for visible raw Markdown markers.
|
|
|
|
This catches common hand-conversion misses such as literal `---`, `**bold**`,
|
|
and inline-code backticks leaking into the final PDF.
|
|
"""
|
|
if not paths:
|
|
paths = default_example_pdfs()
|
|
if not paths:
|
|
print("ERROR: no files to scan")
|
|
return 2
|
|
|
|
failures = 0
|
|
scanned = 0
|
|
for raw in paths:
|
|
path = Path(raw)
|
|
if not path.is_absolute():
|
|
path = ROOT / path
|
|
rel = rel_to_root(path)
|
|
if not path.exists():
|
|
print(f"ERROR: {raw}: file not found")
|
|
failures += 1
|
|
continue
|
|
|
|
chunks, error = _markdown_text_chunks(path)
|
|
if error:
|
|
print(f"ERROR: {rel}: {error}")
|
|
failures += 1
|
|
continue
|
|
|
|
scanned += 1
|
|
issues: list[str] = []
|
|
for page, text in chunks:
|
|
issues.extend(_markdown_residue_issues(text, page=page))
|
|
if issues:
|
|
failures += 1
|
|
print(f"ERROR: {rel}: markdown residue found")
|
|
for issue in issues:
|
|
print(f" {issue}")
|
|
else:
|
|
print(f"OK: {rel}: no markdown residue")
|
|
|
|
if scanned == 0:
|
|
print("ERROR: no files scanned")
|
|
return 2
|
|
return 0 if failures == 0 else 1
|
|
|
|
|
|
# ---------- orphan check ----------
|
|
|
|
def _orphan_last_line(text: str, max_words: int, max_chars: int) -> str | None:
|
|
"""Return a block's last line if it is an orphan, else None.
|
|
|
|
A block orphans when it has 2+ lines and the trailing line is short by
|
|
both word count (<= max_words) and length (< max_chars). Pure so the
|
|
predicate is unit-testable without a rendered PDF.
|
|
"""
|
|
lines = text.strip().splitlines()
|
|
if len(lines) < 2:
|
|
return None
|
|
last = lines[-1].strip()
|
|
if len(last.split()) <= max_words and len(last) < max_chars:
|
|
return last
|
|
return None
|
|
|
|
|
|
def check_orphans(paths: list[str]) -> int:
|
|
"""Scan PDF for text blocks whose last line has <= max_words and < max_chars."""
|
|
try:
|
|
fitz = require_pymupdf()
|
|
except MissingDepError as exc:
|
|
print(f"ERROR: {exc}")
|
|
return 2
|
|
|
|
if not paths:
|
|
paths = default_example_pdfs()
|
|
if not paths:
|
|
print("ERROR: no PDF files to scan")
|
|
return 2
|
|
|
|
orphan_cfg = load_checks_thresholds()["orphan"]
|
|
max_words = int(orphan_cfg["max_words"])
|
|
max_chars = int(orphan_cfg["max_chars"])
|
|
|
|
total = 0
|
|
missing = 0
|
|
scanned = 0
|
|
for raw in paths:
|
|
path = Path(raw)
|
|
if not path.exists():
|
|
print(f"ERROR: {raw}: not found")
|
|
missing += 1
|
|
continue
|
|
try:
|
|
doc = fitz.open(str(path))
|
|
except Exception as exc:
|
|
print(f"ERROR: {raw}: could not read PDF: {exc}")
|
|
missing += 1
|
|
continue
|
|
scanned += 1
|
|
rel = rel_to_root(path)
|
|
for page_num in range(len(doc)):
|
|
page = doc[page_num]
|
|
blocks = page.get_text("blocks")
|
|
for bx0, by0, bx1, by1, text, block_no, block_type in blocks:
|
|
if block_type != 0: # text blocks only
|
|
continue
|
|
last = _orphan_last_line(text, max_words, max_chars)
|
|
if last is not None:
|
|
total += 1
|
|
print(f" {rel} p{page_num + 1}: orphan: \"{last}\" ({len(last.split())} word(s), {len(last)} chars)")
|
|
doc.close()
|
|
|
|
if scanned == 0:
|
|
print(f"ERROR: no PDFs scanned ({missing} missing)")
|
|
return 2
|
|
|
|
if total == 0 and missing == 0:
|
|
print(f"OK: no orphans found across {scanned} PDF(s)")
|
|
return 0
|
|
|
|
if total:
|
|
print(f"\n{total} orphan(s) found across {scanned} PDF(s)")
|
|
if missing:
|
|
print(f"{missing} input(s) missing")
|
|
return 1
|
|
|
|
|
|
# ---------- density check ----------
|
|
|
|
def _last_content_y(samples: bytes, w: int, h: int, stride: int, n: int) -> int:
|
|
"""Return the highest y row index that contains non-parchment content.
|
|
|
|
Uses numpy when available (vectorized scan, ~50-100x faster on multi-page
|
|
PDFs); falls back to a pure Python loop otherwise. Both paths sample every
|
|
fourth column for parity, so the result is identical.
|
|
"""
|
|
try:
|
|
import numpy as np
|
|
except ImportError:
|
|
last_y = 0
|
|
for y in range(h - 1, -1, -1):
|
|
row_start = y * stride
|
|
is_bg = True
|
|
for x in range(0, w, 4):
|
|
offset = row_start + x * n
|
|
if (abs(samples[offset] - _BG_R) > _BG_TOLERANCE
|
|
or abs(samples[offset + 1] - _BG_G) > _BG_TOLERANCE
|
|
or abs(samples[offset + 2] - _BG_B) > _BG_TOLERANCE):
|
|
is_bg = False
|
|
break
|
|
if not is_bg:
|
|
last_y = y
|
|
break
|
|
return last_y
|
|
|
|
arr = np.frombuffer(samples, dtype=np.uint8).reshape((h, stride))
|
|
pixels = arr[:, : w * n].reshape((h, w, n))
|
|
rgb = pixels[:, ::4, :3].astype(np.int16)
|
|
bg = np.array([_BG_R, _BG_G, _BG_B], dtype=np.int16)
|
|
row_is_bg = (np.abs(rgb - bg).max(axis=2) <= _BG_TOLERANCE).all(axis=1)
|
|
non_bg = np.where(~row_is_bg)[0]
|
|
return int(non_bg[-1]) if non_bg.size else 0
|
|
|
|
|
|
def _density_bucket(empty: float, warn_pct: float, sparse_pct: float) -> str:
|
|
"""Categorize a page by its trailing-whitespace fraction.
|
|
|
|
Pure so `_scan_density` and its tests share one decision. A test that
|
|
reimplements these comparisons would stay green if the real operators
|
|
drifted (`>` to `>=`, or warn/sparse swapped); calling this keeps the
|
|
assertion anchored to production logic.
|
|
"""
|
|
if empty > sparse_pct:
|
|
return "SPARSE"
|
|
if empty > warn_pct:
|
|
return "WARN"
|
|
return "OK"
|
|
|
|
|
|
def _scan_density(paths: list[str]) -> tuple[int, int, int, int] | None:
|
|
"""Scan PDFs and print SPARSE/WARN lines.
|
|
|
|
Returns (sparse, warn, missing, scanned), or None if PyMuPDF is missing.
|
|
Thresholds (warn_pct, sparse_pct, dpi) come from
|
|
references/checks_thresholds.json.
|
|
"""
|
|
try:
|
|
fitz = require_pymupdf()
|
|
except MissingDepError as exc:
|
|
print(f"ERROR: {exc}")
|
|
return None
|
|
|
|
density_cfg = load_checks_thresholds()["density"]
|
|
warn_pct = float(density_cfg["warn_pct"])
|
|
sparse_pct = float(density_cfg["sparse_pct"])
|
|
dpi = int(density_cfg["dpi"])
|
|
|
|
sparse = 0
|
|
warn = 0
|
|
missing = 0
|
|
scanned = 0
|
|
for raw in paths:
|
|
path = Path(raw)
|
|
if not path.exists():
|
|
print(f"ERROR: {raw}: not found")
|
|
missing += 1
|
|
continue
|
|
try:
|
|
doc = fitz.open(str(path))
|
|
except Exception as exc:
|
|
print(f"ERROR: {raw}: could not read PDF: {exc}")
|
|
missing += 1
|
|
continue
|
|
scanned += 1
|
|
rel = rel_to_root(path)
|
|
for page_num in range(len(doc)):
|
|
if page_num == 0:
|
|
continue
|
|
page = doc[page_num]
|
|
pix = page.get_pixmap(dpi=dpi)
|
|
w, h = pix.width, pix.height
|
|
if h == 0:
|
|
continue
|
|
last_content_y = _last_content_y(pix.samples, w, h, pix.stride, pix.n)
|
|
|
|
empty = (h - last_content_y) / h
|
|
bucket = _density_bucket(empty, warn_pct, sparse_pct)
|
|
if bucket == "SPARSE":
|
|
print(f" SPARSE: {rel} p{page_num + 1}: {empty:.0%} trailing whitespace")
|
|
sparse += 1
|
|
elif bucket == "WARN":
|
|
print(f" WARN: {rel} p{page_num + 1}: {empty:.0%} trailing whitespace")
|
|
warn += 1
|
|
doc.close()
|
|
return sparse, warn, missing, scanned
|
|
|
|
|
|
def check_density(paths: list[str]) -> int:
|
|
"""Scan PDF pages for sparse content (large trailing whitespace from
|
|
break-inside:avoid pushing content to the next page)."""
|
|
if not paths:
|
|
paths = default_example_pdfs()
|
|
if not paths:
|
|
print("ERROR: no PDF files to scan")
|
|
return 2
|
|
|
|
result = _scan_density(paths)
|
|
if result is None:
|
|
return 2
|
|
sparse, warn, missing, scanned = result
|
|
|
|
if scanned == 0:
|
|
print(f"ERROR: no PDFs scanned ({missing} missing)")
|
|
return 2
|
|
|
|
total = sparse + warn
|
|
if total == 0 and missing == 0:
|
|
print(f"OK: no density issues across {scanned} PDF(s)")
|
|
return 0
|
|
|
|
if total:
|
|
print(f"\n{total} density warning(s) across {scanned} PDF(s)")
|
|
if missing:
|
|
print(f"{missing} input(s) missing")
|
|
return 1
|
|
|
|
|
|
# ---------- resume balance check ----------
|
|
|
|
def _resume_balance_issues(
|
|
fills: list[float],
|
|
page_count: int,
|
|
min_fill: float,
|
|
max_fill: float,
|
|
max_gap: float,
|
|
) -> list[str]:
|
|
"""Return human-readable resume balance failures for unit tests and CLI."""
|
|
issues: list[str] = []
|
|
if page_count != 2:
|
|
issues.append(f"{page_count} pages (expected 2)")
|
|
|
|
for index, fill in enumerate(fills[:2], start=1):
|
|
if fill < min_fill:
|
|
issues.append(f"p{index} fill {fill:.0%} below {min_fill:.0%}")
|
|
elif fill > max_fill:
|
|
issues.append(f"p{index} fill {fill:.0%} above {max_fill:.0%}")
|
|
|
|
if len(fills) >= 2:
|
|
gap = abs(fills[0] - fills[1])
|
|
if gap > max_gap:
|
|
issues.append(f"page fill gap {gap:.0%} above {max_gap:.0%}")
|
|
|
|
return issues
|
|
|
|
|
|
def _resume_page_fills(path: Path, dpi: int) -> tuple[list[float], int] | None:
|
|
try:
|
|
fitz = require_pymupdf()
|
|
except MissingDepError as exc:
|
|
print(f"ERROR: {exc}")
|
|
return None
|
|
|
|
try:
|
|
doc = fitz.open(str(path))
|
|
except Exception as exc:
|
|
print(f"ERROR: {path}: could not read PDF: {exc}")
|
|
return None
|
|
fills: list[float] = []
|
|
for page in doc:
|
|
pix = page.get_pixmap(dpi=dpi)
|
|
if pix.height == 0:
|
|
fills.append(0.0)
|
|
continue
|
|
last_content_y = _last_content_y(pix.samples, pix.width, pix.height, pix.stride, pix.n)
|
|
fills.append((last_content_y + 1) / pix.height)
|
|
page_count = len(doc)
|
|
doc.close()
|
|
return fills, page_count
|
|
|
|
|
|
def check_resume_balance(paths: list[str]) -> int:
|
|
"""Require resume PDFs to be exactly 2 pages with balanced content fill."""
|
|
if not paths:
|
|
print("ERROR: provide at least one filled resume PDF to scan")
|
|
return 2
|
|
|
|
# Resolve the dependency once up front so a missing PyMuPDF stays a
|
|
# tooling error (exit 2) while a single unreadable PDF inside the loop
|
|
# tallies as missing and lets the remaining files still get scanned.
|
|
try:
|
|
require_pymupdf()
|
|
except MissingDepError as exc:
|
|
print(f"ERROR: {exc}")
|
|
return 2
|
|
|
|
cfg = load_checks_thresholds()["resume_balance"]
|
|
min_fill = float(cfg["min_fill_pct"])
|
|
max_fill = float(cfg["max_fill_pct"])
|
|
max_gap = float(cfg["max_gap_pct"])
|
|
dpi = int(cfg["dpi"])
|
|
|
|
failures = 0
|
|
missing = 0
|
|
scanned = 0
|
|
for raw in paths:
|
|
path = Path(raw)
|
|
if not path.exists():
|
|
print(f"ERROR: {raw}: not found")
|
|
missing += 1
|
|
continue
|
|
|
|
result = _resume_page_fills(path, dpi)
|
|
if result is None:
|
|
missing += 1
|
|
continue
|
|
|
|
fills, page_count = result
|
|
scanned += 1
|
|
rel = rel_to_root(path)
|
|
fill_text = " / ".join(f"{fill:.0%}" for fill in fills)
|
|
issues = _resume_balance_issues(fills, page_count, min_fill, max_fill, max_gap)
|
|
if issues:
|
|
failures += 1
|
|
print(f"ERROR: {rel}: {fill_text} ({'; '.join(issues)})")
|
|
else:
|
|
gap = abs(fills[0] - fills[1])
|
|
print(f"OK: {rel}: 2 pages, fill {fill_text}, gap {gap:.0%}")
|
|
|
|
if scanned == 0:
|
|
print(f"ERROR: no PDFs scanned ({missing} missing)")
|
|
return 2
|
|
if missing:
|
|
print(f"{missing} input(s) missing")
|
|
return 0 if failures == 0 and missing == 0 else 1
|
|
|
|
|
|
# ---------- rhythm check ----------
|
|
|
|
# Layout functions that count as "divider" slides (break monotony).
|
|
_DIVIDER_FUNCS = {"chapter_slide"}
|
|
# Layout functions that count as "density variation" slides.
|
|
_DENSITY_VARIATION_FUNCS = {"quote_slide", "metrics_slide"}
|
|
# Layout function call pattern in slides.py source.
|
|
_SLIDE_CALL = re.compile(r"^\s*(\w+_slide)\s*\(")
|
|
|
|
|
|
def _parse_slide_sequence(src: Path) -> list[str]:
|
|
"""Return the ordered list of slide-function names called in main()."""
|
|
text = src.read_text(encoding="utf-8", errors="replace")
|
|
in_main = False
|
|
sequence: list[str] = []
|
|
for line in text.splitlines():
|
|
if re.match(r"^def main\s*\(", line):
|
|
in_main = True
|
|
continue
|
|
if in_main and re.match(r"^def \w", line):
|
|
break
|
|
if in_main:
|
|
m = _SLIDE_CALL.match(line)
|
|
if m:
|
|
sequence.append(m.group(1))
|
|
return sequence
|
|
|
|
|
|
def _rhythm_issues(seq: list[str], max_content_run: int, divider_min_deck_size: int) -> list[str]:
|
|
"""Return the rhythm warnings for one parsed slide sequence.
|
|
|
|
Pure so the three monotony rules are unit-testable without rendering a
|
|
deck, matching the `_resume_balance_issues` seam.
|
|
"""
|
|
issues: list[str] = []
|
|
|
|
# Rule 1: no run of more than `max_content_run` consecutive content_slides.
|
|
run = 0
|
|
max_run = 0
|
|
for fn in seq:
|
|
if fn == "content_slide":
|
|
run += 1
|
|
max_run = max(max_run, run)
|
|
else:
|
|
run = 0
|
|
if max_run > max_content_run:
|
|
issues.append(f"longest content_slide run is {max_run} (limit {max_content_run})")
|
|
|
|
# Rule 2: large decks need at least one chapter_slide divider.
|
|
if len(seq) >= divider_min_deck_size and not any(fn in _DIVIDER_FUNCS for fn in seq):
|
|
issues.append(f"{len(seq)} slides with no chapter_slide divider")
|
|
|
|
# Rule 3: deck must contain at least one density-variation slide.
|
|
if not any(fn in _DENSITY_VARIATION_FUNCS for fn in seq):
|
|
issues.append("no quote_slide or metrics_slide for density variation")
|
|
|
|
return issues
|
|
|
|
|
|
def check_rhythm(targets: list[str], pptx_targets: dict[str, str], templates_dir: Path) -> int:
|
|
"""Scan slide templates for monotony: too many consecutive content_slides,
|
|
missing dividers, and missing density variation.
|
|
|
|
Thresholds come from references/checks_thresholds.json.
|
|
"""
|
|
names = targets if targets else list(pptx_targets.keys())
|
|
failures = 0
|
|
rhythm_cfg = load_checks_thresholds()["rhythm"]
|
|
max_content_run = int(rhythm_cfg["max_content_run"])
|
|
divider_min_deck_size = int(rhythm_cfg["divider_min_deck_size"])
|
|
|
|
for name in names:
|
|
source = pptx_targets.get(name)
|
|
if source is None:
|
|
print(f"ERROR: {name}: not a known slides target")
|
|
failures += 1
|
|
continue
|
|
src = templates_dir / source
|
|
if not src.exists():
|
|
print(f"ERROR: {name}: source not found ({src})")
|
|
failures += 1
|
|
continue
|
|
|
|
seq = _parse_slide_sequence(src)
|
|
if not seq:
|
|
print(f"ERROR: {name}: no slide calls found in main() (deck unparseable)")
|
|
failures += 1
|
|
continue
|
|
|
|
issues = _rhythm_issues(seq, max_content_run, divider_min_deck_size)
|
|
|
|
if issues:
|
|
# These fail the run (exit 1), so label them ERROR; WARN is
|
|
# reserved for advisory output that does not gate the build.
|
|
for issue in issues:
|
|
print(f"ERROR: {name}: {issue}")
|
|
failures += 1
|
|
else:
|
|
content_run = 0
|
|
max_run = 0
|
|
for fn in seq:
|
|
content_run = content_run + 1 if fn == "content_slide" else 0
|
|
max_run = max(max_run, content_run)
|
|
print(f"OK: {name}: rhythm ok ({len(seq)} slides, max run {max_run})")
|
|
|
|
return 0 if failures == 0 else 1
|