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
713 lines
32 KiB
Python
Executable File
713 lines
32 KiB
Python
Executable File
#!/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())
|