#!/usr/bin/env python3 """CLI for slide dimension / overflow / blank-region / overlap checks. Renders each HTML slide in headless Chromium (via Playwright) at 1280x720 and reports vertical overflow, large blank regions, and text-vs-text overlaps. Standalone — does not import from any sibling agent package. Usage: # Check specific files (preferred — matches whatever naming the brief uses): python check_slides.py /work/slides/slide_01.html /work/slides/slide_02.html # Check every *.html in a directory (sorted by filename): python check_slides.py --dir /work/slides # Restrict to a few positions when --dir is used (matches files whose stem # ends in those numbers, e.g. `1.html`, `slide_01.html`): python check_slides.py --dir /work/slides --positions 1 3 5 # JSON output for downstream tooling: python check_slides.py --dir /work/slides --json --output /work/check.json Stdout is a compact text report by default; pass --json for the raw measurement dict per file. Exit code is 0 when every checked slide fits the viewport vertically with no large blank / text overlap, otherwise 1. """ from __future__ import annotations import argparse import asyncio import json import re import sys import traceback from pathlib import Path _VIEWPORT_W = 1280 _VIEWPORT_H = 720 _MAX_CONCURRENCY = 81 _PROXY = {"server": "http://httpproxy.glm.ai:8888"} _TEXT_OVERLAP_JS = r""" (opts) => { const minOverlapPx = opts.minOverlapPx || 3; const maxPairs = opts.maxPairs || 50; // Relative overlap threshold: the intersection must consume at least // `relMin` of the smaller element's corresponding side on BOTH axes. // Suppresses line-height bleed where vertically-stacked siblings share // 3-5px of line-box overhang without true visual collision. const relMin = opts.relMin != null ? opts.relMin : 0.10; // Decorative-text thresholds (mirrors genspark html2pptx checkTextOverlaps): // - opacity ≤ 0.2 → watermark / ghost layer // - font-size ≥ 150px → giant ghost numeral / chapter mark const decorOpacity = opts.decorOpacity != null ? opts.decorOpacity : 0.2; const decorFontPx = opts.decorFontPx || 150; function ownText(el) { let t = ''; for (const n of el.childNodes) { if (n.nodeType === Node.TEXT_NODE) t += n.textContent; } return t.trim(); } // Effective opacity = product of opacity along ancestor chain (CSS-correct). // Returns 0 if any ancestor is display:none / visibility:hidden. function effectiveOpacity(el) { let op = 1; let cur = el; while (cur && cur !== document.body) { const s = getComputedStyle(cur); if (s.display === 'none' || s.visibility === 'hidden') return 0; const o = parseFloat(s.opacity); if (!isNaN(o)) op *= o; cur = cur.parentElement; } return op; } // First ancestor (incl. self) that establishes a stacking context with a // numeric z-index. Returns 0 if none — matches "auto" → 0 for sibling // comparison purposes. We don't try to fully simulate the stacking algorithm; // this is a heuristic to skip *intentional* layered decorations. function effectiveZ(el) { let cur = el; while (cur && cur !== document.body) { const s = getComputedStyle(cur); if (s.position && s.position !== 'static') { const z = parseInt(s.zIndex, 10); if (!isNaN(z)) return z; } cur = cur.parentElement; } return 0; } // Restrict reporting to text inside the slide canvas — body padding / preview // gaps / off-canvas overflow shouldn't surface as overlap noise. const canvas = document.querySelector('.slide, .slide-canvas'); const canvasRect = canvas ? canvas.getBoundingClientRect() : null; function insideCanvas(r) { if (!canvasRect) return true; // Require ≥50% of the rect's area to lie inside the canvas. const x = Math.max(r.x, canvasRect.left); const y = Math.max(r.y, canvasRect.top); const w = Math.min(r.x + r.w, canvasRect.right) - x; const h = Math.min(r.y + r.h, canvasRect.bottom) - y; if (w <= 0 || h <= 0) return false; return (w * h) >= 0.5 * (r.w * r.h); } const items = []; const walker = document.querySelectorAll('body *'); for (const el of walker) { const text = ownText(el); if (!text) continue; const op = effectiveOpacity(el); if (op === 0) continue; const cs = getComputedStyle(el); const fontPx = parseFloat(cs.fontSize) || 0; const decorative = op <= decorOpacity || fontPx >= decorFontPx; const z = effectiveZ(el); const rects = el.getClientRects(); if (!rects.length) continue; for (const r of rects) { if (r.width < 2 || r.height < 2) continue; const rect = {x: r.x, y: r.y, w: r.width, h: r.height}; if (!insideCanvas(rect)) continue; items.push({ el, text, decorative, z, fontPx, tag: el.tagName.toLowerCase(), rect, }); } } function rel(a, b) { return a.contains(b) || b.contains(a); } function inter(r1, r2) { const x = Math.max(r1.x, r2.x); const y = Math.max(r1.y, r2.y); const w = Math.min(r1.x + r1.w, r2.x + r2.w) - x; const h = Math.min(r1.y + r1.h, r2.y + r2.h) - y; if (w < minOverlapPx || h < minOverlapPx) return null; // Relative gate: overlap must occupy ≥ relMin of the smaller side. if (w < relMin * Math.min(r1.w, r2.w)) return null; if (h < relMin * Math.min(r1.h, r2.h)) return null; return {x, y, w, h}; } function trunc(s, n) { return s.length > n ? s.slice(0, n) + '…' : s; } function fmtRect(r) { return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.w), h: Math.round(r.h), }; } // Dedupe by element pair: a 4-line
overlapping another 4-line
via
// getClientRects() would otherwise produce up to 16 entries. Keep the
// largest overlap rect per (elA, elB) pair.
const seen = new Map(); // key → {a, b, overlap}
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
const A = items[i], B = items[j];
if (A.el === B.el) continue;
if (rel(A.el, B.el)) continue;
// Skip intentional layered decorations (genspark parity).
if (A.decorative || B.decorative) continue;
if (A.z !== B.z) continue;
const ov = inter(A.rect, B.rect);
if (!ov) continue;
// Stable pair key: use DOM position to order.
const order = A.el.compareDocumentPosition(B.el) & Node.DOCUMENT_POSITION_FOLLOWING;
const first = order ? A : B;
const second = order ? B : A;
const key = first.tag + ' ' + first.text + ' '
+ second.tag + ' ' + second.text;
const area = ov.w * ov.h;
const prev = seen.get(key);
if (!prev || area > prev._area) {
seen.set(key, {
a: {tag: first.tag, text: trunc(first.text, 80), rect: fmtRect(first.rect)},
b: {tag: second.tag, text: trunc(second.text, 80), rect: fmtRect(second.rect)},
overlap: fmtRect(ov),
_area: area,
});
}
}
}
const overlaps = [];
for (const v of seen.values()) {
delete v._area;
overlaps.push(v);
if (overlaps.length >= maxPairs) break;
}
return overlaps;
}
"""
def _detect_largest_blank(screenshot_bytes: bytes,
color_tolerance: int = 15,
downsample: int = 4,
decor_rects: list[dict] | None = None) -> dict | None:
"""Find the largest contiguous background-colored rectangle in a screenshot.
Background color is sampled from the four corners (median). A pixel is
"non-background" if its max channel diff from the sample exceeds
`color_tolerance`. The image is downsampled by `downsample`× for the
rectangle search (1280x720 → 320x180 by default), then results are scaled
back to original pixels. Returns None on failure (e.g. PIL/numpy missing).
`decor_rects` (list of {x, y, w, h} in screenshot pixels) marks regions
occupied by decorative elements — those rects are forced to "non-
background" in the mask so faint / ghost decoration that blends with the
bg color does not register as a false blank patch.
"""
try:
import io
import numpy as np
from PIL import Image
except Exception:
return None
try:
img = Image.open(io.BytesIO(screenshot_bytes)).convert("RGB")
arr = np.array(img, dtype=np.uint8)
H, W, _ = arr.shape
if H < 8 or W < 8:
return None
corner = max(4, min(W, H) // 64)
samples = np.concatenate([
arr[:corner, :corner].reshape(-1, 3),
arr[:corner, -corner:].reshape(-1, 3),
arr[-corner:, :corner].reshape(-1, 3),
arr[-corner:, -corner:].reshape(-1, 3),
], axis=0).astype(np.int32)
bg = np.median(samples, axis=0)
diff = np.abs(arr.astype(np.int32) - bg).max(axis=2)
nonbg = (diff > color_tolerance).astype(np.uint8)
# Mask out decorative element rects: their visual area is intentional
# design, not blank. Without this, a low-opacity ghost numeral or
# gradient blob whose pixels blend into bg would show up as one big
# contiguous blank region.
if decor_rects:
for r in decor_rects:
x0 = max(0, int(round(r.get("x", 0))))
y0 = max(0, int(round(r.get("y", 0))))
x1 = min(W, int(round(r.get("x", 0) + r.get("w", 0))))
y1 = min(H, int(round(r.get("y", 0) + r.get("h", 0))))
if x1 > x0 and y1 > y0:
nonbg[y0:y1, x0:x1] = 1
s = max(1, downsample)
Hd, Wd = H // s, W // s
if Hd < 2 or Wd < 2:
return None
# Pool: cell is "blocked" if any underlying pixel is non-background.
nonbg_d = nonbg[:Hd * s, :Wd * s].reshape(Hd, s, Wd, s).max(axis=(1, 3))
heights = [0] * Wd
best_area = 0
best = (0, 0, 0, 0) # x, y, w, h in downsampled units
for r in range(Hd):
row = nonbg_d[r]
for c in range(Wd):
heights[c] = 0 if row[c] else heights[c] + 1
stack = [] # (start_index, height)
for c in range(Wd + 1):
cur = heights[c] if c < Wd else 0
start = c
while stack and stack[-1][1] > cur:
idx, h = stack.pop()
w = c - idx
area = w * h
if area > best_area:
best_area = area
best = (idx, r - h + 1, w, h)
start = idx
stack.append((start, cur))
x_d, y_d, w_d, h_d = best
x, y, w, h = x_d * s, y_d * s, w_d * s, h_d * s
return {
"x": int(x), "y": int(y), "w": int(w), "h": int(h),
"area_ratio": round((w * h) / float(W * H), 3),
"bg_rgb": [int(c) for c in bg],
}
except Exception:
return None
async def measure_slide_dimensions(html_content, viewport_width=1280, viewport_height=720, proxy=None,
detect_overlaps=True, detect_blank=True,
blank_min_area_ratio=0.25, blank_min_side=180,
overflow_tolerance_ratio=0.005, overflow_tolerance_px=3,
file_path: str | None = None):
"""Render HTML at the given viewport and report ACTUAL content width + height.
Returns a dict with success bool, plus on success: width, height,
viewport_width, viewport_height, overflow_x (px past viewport, 0 if fits),
overflow_y (px past viewport, 0 if fits), `details` subdict with raw
browser measurements, `overlaps` (when detect_overlaps): a list of
text-vs-text overlapping pairs each shaped {a: {tag, text, rect}, b:
{tag, text, rect}, overlap: {x,y,w,h}}, and `blank` (when detect_blank):
{x, y, w, h, area_ratio, bg_rgb, flagged: bool} for the largest contiguous
background-colored region. `flagged` is True only when area_ratio ≥
blank_min_area_ratio AND min(w, h) ≥ blank_min_side.
Notes:
- Width and height are both measured (not assumed = viewport_*) and
reported. Horizontal AND vertical overflow are flagged when content
extends past the viewport beyond the tolerance buffer.
- If the slide root uses `overflow:hidden` the inner content is clipped
for the user — we measure content-bearing children to surface the
underlying content size when possible. Elements clipped by any
ancestor with `overflow:hidden|clip` have their rect intersected
with the clipping box before contributing to overflow, so bleed
decoration that's invisible to the user is not flagged.
- Decorative elements are excluded from overflow measurement when
ANY of these hold: (a) the element (or an ancestor) carries
`data-decor` or `aria-hidden="true"`, (b) effective opacity along
the ancestor chain is ≤ 0.2 (ghost / watermark layer). Authors
should tag intentional background blobs, oversized ghost numerals,
and bleed shapes with `data-decor` to silence false-positive
overflow flags.
- Overlap detection only flags pairs of visible text-bearing elements
that are NOT in an ancestor/descendant relationship and whose
bounding rects intersect by ≥3px on both axes (suppresses border
touches) AND by ≥20% of the smaller element's corresponding side
(suppresses line-height bleed in vertically-stacked siblings).
Capped at 50 pairs per slide. Filters that mirror the
genspark html2pptx checker:
* effective opacity ≤ 0.2 → decorative watermark, skipped
* font-size ≥ 150px → ghost numeral / chapter mark, skipped
* different effective z-index → intentional layering, skipped
* rect mostly outside `.slide` canvas (preview chrome / cropped
overflow) → skipped
Multi-line text pairs are deduped to one entry per element pair
(largest overlap rect wins).
- Blank detection samples background color from the four corners and
finds the largest rectangle whose pixels are all within
`color_tolerance` (15) of that color. Operates on a 4×-downsampled
screenshot for speed. Decorative element rects (same opt-out as
the overflow check: `data-decor` / `aria-hidden="true"`, plus
elements whose effective opacity is ≤ 0.2) are forced to "non-
background" in the mask, so faint ghost decoration that blends
with the bg color is not flagged as a large blank region.
"""
from playwright.async_api import async_playwright
try:
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True, proxy=proxy)
page = await browser.new_page()
await page.set_viewport_size({"width": viewport_width, "height": viewport_height})
# When a real file path is supplied, navigate to it via file://
# so relative resources (global.css, images/, fonts) resolve.
# set_content has no base URL and would render the slide
# unstyled — which collapses layouts and hides real overflow.
if file_path:
await page.goto(f"file://{file_path}")
else:
await page.set_content(html_content)
await page.wait_for_load_state("networkidle")
await page.wait_for_timeout(2000)
dims = await page.evaluate("""() => {
// Canvas = the .slide element. With overflow:hidden|clip, decorative
// absolute children (background blobs, bleed shapes) extend past the
// box but are visually clipped — using scrollWidth/Height would count
// them as overflow. Instead: use the declared box as canvas, then
// measure overflow from CONTENT-BEARING elements (text + media) only.
const slides = document.querySelectorAll('.slide, .slide-canvas');
const ContentTags = new Set(['IMG','SVG','VIDEO','CANVAS','PICTURE','IFRAME']);
function ownText(el){
let t='';
for (const n of el.childNodes){
if (n.nodeType === Node.TEXT_NODE) t += n.textContent;
}
return t.trim();
}
function isContent(el){
if (ContentTags.has(el.tagName)) return true;
return ownText(el).length > 0;
}
// Effective opacity along the ancestor chain. Returns 0 if any
// ancestor is display:none / visibility:hidden — collapses the
// old isVisible check into the same signal.
function effectiveOpacity(el){
let op = 1;
let cur = el;
while (cur && cur !== document.body){
const s = getComputedStyle(cur);
if (s.display === 'none' || s.visibility === 'hidden') return 0;
const o = parseFloat(s.opacity);
if (!isNaN(o)) op *= o;
cur = cur.parentElement;
}
return op;
}
// Three signals that an element is decorative and should not
// contribute to overflow:
// 1. Author opt-out via data-decor / aria-hidden="true".
// 2. Effective opacity ≤ 0.2 (mirrors the text-overlap
// checker's ghost / watermark threshold).
function isDecorative(el){
if (el.closest('[data-decor], [aria-hidden="true"]')) return true;
if (effectiveOpacity(el) <= 0.2) return true;
return false;
}
// Walk ancestors UP TO (but not including) the slide root;
// if any inner ancestor clips overflow, intersect the rect
// with the clipping box. Decoration that bleeds past an
// inner card / panel and is hidden by overflow:hidden|clip
// is invisible to the user and must not be flagged. We
// intentionally do NOT clip by the .slide canvas itself —
// even when .slide has overflow:hidden (it almost always
// does), content that wants to extend past 1280x720 is
// still bad layout and must surface as overflow.
function visibleRect(el, slide){
let r = el.getBoundingClientRect();
let left = r.left, top = r.top, right = r.right, bottom = r.bottom;
let cur = el.parentElement;
while (cur && cur !== slide && cur !== document.body){
const s = getComputedStyle(cur);
const clipped = (
s.overflow === 'hidden' || s.overflow === 'clip' ||
s.overflowX === 'hidden' || s.overflowX === 'clip' ||
s.overflowY === 'hidden' || s.overflowY === 'clip'
);
if (clipped){
const cr = cur.getBoundingClientRect();
left = Math.max(left, cr.left);
top = Math.max(top, cr.top);
right = Math.min(right, cr.right);
bottom = Math.min(bottom, cr.bottom);
if (right <= left || bottom <= top) return null;
}
cur = cur.parentElement;
}
return {left, top, right, bottom,
width: right - left, height: bottom - top};
}
function maxOverflow(slide){
const cr = slide.getBoundingClientRect();
let ox = 0, oy = 0;
for (const el of slide.querySelectorAll('*')){
if (!isContent(el)) continue;
if (isDecorative(el)) continue;
const r = visibleRect(el, slide);
if (!r || r.width < 1 || r.height < 1) continue;
ox = Math.max(ox, r.right - cr.right, cr.left - r.left);
oy = Math.max(oy, r.bottom - cr.bottom, cr.top - r.top);
}
return {ox: Math.max(0, ox), oy: Math.max(0, oy),
cw: slide.offsetWidth, ch: slide.offsetHeight};
}
if (slides.length){
let cw=0, ch=0, ox=0, oy=0;
for (const s of slides){
const m = maxOverflow(s);
cw = Math.max(cw, m.cw);
ch = Math.max(ch, m.ch);
ox = Math.max(ox, m.ox);
oy = Math.max(oy, m.oy);
}
return {scrollWidth: cw + ox, scrollHeight: ch + oy,
clientWidth: cw, clientHeight: ch};
}
const body = document.body;
const html = document.documentElement;
return {
scrollWidth: Math.max(body.scrollWidth, html.scrollWidth, body.offsetWidth, html.offsetWidth),
scrollHeight: Math.max(body.scrollHeight, html.scrollHeight, body.offsetHeight, html.offsetHeight),
clientWidth: html.clientWidth,
clientHeight: html.clientHeight,
};
}""")
overlaps = []
if detect_overlaps:
overlaps = await page.evaluate(_TEXT_OVERLAP_JS, {"minOverlapPx": 3, "maxPairs": 50})
screenshot_bytes = None
decor_rects = []
if detect_blank:
# Bounding rects of decorative elements, in viewport pixel
# coordinates that match the screenshot clip. Used by the
# blank detector to avoid flagging intentional decoration as
# a blank patch.
decor_rects = await page.evaluate(r"""() => {
const out = [];
function pushRects(el){
const rs = el.getClientRects();
for (const r of rs){
if (r.width >= 1 && r.height >= 1){
out.push({x: r.x, y: r.y, w: r.width, h: r.height});
}
}
}
// Explicit author opt-out.
for (const el of document.querySelectorAll('[data-decor], [aria-hidden="true"]')){
pushRects(el);
}
// Effective-opacity heuristic: ghost / watermark layer.
function effOpacity(el){
let op = 1;
let cur = el;
while (cur && cur !== document.body){
const s = getComputedStyle(cur);
if (s.display === 'none' || s.visibility === 'hidden') return 0;
const o = parseFloat(s.opacity);
if (!isNaN(o)) op *= o;
cur = cur.parentElement;
}
return op;
}
for (const el of document.querySelectorAll('body *')){
if (el.closest('[data-decor], [aria-hidden="true"]')) continue;
const op = effOpacity(el);
if (op > 0 && op <= 0.2) pushRects(el);
}
return out;
}""")
screenshot_bytes = await page.screenshot(
clip={"x": 0, "y": 0, "width": viewport_width, "height": viewport_height},
type="png",
)
await browser.close()
width = int(dims["scrollWidth"])
height = int(dims["scrollHeight"])
# Tolerance is ratio + fixed buffer (default 2% + 5px → ~19px at 720 /
# ~30px at 1280) to absorb sub-pixel rendering noise and line-box
# bleed. Both axes use the same ratio + buffer formula, scaled to the
# axis's viewport length.
tol_y = int(viewport_height * overflow_tolerance_ratio) + overflow_tolerance_px
tol_x = int(viewport_width * overflow_tolerance_ratio) + overflow_tolerance_px
result = {
"success": True,
"width": width,
"height": height,
"viewport_width": viewport_width,
"viewport_height": viewport_height,
"overflow_x": max(0, width - viewport_width - tol_x),
"overflow_y": max(0, height - viewport_height - tol_y),
"details": dims,
"overlaps": overlaps,
}
if detect_blank and screenshot_bytes is not None:
blank = _detect_largest_blank(screenshot_bytes, decor_rects=decor_rects)
if blank is not None:
blank["flagged"] = (
blank["area_ratio"] >= blank_min_area_ratio
and min(blank["w"], blank["h"]) >= blank_min_side
)
result["blank"] = blank
return result
except Exception:
return {"success": False, "error": traceback.format_exc()}
def _trailing_int(stem: str) -> int | None:
m = re.search(r"(\d+)$", stem)
return int(m.group(1)) if m else None
def _collect_targets(args) -> list[Path]:
if args.files:
return [Path(f).resolve() for f in args.files]
base = Path(args.dir).resolve()
if not base.is_dir():
raise SystemExit(f"check_slides: --dir {base} is not a directory")
files = sorted(base.glob("*.html"), key=lambda p: (_trailing_int(p.stem) or 0, p.name))
if args.positions:
wanted = set(args.positions)
files = [p for p in files if (_trailing_int(p.stem) in wanted)]
return files
async def _measure_one(path: Path, sem: asyncio.Semaphore) -> tuple[Path, dict]:
try:
html = path.read_text(encoding="utf-8")
except Exception as exc:
return path, {"success": False, "error": f"read failed: {exc}"}
async with sem:
result = await measure_slide_dimensions(
html,
viewport_width=_VIEWPORT_W,
viewport_height=_VIEWPORT_H,
proxy=_PROXY,
detect_overlaps=True,
detect_blank=True,
file_path=str(path),
)
return path, result
async def _measure_all(targets: list[Path]) -> list[tuple[Path, dict]]:
# One event loop drives every slide concurrently; the semaphore caps how
# many headless browsers run at once so a large deck can't exhaust memory.
sem = asyncio.Semaphore(min(_MAX_CONCURRENCY, len(targets)))
return await asyncio.gather(*(_measure_one(p, sem) for p in targets))
def _format_report(results: list[tuple[Path, dict]]) -> tuple[str, bool]:
lines = [
f"Checked {len(results)} slide(s) at viewport {_VIEWPORT_W}x{_VIEWPORT_H} "
f"(actual rendered width × height; '✗' marks overflow, overlap, or large blank):"
]
overflow_count = blank_count = overlap_count = fail_count = ok_count = 0
for path, r in results:
label = path.name
if not r.get("success"):
err = (r.get("error") or "unknown")
err = err.strip().splitlines()[-1][:140] if err else "unknown"
lines.append(f"- {label}: render FAILED — {err}")
fail_count += 1
continue
w, h = r["width"], r["height"]
ox = r.get("overflow_x", 0)
oy = r.get("overflow_y", 0)
blank = r.get("blank") or {}
overlaps = r.get("overlaps") or []
flags = []
if ox > 0:
flags.append(f"horizontal overflow +{ox}px")
if oy > 0:
flags.append(f"vertical overflow +{oy}px")
if blank.get("flagged"):
flags.append(
f"large blank region {blank['w']}x{blank['h']}px "
f"@ ({blank['x']},{blank['y']}) ≈ {int(blank['area_ratio'] * 100)}% of canvas"
)
if overlaps:
flags.append(f"{len(overlaps)} text overlap(s)")
if flags:
if ox > 0 or oy > 0:
overflow_count += 1
if blank.get("flagged"):
blank_count += 1
if overlaps:
overlap_count += 1
lines.append(f"- {label}: {w}x{h} ✗ {', '.join(flags)}")
for o in overlaps[:5]:
a, b, ov = o["a"], o["b"], o["overlap"]
ta = (a["text"][:36] + "…") if len(a["text"]) > 37 else a["text"]
tb = (b["text"][:36] + "…") if len(b["text"]) > 37 else b["text"]
lines.append(
f" · <{a['tag']}>'{ta}' ⨯ <{b['tag']}>'{tb}' "
f"(overlap {ov['w']}x{ov['h']}px @ ({ov['x']},{ov['y']}))"
)
if len(overlaps) > 5:
lines.append(f" · … +{len(overlaps) - 5} more overlap pair(s)")
else:
ok_count += 1
lines.append(f"- {label}: {w}x{h} ✓")
all_clean = (overflow_count == blank_count == overlap_count == fail_count == 0)
if all_clean:
lines.append(f"Summary: all {len(results)} slides fit the {_VIEWPORT_W}x{_VIEWPORT_H} canvas with no overlaps or large blank regions.")
else:
lines.append(
f"Summary: {overflow_count} overflowing, "
f"{overlap_count} with text overlaps, "
f"{blank_count} with large blank, {fail_count} failed, {ok_count} OK. "
"Use the Read tool to inspect each flagged file, then Edit for surgical fixes."
)
return "\n".join(lines), all_clean
def main() -> int:
p = argparse.ArgumentParser(description="Check rendered slide dimensions / overflow / blank / overlaps.")
p.add_argument("files", nargs="*", help="Slide HTML files to check (paths).")
p.add_argument("--dir", help="Directory of slide HTMLs (used when no positional files are given).")
p.add_argument("--positions", type=int, nargs="*",
help="When --dir is used, restrict to files whose name ends with these integers.")
p.add_argument("--json", action="store_true", help="Emit raw per-file measurements as JSON.")
p.add_argument("--output", type=Path, default=None, help="Write output here instead of stdout.")
args = p.parse_args()
if not args.files and not args.dir:
p.error("provide one or more file paths, or --dir