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
This commit is contained in:
1 parent
7acc0c5451
commit
e45b68bb8b
1089 files changed
+260614
No files matched your search
Executable
+712
@@ -0,0 +1,712 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user