"""Soft visual-polish gate — runs at Step 6.
Three gates the hard alignment gate cannot see:
- **Gate A: figure sizing by aspect ratio.** A wide figure (AR > 1.3)
rendered at 38% of card width wastes 60% of the column even when
columns align. The defaults match the documented "aim for" lower
bounds in SKILL.md so any figure inside the recommended range
passes cleanly.
- **Gate B: typography orphans.** ``1.18-1.30× ↑`` whose ``↑``
wrapped alone onto its own line. Detected on elements with
``[class*="stat"]`` / ``[class*="num"]`` / ``.takeaway-num`` /
``.headline-num`` that end with a known orphan-prone glyph but
lack ``white-space: nowrap``.
- **Gate C: space-between fill.** ``justify-content: space-between``
on a column with one short card produces a giant whitespace gap
that reads as "this column ran out of things to say". Detected
when the largest inter-card gap exceeds the column's stated
``row-gap`` by > 5% of column height.
Warns by default; ``--strict`` to exit non-zero. Hard-fails if the
poster has no ``[data-measure-role]`` markup at all — a polish PASS on
"0 figures, 0 columns, 0 stat elements" would be misleading.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from typing import Any
from . import canvas as _canvas
from . import preflight as _preflight
from . import render as _render
# Trailing glyphs that orphan when wrapped: arrows, multiplicative
# cross, division, plus-minus, footnote markers, degree, percent.
ORPHAN_GLYPHS = "↑↓↔×÷±§¶†‡*°%"
from .textutil import ascii_safe
def _eprint(*args: Any, **kw: Any) -> None:
print(*args, file=sys.stderr, **kw)
_POLISH_JS = r"""
() => {
// ---- 1) Figure sizing ----
// For each card, list every with rendered size, the card's
// bounding width (the "budget"), and natural dimensions for AR.
const figures = [];
document.querySelectorAll('[data-measure-role="card"]')
.forEach((card, ci) => {
const cw = card.getBoundingClientRect().width;
card.querySelectorAll('img').forEach(img => {
const r = img.getBoundingClientRect();
if (r.width < 50) return; // skip inline icons
figures.push({
card_index: ci,
role: 'card',
src: img.getAttribute('src') || '',
alt: img.getAttribute('alt') || '',
fig_layout: img.getAttribute('data-fig-layout') || '',
rendered_w: r.width,
rendered_h: r.height,
card_w: cw,
natural_w: img.naturalWidth || 0,
natural_h: img.naturalHeight || 0,
});
});
});
// Hero-panel images (the main figure of a hero-layout poster) get the
// broken-image check too -- a blank centerpiece is the worst failure
// mode and the card-only scan used to miss it. AR sizing gates are
// skipped for these on the Python side (they are framed as % of card
// width, which the full-bleed hero panel doesn't have).
document.querySelectorAll('[data-measure-role="hero"]')
.forEach(hero => {
const hw = hero.getBoundingClientRect().width;
hero.querySelectorAll('img').forEach(img => {
const r = img.getBoundingClientRect();
if (r.width < 50) return; // skip venue badges / inline icons
figures.push({
card_index: -1,
role: 'hero',
src: img.getAttribute('src') || '',
alt: img.getAttribute('alt') || '',
fig_layout: img.getAttribute('data-fig-layout') || '',
rendered_w: r.width,
rendered_h: r.height,
card_w: hw,
natural_w: img.naturalWidth || 0,
natural_h: img.naturalHeight || 0,
});
});
});
// ---- 2) Orphan-prone text elements ----
const sel = '[class*="stat"], [class*="num"], .num, .takeaway-num,'
+ ' .headline-num';
const seen = new Set();
const orphans = [];
document.querySelectorAll(sel).forEach(el => {
if (seen.has(el)) return;
seen.add(el);
const txt = (el.innerText || '').replace(/\s+$/, '');
if (!txt || txt.length > 80) return;
const cs = window.getComputedStyle(el);
orphans.push({
tag: el.tagName.toLowerCase(),
cls: el.className || '',
text: txt,
ws: cs.whiteSpace || '',
});
});
// ---- 3) Space-between fill ----
const cols = [];
document.querySelectorAll('[data-measure-role="column"]')
.forEach((col, ci) => {
const cs = window.getComputedStyle(col);
if (cs.justifyContent !== 'space-between') return;
const colR = col.getBoundingClientRect();
const children = Array.from(col.children).map(c => {
const r = c.getBoundingClientRect();
return {top: r.top, bottom: r.bottom, h: r.height};
}).filter(c => c.h > 0);
if (children.length < 2) return;
const gapPx = parseFloat(cs.rowGap || cs.gap || '0') || 0;
let maxExcess = 0;
let pairIdx = -1;
for (let i = 1; i < children.length; i++) {
const actual = children[i].top - children[i - 1].bottom;
const excess = actual - gapPx;
if (excess > maxExcess) {
maxExcess = excess;
pairIdx = i;
}
}
cols.push({
column_index: ci,
column_h: colR.height,
stated_gap_px: gapPx,
max_excess_px: maxExcess,
pair_idx: pairIdx,
});
});
// ---- 4) Card trailing whitespace (single stretched card) ----
// A card with flex:1 (or any stretch-to-fill) whose content is top-
// packed leaves blank space below the last line. `measure` only checks
// the card's bottom edge so it passes; Gate C only looks BETWEEN cards.
// Skip cards that distribute space on purpose (space-* / center / end)
// -- that is Gate C's territory or an intentional layout.
const cards = [];
document.querySelectorAll('[data-measure-role="card"]')
.forEach((card, ci) => {
const cs = window.getComputedStyle(card);
const jc = cs.justifyContent || '';
if (jc.indexOf('space') !== -1 || jc === 'center'
|| jc === 'end' || jc === 'flex-end') return;
const cr = card.getBoundingClientRect();
if (cr.height <= 0) return;
const padB = parseFloat(cs.paddingBottom) || 0;
const padT = parseFloat(cs.paddingTop) || 0;
const borderB = parseFloat(cs.borderBottomWidth) || 0;
// Is `node` inside an absolutely/fixed-positioned subtree within the
// card? A corner badge / QR / watermark sits at the card bottom but
// is NOT the normal-flow content bottom -- counting it would mask a
// top-packed void above it (false negative). Walk parents to card.
const inAbs = (node) => {
let el = node.nodeType === 1 ? node : node.parentElement;
while (el && el !== card) {
const pos = window.getComputedStyle(el).position;
if (pos === 'absolute' || pos === 'fixed') return true;
el = el.parentElement;
}
return false;
};
// Bottom-most rendered CONTENT = max over three sources (each kept
// via `maxB`, so adding a source can only RAISE the content bottom,
// never hide a void):
// (1) TEXT, via Range -- a plain-text tail that wraps onto a line
// BELOW an inline // is invisible to an element
// scan (its parent
has element children so it's skipped,
// and the inline leaf sits on an earlier line) -> undershoot.
// (2) REPLACED media (img/svg/canvas/...) -- even when it has child
// nodes (e.g.