Files
AOS/skills/pptx/scripts/check_slides.py
T
Z User e45b68bb8b feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
2026-10-27 22:50:50 +00:00

713 lines
32 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <p> overlapping another 4-line <p> 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 <slides_dir>")
targets = _collect_targets(args)
if not targets:
msg = "check_slides: no slide files found."
if args.output:
args.output.write_text(msg + "\n", encoding="utf-8")
else:
sys.stdout.write(msg + "\n")
return 1
results = asyncio.run(_measure_all(targets))
if args.json:
payload = json.dumps(
{str(path): r for path, r in results},
ensure_ascii=False,
indent=2,
)
all_clean = all(r.get("success") and r.get("overflow_x", 0) == 0
and r.get("overflow_y", 0) == 0
and not (r.get("blank") or {}).get("flagged") and not (r.get("overlaps") or [])
for _, r in results)
else:
payload, all_clean = _format_report(results)
if args.output:
args.output.write_text(payload + "\n", encoding="utf-8")
else:
sys.stdout.write(payload + "\n")
return 0 if all_clean else 1
if __name__ == "__main__":
sys.exit(main())