Firemedic15's picture
download
raw
139 kB
"""Soft visual-polish gate — runs at Step 6.
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 + prose widows.** (1) ``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``. (2) ``WIDOW``: a ``.callout`` /
``.body-text`` / ``.caption`` / ``.section-title`` / ``.card p`` /
``.card li`` / ``.fb-text`` (or a ``<br>`` segment of one) that wraps
so its last visual line is a stranded runt -- filling less than 35%
of the widest line (the framework banner ``.fb-text`` carries a higher
~80% bar: it must read as a filled rectangle, not merely avoid a runt).
Judged by the last
line's WIDTH as a fraction of the measure (not word count), so a short
two-word tail flags and a single long word filling the line does not.
A trailing figure/icon/table keeps the last line unjudgeable, but a short
text tail ending in inline math (``by $\\lambda$.``) is caught.
Long running prose (> the char cap) and GENERIC blocks -- any block-ish
element holding worded text, discovered by geometry so custom-skeleton
class names are covered -- are judged by the conservative bar only: a
stranded SINGLE word under the runt width. (3) ``GLUE-CHAIN``: >= 3
words fused with ``&nbsp;`` (the lazy widow "fix") -- the unbreakable
unit wraps early as a whole and tears a hole in the line above; stat /
math / list idioms are exempt (< 3 prose words, or a pure separator
token in the chain). (4) ``TEXT-WRAP`` census: when >= 3 wrapped blocks
carry neither ``text-wrap: pretty`` nor ``balance``, the templates'
protective defaults were dropped (the custom-skeleton failure mode).
- **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. Two card-level siblings catch
the same void inside a single card: ``CARD/TRAILING`` (blank BELOW
the last line of a stretched card) and ``CARD/INNER-VOID`` (a gap in
the MIDDLE of a card, below the last real block and above a child
pinned to the bottom by ``margin-top: auto`` / ``justify-content:
space-*`` -- the equal-height-row-with-unequal-content case, which
``CARD/TRAILING`` misses because the pinned tail makes trailing ~0).
``TRACK/INNER-VOID`` extends the same geometry to header/footer tracks
(a vertical masthead spine / side rail), which are neither measure
columns nor ``.card`` -- the wave-2 spine stretched by
``space-between`` shipped two ~500 px voids with every gate green.
- **Gate G: composed text/ground contrast.** ``style_check`` verifies
DECLARED token pairs; Gate G samples what actually RENDERED: each text
run's foreground against the alpha-composited ground beneath it, WCAG
ratio floored at ``--min-contrast`` (default 3.0 -- flags only
unambiguous defects; deliberate muted inks sit ~3.5+). Text over
images/gradients is skipped (verify those on the rendered crop). The
recurring incident class: an inline emphasis/highlight class that sets
a background but inherits text color from a different ground.
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 = "↑↓↔×÷±§¶†‡*°%"
# A centered tall figure (AR<0.8) rendered below this fraction of card
# width is too small (wide symmetric side voids) -> FIG/TALL-SMALL. Single
# source of truth: poster_check.py's CLI default imports this constant, and
# the defensive getattr fallback below reuses it, so a programmatic caller
# with a pre-flag Namespace gets the SAME floor as the CLI.
DEFAULT_TALL_MIN_RATIO = 0.36
# Hero-stage letterbox (Gate A, hero branch). A hero figure is NOT exempt
# from sizing the way the old blanket `role=="hero": continue` assumed: a
# narrow-aspect image dropped into a wide-but-SHORT `.hero-stage` is height-
# constrained and leaves big symmetric side voids (e.g. a 2:1 panorama in a
# 5.5:1 stage fills ~35% of the width). HERO/STAGE-LETTERBOX fires when the
# picture fills < FILL of the stage width WHILE the stage is AR_MULT× wider
# (relative to the image AR) than the image needs, with symmetric side voids.
# A genuine full-bleed hero (image AR ~= stage AR, picture fills the width)
# never trips it.
DEFAULT_HERO_LETTERBOX_FILL = 0.55
DEFAULT_HERO_LETTERBOX_AR_MULT = 1.6
# Beside-text float void (Gate A2). A figure floated beside text
# (`data-fig-layout="beside-text"` inside a `.fig-wrap`) whose wrapping text
# stops more than this fraction of the figure's height short of the figure
# bottom leaves an L-shaped void below the text -> FIG/BESIDE-TEXT-VOID. The
# beside-text AR opt-out proves the figure is side-hugged but never checked
# the text actually fills the other side; this closes that residual.
DEFAULT_BESIDE_VOID_RATIO = 0.30
# Inner-card void (Gate C, third sibling of SPACE-BETWEEN / CARD/TRAILING).
# Those two catch a gap BETWEEN cards in a column and blank BELOW a card's
# last line; neither sees a void in the MIDDLE of a card. When a card is
# stretched taller than its content (an equal-height grid row with unequal
# content) and a child is pinned to the bottom -- `margin-top: auto`, or a
# `justify-content: space-*` on the card itself -- the slack opens between
# two children, below the last real block and above the pinned one, so the
# trailing-below-last reads ~0 and CARD/TRAILING stays silent. Detected by
# geometry (largest inter-child vertical gap minus the card's stated
# row-gap), so the mechanism (auto-margin / space-between / stray margin)
# does not matter. The scan covers every `.card` (not only the
# data-measure-role-tagged ones), so an agent-authored feature band whose
# cards carry only the `.card` class is still checked. Flag when the excess
# is BOTH > this fraction of card height AND > the px floor (the floor keeps
# a sub-line gap on a small card from tripping it).
DEFAULT_CARD_INNER_VOID = 0.08
DEFAULT_CARD_INNER_VOID_PX = 24.0
# Header-logo gates (Gate E). Same single-source pattern as above: the
# CLI defaults in poster_check.py import these, and the getattr fallbacks
# in cmd_polish reuse them. Calibrated against the template size classes
# (60x36in landscape header ~1496u: logo-wide caps at 300u < 22%;
# 24x36in portrait header ~586u: wide cap 125u < 22%) so a logo sized by
# a recommended class never trips its own gate.
DEFAULT_LOGO_MAX_WIDTH_RATIO = 0.22
DEFAULT_LOGO_QR_TOL = 0.15
# A logo-wide wordmark is INTENTIONALLY shorter than the QR for visual
# balance (58/85 = 0.68), so it gets a height BAND relative to the QR
# instead of the strict match above.
LOGO_WIDE_QR_BAND = (0.55, 0.85)
DEFAULT_RIGHTBLOCK_MAX_RATIO = 0.32
DEFAULT_TITLE_MIN_RATIO = 0.45
# Vertical-rail masthead detection (Gate E). Every Gate E calibration
# above is written for a HORIZONTAL masthead strip (logo width as % of
# header width, QR-height-matched logo row, side blocks squeezing a
# centred title). A portrait title-spine masthead (DESIGN-AXES Axis 1,
# P5) is a tall narrow RAIL -- those ratios are meaningless there and
# every one of them mis-fires. A rail is unmistakable in practice
# (P5's ~3.8in x ~33in rail is ~9:1; a portrait strip masthead is
# ~1:7 the other way), so a modest 1.5:1 height-over-width threshold
# separates them with a wide margin. In rail mode the horizontal
# calibrations are SKIPPED and replaced by rail checks: logos capped by
# the rail's content width, and blocks checked against all four content
# edges. LOGO/BROKEN is unconditional in both modes.
RAIL_MIN_ASPECT = 1.5
# Title-centring gate (Gate E). The shipped header centres the title with
# `1fr minmax(50%, auto) 1fr`; a side block (logo / venue badge / QR) heavier
# than the other still pulls the centred track aside. Soft WARN when the
# title-block centre is off the header centre by more than this fraction of
# header width -- a nudge to rebalance the header WHEN logo/QR sizing allows,
# not a hard rule (sizing + layout win; centring is best-effort).
DEFAULT_TITLE_OFFSET_MAX = 0.03
# Banner image-slot gate (Gate F). A method figure in the optional framework
# banner whose flex-ITEM ("slot") is much wider than the image itself wastes
# banner width and steals room from the body text/stats (the banner figure is
# captionless by default; a needless caption is the usual cause). Two
# shapes feed one warning: (1) the image is pinned to one side of an over-wide
# slot -> a big one-sided dead band (the visible "whitespace beside the
# figure"); (2) the image is centred but a long single-line caption still
# stretches the slot to the caption's width (the "half-fix" -- adding
# margin:auto evens the gaps but the caption keeps setting the width). Images
# narrower/shorter than these floors are inline icons, not method figures, and
# are skipped. The shipped `banner-figure` component (`width:min-content`)
# collapses the slot to the image, so a correct banner never trips this.
DEFAULT_BANNER_SLOT_MIN_PIC_W = 240.0
DEFAULT_BANNER_SLOT_MIN_PIC_H = 80.0
# Gate thresholds whose CLI defaults previously lived only in poster_check's
# argparse `default=`. Kept here so callers WITHOUT a polish arg namespace --
# `measure --with-polish` runs the polish gates on the SAME rendered page via
# default_polish_args() -- use the exact same numbers; poster_check points its
# `default=` at these constants so the two entry points cannot drift.
DEFAULT_WIDE_MIN_RATIO = 0.65
DEFAULT_TALL_MAX_RATIO = 0.70
DEFAULT_SQUARE_MIN_RATIO = 0.55
DEFAULT_MAX_SPACE_BETWEEN_FILL = 0.05
DEFAULT_MAX_CARD_TRAILING = 0.10
# Composed-contrast gate (Gate G). style_check verifies DECLARED token pairs;
# this verifies the COMPOSED result at render time -- the recurring incident
# class is an inline emphasis/highlight class whose background lands under
# text whose color was designed for a DIFFERENT ground (wave-1: rust lead-ins
# on a steel-blue callout fill, 1.2:1; wave-2: inherited white '4.05' on a
# pale .mark highlight, 1.4:1). 3.0 is deliberately below WCAG-AA body (4.5):
# poster text is large-format and deliberate muted-ink designs sit ~3.5-4.5,
# so the gate flags only unambiguous defects. Raise via --min-contrast for a
# stricter pass.
DEFAULT_MIN_CONTRAST = 3.0
# text-wrap census (Gate B advisory). Warn only when at least this many
# WRAPPED text blocks carry neither `text-wrap: pretty` nor `balance` -- one
# or two unprotected blocks is normal (a deliberate tight lockup); a poster
# full of them has dropped the templates' protective defaults (the usual
# custom-skeleton failure: wave-2's band-rows poster shipped ZERO text-wrap
# declarations and stranded both a body-text widow and a title 'Matching').
DEFAULT_MIN_UNPROTECTED_WRAPS = 3
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 <img> 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 cr = card.getBoundingClientRect();
const cw = cr.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') || '',
// object-fit + side offsets let the Python gate see picture-level
// letterboxing (contain voids INSIDE a full-width box) and tell a
// genuine beside-text layout (hugged to one side) from a centred
// figure mis-tagged to mute the warning.
obj_fit: window.getComputedStyle(img).objectFit || '',
off_left: r.left - cr.left,
off_right: cr.right - r.right,
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
// The "stage" is the immediate figure box the image is letterboxed
// INSIDE (`.hero-stage` in the hero template), falling back to the
// hero panel itself. The wide-short-stage / narrow-image side void
// is measured against THIS box and the image's offsets within it --
// not the whole hero -- so HERO/STAGE-LETTERBOX can replace the old
// blanket hero skip without over-reaching.
const stage = img.closest('.hero-stage') || hero;
const sr = stage.getBoundingClientRect();
figures.push({
card_index: -1,
role: 'hero',
src: img.getAttribute('src') || '',
alt: img.getAttribute('alt') || '',
fig_layout: img.getAttribute('data-fig-layout') || '',
obj_fit: window.getComputedStyle(img).objectFit || '',
off_left: r.left - sr.left,
off_right: sr.right - r.right,
rendered_w: r.width,
rendered_h: r.height,
card_w: hw,
stage_w: sr.width,
stage_h: sr.height,
natural_w: img.naturalWidth || 0,
natural_h: img.naturalHeight || 0,
});
});
});
// Band images (full-width portrait content band, DESIGN-AXES Axis 1
// portrait translations) take the same hero path: broken-image check
// plus the wide-short-stage letterbox test, measured against the
// nearest stage box (`.band-stage`, or `.hero-stage` when a hero
// construction was re-used inside the band) falling back to the band.
document.querySelectorAll('[data-measure-role="band"]')
.forEach(band => {
const bw = band.getBoundingClientRect().width;
band.querySelectorAll('img').forEach(img => {
const r = img.getBoundingClientRect();
if (r.width < 50) return; // skip venue badges / inline icons
const stage = img.closest('.band-stage, .hero-stage') || band;
const sr = stage.getBoundingClientRect();
figures.push({
card_index: -1,
role: 'band',
src: img.getAttribute('src') || '',
alt: img.getAttribute('alt') || '',
fig_layout: img.getAttribute('data-fig-layout') || '',
obj_fit: window.getComputedStyle(img).objectFit || '',
off_left: r.left - sr.left,
off_right: sr.right - r.right,
rendered_w: r.width,
rendered_h: r.height,
card_w: bw,
stage_w: sr.width,
stage_h: sr.height,
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 <span>/<b>/<code> is invisible to an element
// scan (its parent <p> 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. <svg> wrapping <path>s) and so isn't a leaf.
// (3) LEAF element boxes (no element children) -- re-covers a pure-
// CSS diagram node (an empty <div> bar/box) that carries no
// text and isn't replaced, which (1)+(2) alone would miss.
// Non-leaf, non-replaced CONTAINERS are skipped: a stretched wrapper
// box would over-measure to the card bottom and mask the void.
let maxB = cr.top + padT;
const bump = (r) => {
if (r && r.height > 0 && r.bottom > maxB) maxB = r.bottom;
};
const walker = document.createTreeWalker(card, NodeFilter.SHOW_TEXT);
for (let tn = walker.nextNode(); tn; tn = walker.nextNode()) {
if (!tn.nodeValue || !tn.nodeValue.trim()) continue;
if (inAbs(tn)) continue;
const rng = document.createRange();
rng.selectNodeContents(tn);
const rects = rng.getClientRects();
for (let i = 0; i < rects.length; i++) bump(rects[i]);
}
const REPLACED = /^(IMG|SVG|CANVAS|VIDEO|IFRAME|HR|OBJECT|EMBED)$/;
card.querySelectorAll('*').forEach(el => {
if (inAbs(el)) return;
// tagName is upper-case for HTML, but case-preserved (lower) for
// SVG elements -- normalise before the replaced-tag test.
if (!REPLACED.test(el.tagName.toUpperCase())) {
if (el.children.length) {
return; // a non-replaced container: skip (only leaves + media)
}
// A text-bearing leaf with NO visible paint of its own
// (transparent bg, no border/shadow): its visual content is
// exactly its text, which the Range walk above already
// measured. Counting the whole box would let a tall reserved
// box (e.g. the keybox label's `min-height: 2lh` slot) read
// as "content" and mask a real trailing void. A PAINTED leaf
// (callout pill, tinted bar) keeps the box bump -- its
// background visibly extends to the box edge.
if ((el.textContent || '').trim()) {
const ls = window.getComputedStyle(el);
const bg = ls.backgroundColor || '';
// An invisible box paints nothing regardless of bg/border.
const invisible = ls.visibility === 'hidden'
|| parseFloat(ls.opacity) === 0;
const painted = !invisible && (
(bg && bg !== 'transparent'
&& bg !== 'rgba(0, 0, 0, 0)')
|| (ls.backgroundImage && ls.backgroundImage !== 'none')
|| (ls.boxShadow && ls.boxShadow !== 'none')
|| (parseFloat(ls.borderTopWidth) || 0) > 0
|| (parseFloat(ls.borderRightWidth) || 0) > 0
|| (parseFloat(ls.borderBottomWidth) || 0) > 0
|| (parseFloat(ls.borderLeftWidth) || 0) > 0);
if (!painted) return;
}
}
bump(el.getBoundingClientRect());
});
cards.push({
card_index: ci,
card_h: cr.height,
trailing_px: (cr.bottom - padB - borderB) - maxB,
});
});
// ---- 5) <br> as a direct child of a flex container ----
// A <br> that is an in-flow child of display:flex|inline-flex is
// blockified into a flex ITEM and stops creating a line break -- so
// intended multi-line content (e.g. an icon + label stacked with <br>)
// silently collapses onto one row. `measure` can't see it (card bottom
// is unchanged); only the eye catches it. Report each offending flex
// parent once. Even in flex-direction:column the <br> does nothing (the
// text runs already stack as separate items); row is where it visibly
// breaks, so we report the direction to make the fix obvious.
const flexbr = [];
const seenFlexBr = new Set();
document.querySelectorAll('br').forEach(br => {
const parent = br.parentElement;
if (!parent || seenFlexBr.has(parent)) return;
const cs = window.getComputedStyle(parent);
if (cs.display === 'flex' || cs.display === 'inline-flex') {
seenFlexBr.add(parent);
flexbr.push({
tag: parent.tagName.toLowerCase(),
cls: parent.className || '',
dir: cs.flexDirection || 'row',
});
}
});
// ---- 6) Header logos / QR / title squeeze ----
// Affiliation + venue logos live in the header, outside any card/hero,
// so blocks 1-5 never see them: a 404'd logo prints blank silently and
// an oversized wordmark silently crowds the title (the header grid is
// `1fr minmax(50%, auto) 1fr`: the title sits in an equal-tracks-centred
// column floored at 50%, so instead of silently shrinking the title an
// oversized side block is caught by the right-block ratio (right side) or
// the title-centre offset (either side -- a fat left venue badge too)).
// Collect geometry for Gate E. Everything
// is scoped UNDER the header role: a footer .qr-block or a card-body
// .logo-slot is not a header asset and must not drive these gates.
const header = document.querySelector('[data-measure-role="header"]');
const headerRect = header ? header.getBoundingClientRect() : null;
const headerW = headerRect ? headerRect.width : 0;
// height too: a vertical-rail masthead (portrait P5 title spine) is
// detected Python-side from the aspect ratio and swaps the horizontal
// calibrations for rail checks.
const headerH = headerRect ? headerRect.height : 0;
// poster-centre x of the header box, for the title-centring gate below
const headerCx = headerRect ? headerRect.left + headerRect.width / 2 : 0;
// content-box edges (inside border + padding), for the overflow gate;
// top/bottom feed the rail-mode vertical overflow check only.
let headerContentLeft = 0, headerContentRight = 0;
let headerContentTop = 0, headerContentBottom = 0;
if (header && headerRect) {
const cs = getComputedStyle(header);
headerContentLeft = headerRect.left
+ (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0);
headerContentRight = headerRect.right
- (parseFloat(cs.borderRightWidth) || 0) - (parseFloat(cs.paddingRight) || 0);
headerContentTop = headerRect.top
+ (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0);
headerContentBottom = headerRect.bottom
- (parseFloat(cs.borderBottomWidth) || 0)
- (parseFloat(cs.paddingBottom) || 0);
}
const logos = [];
const qrs = [];
const headerBlocks = [];
if (header) {
// querySelectorAll dedupes, so an img matching BOTH selectors (a
// .logo-slot nested inside .venue-badge) is collected exactly once;
// closest() then resolves its scope (venue wins -- the badge sits
// left of the title at its own scale).
header.querySelectorAll('.logo-slot img, .venue-badge img')
.forEach(img => {
const r = img.getBoundingClientRect();
if (r.width < 20) return; // skip stray inline marks
const slot = img.closest('.logo-slot')
|| img.closest('.venue-badge');
logos.push({
src: img.getAttribute('src') || '',
rendered_w: r.width,
rendered_h: r.height,
natural_w: img.naturalWidth || 0,
natural_h: img.naturalHeight || 0,
slot_classes: slot ? (slot.className || '') : '',
venue: !!img.closest('.venue-badge'),
stacked: !!img.closest('.logo-row.logo-stack'),
has_chip: !!img.closest('.logo-chip'),
});
});
header.querySelectorAll('.qr-block img').forEach(img => {
const r = img.getBoundingClientRect();
if (r.width < 20) return;
qrs.push({rendered_h: r.height});
});
// .right-stack is the stacked variant some posters use in place of
// .right-block -- cover both, or those posters skip the squeeze gate.
// .venue-badge (the left block) is collected too, only for the overflow
// gate (its width never drives the right-block / title-min ratios).
header.querySelectorAll('.venue-badge, .right-block, .right-stack, .title-block')
.forEach(el => {
const r = el.getBoundingClientRect();
let kind = 'right';
if (el.classList.contains('title-block')) kind = 'title';
else if (el.classList.contains('venue-badge')) kind = 'left';
headerBlocks.push({
cls: el.className || '',
kind: kind,
w: r.width,
cx: r.left + r.width / 2,
left: r.left,
right: r.right,
top: r.top,
bottom: r.bottom,
});
});
}
// ---- 7) Beside-text float void ----
// A figure floated beside text (`.fig-wrap` > `figure.ff-fig` with the
// img tagged data-fig-layout="beside-text") whose wrapping text is SHORT
// leaves an L-shaped void: the text stops beside the figure's upper half
// and below it (still beside the figure's lower half) is blank. Measure
// how far the wrapping text falls short of the figure bottom. Text is the
// fig-wrap's own text EXCLUDING the figure's caption (fig.contains). If
// the text genuinely flows past the figure bottom, text_bottom >=
// fig_bottom and the Python side reads a non-positive deficit -> no warn.
const besideVoids = [];
document.querySelectorAll('.fig-wrap').forEach((wrap, wi) => {
const fig = wrap.querySelector('figure.ff-fig, .ff-fig');
if (!fig) return;
const img = fig.querySelector('img[data-fig-layout="beside-text"]');
if (!img) return; // only the marked float layout, not a generic wrap
const fr = fig.getBoundingClientRect();
if (fr.height <= 0) return;
// Only count line rects that are genuinely BESIDE the figure: they must
// (a) overlap the figure vertically AND (b) sit clear of it horizontally
// (entirely to one side). A line that wraps BELOW the figure runs full
// width -- it overlaps the figure horizontally and/or starts past its
// bottom -- so it is excluded, and trailing below-figure text can't mask
// a side void (the deficit reflects only how far the SIDE text reaches).
let textBottom = -Infinity;
const heights = [];
const walker = document.createTreeWalker(wrap, NodeFilter.SHOW_TEXT);
for (let tn = walker.nextNode(); tn; tn = walker.nextNode()) {
if (!tn.nodeValue || !tn.nodeValue.trim()) continue;
if (fig.contains(tn)) continue; // skip the figure's own caption text
const rng = document.createRange();
rng.selectNodeContents(tn);
const rects = rng.getClientRects();
for (let i = 0; i < rects.length; i++) {
const rc = rects[i];
if (rc.height <= 0) continue;
const overlapsV = (rc.top < fr.bottom - 1) && (rc.bottom > fr.top + 1);
const clearsH = (rc.left >= fr.right - 1) || (rc.right <= fr.left + 1);
if (!overlapsV || !clearsH) continue; // below / behind the figure
if (rc.bottom > textBottom) textBottom = rc.bottom;
heights.push(rc.height);
}
}
// Median line height -- robust to a single tall inline element (MathJax,
// an enlarged span) that a max() would let inflate and wrongly silence a
// real void via the 1.5-line guard on the Python side.
heights.sort((a, b) => a - b);
const lineH = heights.length
? heights[Math.floor((heights.length - 1) / 2)] : 0;
besideVoids.push({
wrap_index: wi,
src: img.getAttribute('src') || '',
fig_bottom: fr.bottom,
fig_h: fr.height,
text_bottom: (textBottom === -Infinity) ? null : textBottom,
line_h: lineH,
});
});
// ---- 8) Prose widows: a wrapped text block whose LAST visual line is a
// stranded RUNT -- it fills less than RUNT_FRAC of the typeset
// measure. `measure` checks card bottoms; section 2's orphan scan
// only sees a trailing GLYPH on a stat/num element. Neither sees a
// `.callout`/`.body-text`/`.caption`/`.section-title`/`.card p`/`.card li`/
// `.fb-text` that wraps to a short last line -- the artefact SKILL.md Gate
// B forbids.
// We judge by the last line's WIDTH as a fraction of the widest line
// (NOT word count): a short two-word tail is as ugly as a one-word
// one, while a single long word that fills the line is not stranded.
// The framework banner (.fb-text) is the poster's most prominent block,
// so it gets a much higher bar (BANNER_FILL_FRAC): not just "not a runt"
// but a near-full last line -- a filled rectangle.
// Robust to inline <strong>/<code> splitting a word's rects, to
// `text-align: justify`, and to sub-pixel / mixed-font-size line tops.
const widows = [];
const RUNT_FRAC = 0.35; // last line < 35% of the measure = stranded runt
// The framework banner (.fb-text) is the poster's single most prominent text
// block; a merely "not-a-runt" last line still reads as a ragged box there. It
// gets a much higher bar -- aim for a near-full last line (a filled rectangle),
// reached by reflowing the text: tune the .fb-text width, bump its font size,
// and/or expand/trim the wording -- any one or a moderate mix (SKILL.md Gate B),
// NOT by justification / letter-spacing padding, nor by pushing one lever to an
// extreme (e.g. a blown-up font) just to pass. Still a soft warning, never a hard fail.
const BANNER_FILL_FRAC = 0.80;
const WIDOW_SEL = '.callout, .body-text, .caption, .section-title,'
+ ' .card p, .card li, .fb-text';
// Glue chains: >=3 words fused with &nbsp; form one unbreakable unit that
// wraps EARLY as a whole, tearing a hole in the line above -- the lazy
// widow "fix" the wave-2 posters shipped. Collected during the same walk.
const glueChains = [];
// text-wrap census: wrapped prose with neither `pretty` nor `balance` --
// custom skeletons routinely drop the templates' protective declarations
// (one wave-2 poster shipped ZERO text-wrap rules). Aggregated, not
// per-element, so it nudges instead of flooding.
const wrapCensus = [];
// Candidates come from TWO pools:
// (1) the whitelisted prose classes above -- full RUNT_FRAC bar;
// (2) every OTHER block-ish element that directly holds worded text,
// discovered by GEOMETRY, not class name -- the 8-axis custom
// skeletons author their own classes (.band-lede, .mh-sub, ...), so a
// class whitelist goes blind exactly where wave-2's widows happened
// (a stranded 'Matching' in a custom masthead subtitle). Generic
// blocks are judged by the CONSERVATIVE bar only (single stranded
// word), so unknown block types can't flood the report.
const candidates = [];
const wlSet = new Set();
document.querySelectorAll(WIDOW_SEL).forEach(el => {
// Scan only the most specific prose leaf: if this element CONTAINS another
// candidate (a .callout wrapping a <p class="body-text">), skip it -- the
// descendant is scanned on its own, so we never double-report one widow.
if (el.querySelector(WIDOW_SEL)) return;
wlSet.add(el);
candidates.push({el, generic: false});
});
{
const root = document.querySelector('[data-measure-role="poster"]')
|| document.body;
// "Blocky" = element that establishes its own line box flow; inline
// descendants (<strong>, <span>) stay part of the parent's paragraph.
const blockyCache = new Map();
const isBlocky = (el) => {
if (blockyCache.has(el)) return blockyCache.get(el);
const d = getComputedStyle(el).display;
// inline-* (inline, inline-block, inline-flex, inline-grid) all flow
// INSIDE the parent's line boxes -- treating them as blocks would make
// an inline-block chip a candidate of its own and, via the
// innermost-only rule below, DELETE the parent paragraph from the
// scan (its widow/census/glue coverage with it).
const b = !d.startsWith('inline') && d !== 'contents' && d !== 'none'
&& !d.startsWith('ruby');
blockyCache.set(el, b);
return b;
};
// Nearest blocky ancestor of each worded text node = its paragraph box.
const candSet = new Set();
const twAll = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let tn = twAll.nextNode(); tn; tn = twAll.nextNode()) {
if (!/[\p{L}\p{N}]{2}/u.test(tn.nodeValue || '')) continue;
let el = tn.parentElement;
if (!el) continue;
// Math/SVG internals are not prose; TABLE cells wrap short lines by
// design (the widow contract doesn't fit them -- contrast still covers
// their text via the separate scan below).
if (el.closest('mjx-container, .MathJax, math, svg, table,'
+ ' script, style')) continue;
while (el && el !== root && !isBlocky(el)) el = el.parentElement;
if (el) candSet.add(el);
}
// Innermost blocks only: a wrapper holding both direct text and a texted
// block child would judge a "last line" that isn't visually last.
const hasCandDesc = new Set();
candSet.forEach(el => {
for (let p = el.parentElement; p; p = p.parentElement) {
if (candSet.has(p)) hasCandDesc.add(p);
}
});
candSet.forEach(el => {
if (hasCandDesc.has(el)) return;
if (wlSet.has(el) || el.closest(WIDOW_SEL)) return; // pool (1) owns it
if (el.querySelector(WIDOW_SEL)) return; // wraps a pool-(1) leaf
if (el.closest('[data-vrail-title]')) return; // deliberate stack
candidates.push({el, generic: true});
});
}
candidates.forEach(({el, generic}) => {
// A vrail rail title is a DELIBERATELY narrow stacked column
// (each word on its own horizontal line, an over-long word broken with a soft
// hyphen at a syllable boundary the AGENT judges). Its short last line is
// intentional, not a runt -- where to break is an authoring judgment the agent
// makes, not a geometry a gate can score -- so it is marked data-vrail-title to
// opt out of the widow check. See SKILL.md Gate B.
if (el.hasAttribute('data-vrail-title')) return;
const cs = getComputedStyle(el);
// text-wrap protection census (fed to the aggregated TEXT-WRAP note):
// `pretty` guards the last-line orphan on prose, `balance` evens centered
// display text. Chromium exposes the computed value on `textWrap` (older)
// or `text-wrap-style` (newer split property) -- read both.
const twStyle = (cs.textWrap || cs.getPropertyValue('text-wrap')
|| cs.getPropertyValue('text-wrap-style') || '');
const wrapProtected = /pretty|balance/.test(twStyle);
let censusCounted = false;
const ws = (cs.whiteSpace || '').toLowerCase();
if (ws.indexOf('nowrap') !== -1 || ws.indexOf('pre') !== -1) return;
if ((cs.direction || '') === 'rtl') return; // "last word" geometry unclear in RTL
const wm = cs.writingMode || '';
if (wm && wm.indexOf('horizontal') === -1) return; // vertical text out of scope
// Math / figure elements do NOT hide the whole block any more (a caption
// mixing inline $math$ with a lone trailing word slipped through that
// blanket skip -- the eb181286 "one." incident). They join the line model
// as OPAQUE cells: their text (if any) stays out of the token stream, but
// their rects vote in line grouping. A last line is then skipped only when
// it carries figure/icon/table MEDIA, or is opaque with no real WORD -- a
// lone trailing equation, even with a sentence period (see the last-line
// gate below); a text tail with a real word ending in math IS judged.
const OPAQUE = 'mjx-container, .MathJax, math, img, svg, canvas, table';
// Of those, FIGURE-class opaques (image / icon / canvas / table) are real
// media, not prose: a last line trailing one is unjudgeable (its width as a
// "runt" is meaningless and a trailing inline icon may be deliberate). Inline
// MATH (mjx-container / .MathJax / math) is NOT media -- it reads as part of
// the sentence, so a short text fragment ending in math ("by $\\lambda$.")
// IS a stranded runt and must be judged.
const MEDIA = 'img, svg, canvas, table';
// Display text (.caption / .callout / .fb-text) gets a higher length cap
// than running prose: a short stranded last line is prominent in display
// copy even when the block is long -- a caption under a figure, or the
// framework-banner blurb. The 220-char cap was exactly why an incident
// caption (231 chars) and a 269-char banner .fb-text (whose last line filled
// only 17% of the measure) were never measured.
const cap = (el.matches('.caption, .callout') || el.closest('.fb-text')) ? 400 : 220;
// Split the element's own text into `<br>`-delimited paragraphs, each
// keeping a flat string + a DOM map (so a word split across inline tags
// stays ONE token) + the opaque elements seen in that segment. A widow
// can sit at the end of an EARLY segment (the statement above a
// <br>question), so we check every segment, not just the block's final
// visual line.
const paras = [];
let cur = {flat: '', segs: [], ops: []};
// The identity-glyph <svg>: holds a #psReg <use> (same href/xlink
// precedence as preflight -- an existing empty href is NOT a valid ref)
// inside a [data-ps-mark] element.
const isIdGlyphSvg = (n) => {
if (!n.matches('svg[data-ps-mark], [data-ps-mark] svg')) return false;
for (const x of n.querySelectorAll('use')) {
if (x.closest('svg') !== n) continue; // n must be the use's OWNER
const h = x.hasAttribute('href') ? (x.getAttribute('href') || '')
: (x.getAttribute('xlink:href') || '');
if (h.trim() === '#psReg') return true;
}
return false;
};
const tw = document.createTreeWalker(
el, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT);
for (let n = tw.nextNode(); n; n = tw.nextNode()) {
if (n.nodeType === 1) {
// OPAQUE interiors are structure we must not interpret: a <br> inside
// a <table> cell must NOT split the OUTER prose into segments (it
// would orphan the trailing text into a 1-token segment and mask a
// real widow). Skip everything below an opaque root; the root itself
// passes this test and is recorded.
if (n.parentElement && n.parentElement.closest(OPAQUE)) continue;
if (n.tagName === 'BR') { paras.push(cur); cur = {flat: '', segs: [], ops: []}; continue; }
// Record the OUTERMOST opaque element (nested <svg> in <mjx-container>
// was filtered above). visibility:hidden opaques still occupy layout
// but paint nothing -- a lone word next to one IS visually stranded,
// so they must not vote.
// The posterly identity glyph -- the <svg> holding the #psReg <use>
// inside a [data-ps-mark] element -- is NOT prose media: a woven mark
// riding a line end (e.g. beside a terminal period) must not suppress
// the widow/runt check for that line. Exclude ONLY that <svg>: a
// different opaque element (a chart <svg> with no #psReg use) keeps
// its vote even if a mark attribute were misplaced on its container.
if (n.matches && n.matches(OPAQUE) && !isIdGlyphSvg(n)
&& getComputedStyle(n).visibility !== 'hidden') {
cur.ops.push(n);
}
continue;
}
// Skip the section-number badge (.num): a flex item whose center-y would
// corrupt line grouping and whose digit would fuse into the first token.
if (n.parentElement && n.parentElement.closest('.num')) continue;
// Text living INSIDE an opaque element (a <table> cell, MathML token)
// is represented by the opaque rect, not the token stream.
if (n.parentElement && n.parentElement.closest(OPAQUE)) continue;
const v = n.nodeValue;
if (!v) continue;
cur.segs.push({node: n, base: cur.flat.length, text: v});
cur.flat += v;
}
paras.push(cur);
paras.forEach(para => {
const norm = para.flat.replace(/\s+/g, ' ').trim();
if (norm.length === 0) return;
// ---- GLUE-CHAIN: >=3 words fused with &nbsp; (U+00A0). A 2-token glue
// is the documented legit use (marker/stat cell); a longer chain is an
// unbreakable unit that wraps EARLY as a whole and tears a hole in the
// line above -- the lazy widow "fix". Fused PROSE is the problem;
// stat / math / list idioms are legit glue, so a chain flags only when
// it carries >= 3 prose words (>= 2 consecutive letters) AND no pure
// separator token: "alpha = 4" (no prose words), "> GRPO 3.93 > EMPO
// 3.80." (2), and a middot-joined footer contact strip (separator
// tokens) all stay quiet; "holds length and keeps improving." flags.
// Runs BEFORE the length caps below -- chains hide in long prose too.
{
const chainRe = /(?:\S+\u00A0+){2,}\S+/g;
let cm;
while ((cm = chainRe.exec(para.flat)) !== null) {
const words = cm[0].split(/\u00A0+/);
const proseWords =
words.filter(w => /[\p{L}]{2}/u.test(w)).length;
// A list/contact-strip separator ANYWHERE in the chain (middot,
// pipe, slash, en/em dash -- deliberately NOT the word-internal
// hyphen: a fused hyphenated compound is still fused prose) marks
// a joined-list idiom -- footers glue `&nbsp;\u00B7` tight against
// the next token, so a token-level "pure separator" test misses
// them.
const hasSep = /[\u00B7|/\u2014\u2013]/.test(cm[0]);
if (proseWords < 3 || hasSep) continue;
glueChains.push({
tag: el.tagName.toLowerCase(),
cls: el.className || '',
chain: cm[0].replace(/\u00A0+/g, ' ').slice(0, 60),
words: words.length,
});
}
}
// Long running prose used to be SKIPPED outright (norm.length > cap) --
// which is exactly how a 262-char .body-text shipped a stranded
// 'AIME24/25).' last line. Long prose (and every generic, unlisted-class
// block) is now judged by the CONSERVATIVE bar instead: only a stranded
// SINGLE word flags -- short multi-word tails are normal in long prose.
const extremeOnly = generic || norm.length > cap;
// Tokenise on \S+ (JS `\s` includes U+00A0, so `&nbsp;` is a SEPARATOR
// here -- a glued pair is two tokens). Token COUNT no longer decides;
// the WIDTH test below does. The recommended `&nbsp;` glue still helps,
// by pulling the prior word down so the last line fills more of the
// measure -- not by changing the token count.
const toks = [];
const re = /\S+/g;
let m;
while ((m = re.exec(para.flat)) !== null) {
toks.push({s: m.index, e: m.index + m[0].length, t: m[0]});
}
// 0/1-word segment can't widow. Documented exception: a segment that is
// ONLY display math plus one trailing word ("<mjx>...</mjx> one.") is
// treated as a one-word paragraph, not a wrap -- same verdict as case
// "Short." (the text never wrapped, so nothing was stranded BY a wrap).
if (toks.length < 2) return;
const rectsFor = (a, b) => {
// PER-TEXT-NODE ranges: a single Range spanning from one text node to
// another also returns the rects of any element BETWEEN its endpoints
// -- for an unspaced "alpha<mjx/>beta" token that smuggles the tall
// opaque rect in as a TEXT cell, poisoning the line-height median the
// tolerance is built from. Measuring each text segment separately can
// never cross an opaque subtree.
const out = [];
for (const sg of para.segs) {
const sStart = sg.base, sEnd = sg.base + sg.text.length;
const lo = Math.max(a, sStart), hi = Math.min(b, sEnd);
if (lo >= hi) continue;
const rng = document.createRange();
rng.setStart(sg.node, lo - sStart);
rng.setEnd(sg.node, hi - sStart);
const rects = rng.getClientRects();
for (let i = 0; i < rects.length; i++) out.push(rects[i]);
}
return out;
};
// Each VISIBLE rect becomes a line CELL carrying its token index, so a
// long token that itself wraps across two lines (break-word / hyphenation)
// contributes a cell to BOTH lines instead of collapsing the line model.
const cells = []; // {cy, h, ti, l, r}
for (let ti = 0; ti < toks.length; ti++) {
const rects = rectsFor(toks[ti].s, toks[ti].e);
for (let i = 0; i < rects.length; i++) {
const r = rects[i];
if (r.width <= 0.5 || r.height <= 0.5) continue; // drop zero-width wrap-space artefacts
cells.push({cy: (r.top + r.bottom) / 2, h: r.height, ti: ti,
l: r.left, r: r.right});
}
}
// Opaque cells (ti = -1): vote in line grouping, mark their line, but
// never count as a "word". `media` distinguishes a figure/icon/table
// (its last line stays unjudgeable) from inline math (judged with text).
for (const op of para.ops) {
const isMedia = !!(op.matches && op.matches(MEDIA));
const rects = op.getClientRects();
for (let i = 0; i < rects.length; i++) {
const r = rects[i];
if (r.width <= 0.5 || r.height <= 0.5) continue;
cells.push({cy: (r.top + r.bottom) / 2, h: r.height, ti: -1,
l: r.left, r: r.right, media: isMedia});
}
}
if (cells.length < 2) return;
// Group cells into visual lines by center-y within a line-height
// tolerance (NOT top +/- 2px: <sup>, mixed font-size and sub-pixel
// rounding shift tops within one line). Each line keeps the SET of token
// indices with a visible rect on it; the WIDTH test below judges the last
// line (token count no longer decides).
// Tolerance derives from TEXT cells only: tall opaque cells (display
// math) would inflate the median height and merge a real text last line
// into the line above, masking the widow.
const hs = cells.filter(c => c.ti >= 0).map(c => c.h).sort((a, b) => a - b);
const medH = hs.length ? hs[Math.floor((hs.length - 1) / 2)] : 0;
const tol = Math.max(3, medH * 0.6);
cells.sort((a, b) => a.cy - b.cy);
const lines = [];
let line = null;
for (const c of cells) {
if (line && (c.cy - line.cy) <= tol) {
line.n += 1;
line.cy += (c.cy - line.cy) / line.n;
} else {
line = {cy: c.cy, n: 1, tis: new Set(), op: false, media: false,
lo: Infinity, hi: -Infinity, flo: Infinity, fhi: -Infinity};
lines.push(line);
}
// Only TEXT cells set the line's measured extent (`lo`/`hi`): an inline
// opaque (display math / figure / table) wider than the prose must NOT
// inflate the `measure` and make a normal text last line look like a
// runt (Codex MAJOR). `flo`/`fhi` track the FULL visual extent (text +
// opaque) and is used ONLY to size a text+math last line (case "by λ.").
// Opaque cells still vote in grouping (via cy) above; `media` marks a
// figure/icon/table line (kept unjudgeable), `op` marks any opaque.
if (c.l < line.flo) line.flo = c.l; // full L/R extent
if (c.r > line.fhi) line.fhi = c.r; // (text + opaque)
if (c.ti >= 0) {
line.tis.add(c.ti);
if (c.l < line.lo) line.lo = c.l; // text L/R extent
if (c.r > line.hi) line.hi = c.r; // of the visual line
} else {
line.op = true;
if (c.media) line.media = true;
}
}
if (lines.length < 2) return; // single visual line: nothing to widow
// Census: this block genuinely WRAPS, so it either carries wrap
// protection (text-wrap: pretty / balance) or it doesn't. Once per el.
if (!censusCounted) {
censusCounted = true;
wrapCensus.push({
cls: el.className || '', tag: el.tagName.toLowerCase(),
protected: wrapProtected,
});
}
const last = lines[lines.length - 1];
// A last line carrying a FIGURE / icon / table (real media) is OUTSIDE
// this prose-runt contract -- its width as a "runt" is meaningless and a
// trailing inline icon may be deliberate, so it stays unjudgeable.
if (last.media) return;
// A last line carrying opaque content whose only text is PUNCTUATION
// (a lone trailing equation/figure, optionally with a sentence period:
// "$eq$." or "$eq$,") is intentional trailing content, not a stranded
// word -- skip it. A real WORD on the line (a letter/digit token, e.g.
// "by" in "by λ.") keeps the line judged.
const lastHasWord = Array.from(last.tis)
.some(ti => /[\p{L}\p{N}]/u.test(toks[ti].t));
if (last.op && !lastHasWord) return;
// Otherwise the last line is prose: pure text, OR text plus an inline
// MATH symbol (mjx-container) that reads as part of the sentence. A short
// tail ending in math ("traded off by $\\lambda$.") IS a stranded runt, so
// we judge it by its FULL visual extent (text + math), not text alone --
// the old blanket `if (last.op) return` hid exactly this case.
// WIDTH-based runt test (replaces the old "exactly one token" rule). The
// MEASURE is the widest typeset TEXT line (opaque widths excluded so an
// inline figure can't inflate it); a last line filling less than
// RUNT_FRAC of it is a stranded runt -- regardless of word COUNT. This
// catches a SHORT two-word tail ("= OMAD-only.") the token rule missed,
// and clears a SINGLE long word that fills the line (width ~= measure)
// which the token rule wrongly flagged.
const measure = Math.max(...lines.map(l => l.hi - l.lo));
const lastW = last.fhi - last.flo;
// The banner (.fb-text) must read as a FILLED rectangle, not merely avoid a
// runt -- so it is judged against the higher BANNER_FILL_FRAC; all other
// prose keeps the RUNT_FRAC runt bar. `closest` (not `matches`) so banner
// text nested in a sub-candidate (.body-text/.caption inside .fb-text, which
// would make the gate scan the child, not the .fb-text parent) still gets
// the banner bar rather than silently falling back to the runt bar.
const isBanner = !!el.closest('.fb-text');
const threshold = isBanner ? BANNER_FILL_FRAC : RUNT_FRAC;
// Conservative bar for long prose / generic blocks: judge ONLY a
// stranded SINGLE word (one text token on the last line, still under
// the runt width). Multi-word short tails are normal in long running
// prose and unknowable in unlisted block types -- flagging them there
// would flood; a lone stranded word is bad in ANY block type.
if (extremeOnly && last.tis.size > 1) return;
if (measure > 0 && (lastW / measure) < threshold) {
const ord = Array.from(last.tis).sort((a, b) => a - b);
widows.push({
tag: el.tagName.toLowerCase(),
cls: el.className || '',
frac: Math.floor(lastW / measure * 100), // floor: a flagged line never displays its own threshold %
word: ord.map(ti => toks[ti].t).join(' ').slice(0, 40),
lines: lines.length,
text: (norm.length > 60) ? ('...' + norm.slice(-57)) : norm,
banner: isBanner, // banner -> "fill the rectangle" message; else the runt message
mode: generic ? 'generic' : (extremeOnly ? 'long' : 'std'),
});
}
});
});
// ---- 9) Framework-banner image slot ----
// A captioned method figure in the optional framework banner whose flex
// ITEM ("slot") is much wider than the image wastes banner width and steals
// room from the body. Anchored on the IMG (not <figure>), so any wrapper
// element the agent picks is covered. Deliberately NOT extended to the
// `band` role: the shipped banner-figure contract makes "slot = banner's
// direct child" meaningful, but a custom band usually wraps its whole
// content in one inner element -- the walk-up would call the full band
// the "slot" and flag a legitimate image-beside-text layout. Bands get
// the hero-style letterbox + broken checks instead (section 1). The "slot" is the banner's DIRECT
// child that contains the image -- the box that competes with the body for
// banner width; a bare <img> child IS its own slot, so slack==0 and it can
// never trip. `caption_like_w` is the widest non-image descendant of the
// slot (the caption/text block that may be setting the slot width).
const bannerImgs = [];
document.querySelectorAll('[data-measure-role="banner"]').forEach(banner => {
const br = banner.getBoundingClientRect();
banner.querySelectorAll('img').forEach(img => {
const ir = img.getBoundingClientRect();
if (ir.width <= 0 || ir.height <= 0) return;
// Walk up to the banner's direct child (the flex/grid item). If the img
// is itself a direct child of the banner, slot === img.
let slot = img, p = img.parentElement;
while (p && p !== banner) { slot = p; p = p.parentElement; }
if (p !== banner) return; // img not actually under this banner
const sr = slot.getBoundingClientRect();
// Widest non-image content in the slot = the caption/text block. Exclude
// the img and any element that CONTAINS it (those are slot-wide by
// construction and would mask the signal).
let captionLikeW = 0;
slot.querySelectorAll('*').forEach(el => {
if (el === img || el.contains(img)) return;
const r = el.getBoundingClientRect();
if (r.height > 0 && r.width > captionLikeW) captionLikeW = r.width;
});
// A caption written as BARE text directly in the slot (no <figcaption>)
// is invisible to the element scan but can still expand the slot. Measure
// text-run rects too and keep the widest.
const tw = document.createTreeWalker(slot, NodeFilter.SHOW_TEXT);
for (let tn = tw.nextNode(); tn; tn = tw.nextNode()) {
if (!tn.nodeValue || !tn.nodeValue.trim()) continue;
const rng = document.createRange();
rng.selectNodeContents(tn);
const rects = rng.getClientRects();
for (let i = 0; i < rects.length; i++) {
if (rects[i].height > 0 && rects[i].width > captionLikeW) {
captionLikeW = rects[i].width;
}
}
}
bannerImgs.push({
src: img.getAttribute('src') || '',
obj_fit: window.getComputedStyle(img).objectFit || '',
banner_w: br.width,
slot_w: sr.width,
slot_is_img: slot === img,
off_left: ir.left - sr.left,
off_right: sr.right - ir.right,
rendered_w: ir.width,
rendered_h: ir.height,
natural_w: img.naturalWidth || 0,
natural_h: img.naturalHeight || 0,
caption_like_w: captionLikeW,
});
});
});
// ---- 7) Inner-card void: oversized gap BETWEEN a card's stacked
// children ----
// SPACE-BETWEEN catches a gap BETWEEN cards in a column; CARD/TRAILING
// catches blank BELOW a card's last line. Neither sees a void in the
// MIDDLE of a card -- e.g. a `.card` stretched to an equal-height grid
// row taller than its content with a child pinned to the bottom
// (`margin-top: auto`, or `justify-content: space-*` on the card). The
// slack opens below the last real block and above the pinned one, so
// trailing reads ~0. Measured purely by geometry (largest inter-child
// vertical gap minus the stated row-gap) so the mechanism does not
// matter, and scoped to `.card` (not just data-measure-role) so an
// agent-authored feature band whose cards aren't tagged is still seen.
const innerVoids = [];
const seenIV = new Set();
// Scope to the poster so a stray `.card` outside it can't be sampled.
const ivRoot = document.querySelector('[data-measure-role="poster"]')
|| document;
// Shared geometry: largest inter-child vertical gap inside `box`, minus its
// stated row-gap. Used for cards AND for header/footer tracks below.
// A positioned child may veto a void band only when it is wide, VISIBLE,
// and actually carries something -- paint (background), media, or real
// text. An invisible/empty positioned box must not mask a real void.
// `visibleIn` closes the invisibility channels innerText alone misses:
// opacity:0 / transparent ink anywhere on the chain up to the veto box.
const visibleIn = (el, root) => {
for (let e = el; e && e !== root.parentElement; e = e.parentElement) {
const es = window.getComputedStyle(e);
if (es.display === 'none' || es.visibility === 'hidden'
|| es.visibility === 'collapse'
|| parseFloat(es.opacity) === 0) {
return false;
}
}
return true;
};
// Alpha of a computed color string: comma-syntax rgba(...) OR slash
// alpha in any modern space (oklch(.5 .1 20 / 0), color(srgb 0 0 0 / 4%)).
// No alpha channel present -> opaque (1).
const alphaOfColor = (str) => {
let m = /rgba\([^)]*,\s*([\d.]+)\)/.exec(str || '');
if (m) return parseFloat(m[1]);
m = /\/\s*([\d.]+%?)\s*\)/.exec(str || '');
if (m) {
return m[1].endsWith('%')
? parseFloat(m[1]) / 100 : parseFloat(m[1]);
}
return 1;
};
const absSubstantive = (c, contW) => {
const r = c.getBoundingClientRect();
if (r.height <= 0 || r.width < 0.5 * contW) return false;
// The veto element itself gets the full invisibility test (display /
// hidden / collapse / opacity) -- single hop of the same chain walk.
if (!visibleIn(c, c)) return false;
const cs2 = window.getComputedStyle(c);
// Painted fill = background only, judged by parsed ALPHA -- a fully
// transparent rgba(255, 0, 0, 0) or oklch(... / 0) paints nothing. A
// 1px border is decoration, not a band fill, and must not veto.
const bg = cs2.backgroundColor || '';
if ((bg && bg !== 'transparent' && alphaOfColor(bg) >= 0.05)
|| (cs2.backgroundImage && cs2.backgroundImage !== 'none')) {
return true;
}
// VISIBLE ink with some substance -- hidden/opacity:0/transparent text
// or a lone glyph in a big empty box must not veto the band.
let inkLen = 0;
const twv = document.createTreeWalker(c, NodeFilter.SHOW_TEXT);
for (let tn = twv.nextNode(); tn; tn = twv.nextNode()) {
const frag = (tn.nodeValue || '').trim();
if (!frag) continue;
const pe = tn.parentElement;
if (!pe || !visibleIn(pe, c)) continue;
const pc = window.getComputedStyle(pe).color || '';
if (alphaOfColor(pc) < 0.05) continue; // transparent ink
inkLen += frag.length;
if (inkLen >= 8) return true;
}
// Media: the element ITSELF, or a VISIBLE descendant that fills a real
// share of the box (a tiny icon in a big empty container doesn't).
const MEDIA_SEL = 'img, svg, canvas, video';
if (c.matches && c.matches(MEDIA_SEL)) return true;
for (const m of c.querySelectorAll(MEDIA_SEL)) {
const mr = m.getBoundingClientRect();
if (mr.height >= 0.3 * r.height && mr.width > 0
&& visibleIn(m, c)) {
return true;
}
}
// Accepted residual: a container with real VISIBLE text/media still
// vetoes by its whole bbox even when the content sits in one corner --
// this is a soft, waivable warning and the veto errs toward silence.
return false;
};
const interChildVoid = (box, extraAbs) => {
const cs = window.getComputedStyle(box);
const cr = box.getBoundingClientRect();
if (cr.height <= 0) return null;
const gap = parseFloat(cs.rowGap || cs.gap || '0') || 0;
// Direct, in-flow element children with a real box. Skip abs/fixed
// (a corner badge/QR is not flow content). Use getAttribute('class')
// -- an SVG child's `.className` is an SVGAnimatedString and .trim()
// on it throws, which would crash the whole evaluate.
const all = Array.from(box.children).map(c => {
const r = c.getBoundingClientRect();
const pos = window.getComputedStyle(c).position;
const cl = ((c.getAttribute('class') || '').trim()
.split(/\s+/)[0]) || '';
return {tag: c.tagName.toLowerCase(), cls: cl, top: r.top,
bottom: r.bottom, h: r.height, w: r.width, pos};
});
const kids = all
.filter(c => c.h > 0 && c.pos !== 'absolute' && c.pos !== 'fixed')
.sort((a, b) => a.top - b.top);
if (kids.length < 2) return null;
// Absolutely-positioned children are NOT flow content (a corner badge
// must not mask a void above it) -- but a SUBSTANTIVE positioned child
// that genuinely covers a candidate band (a full-width positioned
// figure) is not blank space either. Wide, VISIBLE, non-empty abs
// boxes get to veto a band (extraAbs carries boxes from unwrapped
// wrapper levels -- see the track loop).
const absKids = (extraAbs || []).concat(
Array.from(box.children).filter(c => {
const pos = window.getComputedStyle(c).position;
return pos === 'absolute' || pos === 'fixed';
}).filter(c => absSubstantive(c, cr.width))
.map(c => { const r = c.getBoundingClientRect();
return {top: r.top, bottom: r.bottom}; }));
// Merge same-row children: walk in top order tracking the running MAX
// bottom of everything seen so far, and count a gap only when a child
// STARTS below that max. A side-by-side row (figure beside text, a
// flex row) is dominated by its tallest member, so a following block
// that clears the tall one is NOT a void -- this avoids measuring
// `next.top - shortSibling.bottom` across an already-filled row.
// NOTE: only DIRECT children are inspected; a void nested inside a
// single wrapper (`.card > .body` flex column) is not seen -- keep a
// card's content flat for the gate to cover it (see SKILL.md Gate C).
let rowMaxBottom = kids[0].bottom;
let rowMaxIdx = 0;
const bands = [];
for (let i = 1; i < kids.length; i++) {
const g = kids[i].top - rowMaxBottom;
if (g > 0) {
bands.push({g, top: rowMaxBottom, bottom: kids[i].top,
above: rowMaxIdx, below: i});
}
if (kids[i].bottom > rowMaxBottom) {
rowMaxBottom = kids[i].bottom;
rowMaxIdx = i;
}
}
// Largest band no flow child covers, skipping bands a wide positioned
// child fills (>= 60% of the band's height).
const absCover = (band) => {
let best = 0;
for (const a of absKids) {
const ov = Math.min(a.bottom, band.bottom)
- Math.max(a.top, band.top);
if (ov > best) best = ov;
}
return best / (band.bottom - band.top);
};
let maxGap = 0, pairBelow = -1, pairAbove = -1;
for (const b of bands) {
if (b.g <= maxGap) continue;
if (absCover(b) >= 0.6) continue;
maxGap = b.g; pairAbove = b.above; pairBelow = b.below;
}
const lab = k => k.tag + (k.cls ? '.' + k.cls : '');
return {
h: cr.height,
stated_gap: gap,
excess: maxGap - gap,
above: pairAbove >= 0 ? lab(kids[pairAbove]) : '',
below: pairBelow > 0 ? lab(kids[pairBelow]) : '',
};
};
ivRoot.querySelectorAll('.card, [data-measure-role="card"]')
.forEach(card => {
if (seenIV.has(card)) return;
seenIV.add(card);
const v = interChildVoid(card);
if (!v) return;
innerVoids.push({
cls: (card.getAttribute('class') || ''),
card_h: v.h,
stated_gap: v.stated_gap,
excess: v.excess,
above: v.above,
below: v.below,
});
});
// ---- 7b) Track void: the same stacked-children void inside a HEADER /
// FOOTER track (a vertical masthead spine, a side rail). These
// tracks are neither measure columns nor `.card`s, so every void
// gate above is blind there -- a wave-2 poster stretched its
// full-height title spine with `justify-content: space-between`
// and shipped two ~500 px voids, all gates green. What it
// measures is the largest vertical band NO child covers -- so a
// height-aligned horizontal masthead row stays quiet (children
// overlap vertically), while side-by-side columns that are
// vertically OFFSET (one hugging the top, one the bottom) do
// register: the uncovered band between them is a real void.
const trackVoids = [];
ivRoot.querySelectorAll(
'[data-measure-role="header"], [data-measure-role="footer"]')
.forEach(track => {
if (seenIV.has(track)) return;
seenIV.add(track);
// Unwrap single-child padding/layout wrappers (header > .inner with
// the space-between on .inner): the void walk inspects DIRECT
// children only, and a lone wrapper used to hide the whole track.
let box = track;
const outerAbs = [];
const trackW = track.getBoundingClientRect().width;
for (let hops = 0; hops < 4; hops++) {
// A substantive positioned child at THIS level still covers bands
// measured after unwrapping -- carry it along (review round 3:
// wrapper + outer positioned figure combined used to false-flag).
Array.from(box.children).forEach(c => {
const pos = window.getComputedStyle(c).position;
if ((pos === 'absolute' || pos === 'fixed')
&& absSubstantive(c, trackW)) {
const r = c.getBoundingClientRect();
outerAbs.push({top: r.top, bottom: r.bottom});
}
});
const flow = Array.from(box.children).filter(c => {
const r = c.getBoundingClientRect();
const pos = window.getComputedStyle(c).position;
return r.height > 0 && pos !== 'absolute' && pos !== 'fixed';
});
if (flow.length !== 1) break;
box = flow[0];
}
const v = interChildVoid(box, outerAbs);
if (!v) return;
trackVoids.push({
role: track.getAttribute('data-measure-role') || '',
cls: (track.getAttribute('class') || ''),
track_h: v.h,
stated_gap: v.stated_gap,
excess: v.excess,
above: v.above,
below: v.below,
space_between: (window.getComputedStyle(box).justifyContent || '')
.indexOf('space') !== -1,
});
});
// ---- 10) Composed text/ground contrast ----
// style_check verifies DECLARED token pairs; nothing verified the COMPOSED
// result -- an emphasis span inheriting white text while its own class
// paints a pale highlight behind it (wave-2: white '4.05' on #CFE6E4,
// 1.4:1), or an emph-colored lead-in on an accent fill (wave-1: rust on
// steel blue, 1.2:1). Sample the RENDERED foreground of every text run
// against the effective ground beneath it: the hit-test stack under the
// sample point, alpha-composited until opaque. Text over images/gradients
// (or any replaced layer) is unjudgeable and skipped -- this is a
// solid-fill contract; verify those grounds on the rendered crop.
const contrasts = [];
{
const parseC = (s) => {
const m = /rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/
.exec(s || '');
if (!m) return null;
return {r: +m[1], g: +m[2], b: +m[3], a: m[4] === undefined ? 1 : +m[4]};
};
const over = (top, bot) => { // source-over compositing
const a = top.a + bot.a * (1 - top.a);
if (a <= 0) return {r: 0, g: 0, b: 0, a: 0};
return {
r: (top.r * top.a + bot.r * bot.a * (1 - top.a)) / a,
g: (top.g * top.a + bot.g * bot.a * (1 - top.a)) / a,
b: (top.b * top.a + bot.b * bot.a * (1 - top.a)) / a,
a,
};
};
const lum = (c) => {
const f = (v) => {
v /= 255;
return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
};
return 0.2126 * f(c.r) + 0.7152 * f(c.g) + 0.0722 * f(c.b);
};
const ratioOf = (c1, c2) => {
const l1 = lum(c1), l2 = lum(c2);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
};
const hex = (c) => '#' + [c.r, c.g, c.b]
.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
const REPLACED_RE = /^(IMG|CANVAS|VIDEO|SVG|IFRAME|OBJECT|EMBED)$/;
// Effective ground beneath `el` at (x, y): walk the hit-test stack from
// el downward, compositing translucent fills until one is opaque.
// null = unjudgeable (image/gradient/replaced layer, or el not hit).
const groundAt = (el, x, y) => {
// elementsFromPoint only resolves INSIDE the layout viewport; the
// render pipeline sets the viewport to the full poster canvas, so
// every on-canvas glyph is reachable -- text overflowing the canvas
// (already a hard `measure` failure) silently skips instead.
const stack = document.elementsFromPoint(x, y);
const idx = stack.indexOf(el);
if (idx === -1) return null; // covered oddly / pointer-events
let acc = null; // accumulated translucent paint
for (let i = idx; i < stack.length; i++) {
const layer = stack[i];
if (layer !== el && REPLACED_RE.test(layer.tagName.toUpperCase())) {
return null; // picture ground: unjudgeable
}
const ls = window.getComputedStyle(layer);
if (ls.backgroundImage && ls.backgroundImage !== 'none') return null;
// A painted ::before/::after is invisible to elementsFromPoint (its
// paint belongs to the originating element), so a pseudo-element
// fill -- e.g. a split two-tone header band drawn by
// `.header::before/::after` -- would silently read as whatever sits
// BELOW it (white-on-white false positives, wave-2 poster 4). A
// layer carrying a painted pseudo makes the ground unjudgeable.
for (const pe of ['::before', '::after']) {
const ps = window.getComputedStyle(layer, pe);
if (ps.content === 'none') continue;
const pRaw = ps.backgroundColor || '';
const pBc = parseC(pRaw);
// Same contract as real layers: a painted pseudo OR one whose
// color the parser can't read (oklch()/lab()) is unjudgeable --
// treating a parse failure as alpha 0 would see THROUGH the fill.
if ((ps.backgroundImage && ps.backgroundImage !== 'none')
|| (pBc ? pBc.a > 0 : (pRaw && pRaw !== 'transparent'))) {
return null;
}
}
// A translucent layer (opacity < 1 on the element or any ancestor
// in the stack) changes the effective ink/ground in ways this flat
// model doesn't composite -- unjudgeable.
if (parseFloat(ls.opacity) < 1) return null;
const rawBc = ls.backgroundColor || '';
const bc = parseC(rawBc);
if (!bc) {
// An authored color the parser can't read (oklch()/lab()/color())
// must be unjudgeable, NOT silently transparent -- falling through
// would judge against whatever paint sits below it.
if (rawBc && rawBc !== 'transparent') return null;
continue;
}
if (bc.a <= 0) continue;
acc = acc ? over(acc, bc) : bc;
if (acc.a >= 0.999) return acc;
}
// Fell through every layer: composite over the canvas default (white).
const white = {r: 255, g: 255, b: 255, a: 1};
return acc ? over(acc, white) : white;
};
const seenCEls = new Set();
// Aggregate JS-side by (class | fg | bg), keeping the WORST ratio per
// combo: a fixed collection cap in DOM order could otherwise fill up on
// early borderline runs and silently drop a severe defect further down
// the document. Unique low-contrast combos are bounded by the stylesheet
// (dozens), so the map stays small; 300 is a runaway backstop.
const contrastMap = new Map();
const cRoot = document.querySelector('[data-measure-role="poster"]')
|| document.body;
const ctw = document.createTreeWalker(cRoot, NodeFilter.SHOW_TEXT);
for (let tn = ctw.nextNode(); tn; tn = ctw.nextNode()) {
if (!/\S/.test(tn.nodeValue || '')) continue;
const el = tn.parentElement;
if (!el || seenCEls.has(el)) continue;
seenCEls.add(el);
if (el.closest('mjx-container, .MathJax, math, svg, script, style')) {
continue; // math/vector ink: out of scope
}
const cs = window.getComputedStyle(el);
if (cs.visibility === 'hidden' || parseFloat(cs.opacity) === 0) continue;
// Outlined / shadowed text has engineered edge contrast this flat-fill
// model can't score -- skip rather than mis-judge.
if (cs.textShadow && cs.textShadow !== 'none') continue;
if (parseFloat(cs.webkitTextStrokeWidth) > 0) continue;
const fg0 = parseC(cs.color);
// Unparsable foreground (oklch()/lab() ink) is DELIBERATELY skipped
// as unjudgeable -- same contract as grounds; token-disciplined
// posters author hex/rgb (SKILL.md Gate G limits).
if (!fg0 || fg0.a < 0.05) continue;
// Sample the first and last glyph runs of this text node -- a run
// crossing two fills is judged at both ends, worst sample wins.
const rng = document.createRange();
rng.selectNodeContents(tn);
const rects = Array.from(rng.getClientRects())
.filter(r => r.width > 1 && r.height > 1);
if (!rects.length) continue;
const pts = [rects[0], rects[rects.length - 1]]
.map(r => [(r.left + r.right) / 2, (r.top + r.bottom) / 2]);
let worst = null;
for (const p of pts) {
const g = groundAt(el, p[0], p[1]);
if (!g) continue;
const fg = fg0.a < 1 ? over(fg0, g) : fg0;
const rr = ratioOf(fg, g);
if (!worst || rr < worst.ratio) worst = {ratio: rr, ground: g, fg};
}
// Only sub-7.0 samples ship to Python (payload stays small; the WARN
// threshold itself lives Python-side as --min-contrast, so any floor
// up to 7.0 is fully served -- higher floors are not supported).
if (!worst || worst.ratio >= 7.0) continue;
const fgHex = hex(worst.fg), bgHex = hex(worst.ground);
const key = (el.className || '') + '|' + fgHex + '|' + bgHex;
const prev = contrastMap.get(key);
if (prev) {
prev.count += 1;
if (worst.ratio < prev.ratio) {
prev.ratio = Math.round(worst.ratio * 100) / 100;
prev.text = (tn.nodeValue || '')
.replace(/\s+/g, ' ').trim().slice(0, 32);
}
} else {
if (contrastMap.size >= 300) {
// Backstop full: keep the map biased toward the WORST pairs, not
// toward DOM order -- evict the highest-ratio combo if this one
// is lower; drop it otherwise.
let maxKey = null, maxRatio = -1;
contrastMap.forEach((v, k) => {
if (v.ratio > maxRatio) { maxRatio = v.ratio; maxKey = k; }
});
if (worst.ratio >= maxRatio) continue;
contrastMap.delete(maxKey);
}
contrastMap.set(key, {
tag: el.tagName.toLowerCase(),
cls: el.className || '',
text: (tn.nodeValue || '').replace(/\s+/g, ' ').trim().slice(0, 32),
fg: fgHex,
bg: bgHex,
ratio: Math.round(worst.ratio * 100) / 100,
px: Math.round(parseFloat(cs.fontSize) || 0),
count: 1,
});
}
}
contrastMap.forEach(v => contrasts.push(v));
}
// Identity corner-signature: check the mark's rendered box, its OWN #psReg
// <use> and that use's owner <svg> (box/visibility), what the use resolves
// to (getBBox -- catches a dangling ref or display:none INSIDE the symbol),
// the instance-vs-viewport intersection (a shifted use is clipped away),
// the effective ink alpha, and the ancestor opacity chain. NOT covered
// (stay manual): visibility/opacity/stroke/fill set INSIDE the symbol,
// visibility:collapse, occlusion, and same-colour-on-same-ground. Null when
// the poster carries no corner mark (legacy poster -- no advisory).
let cornerSig = null;
{
const pv = document.querySelector('[data-measure-role="poster"]');
// Scope to the poster root so a stray corner mark elsewhere in the document
// can't stand in for the real one.
const el = (pv || document).querySelector('[data-ps-mark="corner"]');
if (el) {
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
const pr = pv ? pv.getBoundingClientRect() : null;
// The EXACT identity use -- same href/xlink precedence as preflight --
// and ITS owner <svg>. NOT just the first svg/use inside the mark: a
// chart <svg> before the glyph, or an unrelated dangling <use> before
// the real one, would make the probe read the wrong element (false
// negatives AND false positives).
const XL = 'http://www.w3.org/1999/xlink';
let u = null;
for (const x of el.querySelectorAll('use')) {
const h = x.hasAttribute('href') ? (x.getAttribute('href') || '')
: (x.getAttributeNS(XL, 'href') || x.getAttribute('xlink:href') || '');
if (h.trim() === '#psReg') { u = x; break; }
}
const g = u ? u.closest('svg')
: (el.matches('svg') ? el : el.querySelector('svg'));
const gr = g ? g.getBoundingClientRect() : null;
const gcs = g ? getComputedStyle(g) : null;
// What the <use> resolves to: getBBox() reflects the referenced
// symbol's geometry -- 0x0 when the target is missing or its shapes
// are display:none (which no outer-box check can see). Throws when not
// rendered -> 0. The instance must also intersect its <svg> viewport
// when that viewport clips (a shifted <use x="10000"> is cut away;
// with overflow:visible it still paints), and must intersect the
// POSTER either way (an owner svg positioned off-page takes the glyph
// with it while the wrapper box stays put).
let ubw = null, ubh = null, uIn = true;
if (u) {
try { const b = u.getBBox(); ubw = b.width; ubh = b.height; }
catch (e) { ubw = 0; ubh = 0; }
const ov = (p, q) => p.right > q.left && p.left < q.right &&
p.bottom > q.top && p.top < q.bottom;
const ur = u.getBoundingClientRect();
const clips = g ? (gcs ? gcs.overflow : 'hidden') !== 'visible' : false;
uIn = (!g || !clips || ov(ur, g.getBoundingClientRect()))
&& (!pr || ov(ur, pr));
}
// Effective ink alpha (⊕ strokes use currentColor -> the svg's color).
// Alpha from a 4-component rgba() -- rgb() is opaque; don't misread its
// blue channel as alpha -- or from a modern "... / <alpha>)" form
// (oklch()/color() keep their syntax in computed style). 'transparent'
// computes to alpha 0.
const col = (gcs || cs).color || '';
const am = col.match(
/^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)/i);
const sm = col.match(/\/\s*([\d.]+)(%?)\s*\)\s*$/);
const inkAlpha = col === 'transparent' ? 0
: am ? parseFloat(am[1])
: sm ? (sm[2] ? parseFloat(sm[1]) / 100 : parseFloat(sm[1]))
: 1;
// Effective opacity: multiply from the painted glyph up through EVERY
// ancestor to the document root (opacity does not inherit) -- an
// opacity:0 on the inner svg, an intermediate container, or even
// body/a wrapper ABOVE the poster all blank the glyph on the PDF.
let effOp = 1;
for (let n = g || el; n; n = n.parentElement) {
effOp *= parseFloat(getComputedStyle(n).opacity || '1');
}
cornerSig = {
w: Math.round(r.width), h: Math.round(r.height),
vis: cs.visibility, disp: cs.display, op: parseFloat(cs.opacity),
hasGlyph: !!u && !!g,
gw: gr ? Math.round(gr.width) : 0, gh: gr ? Math.round(gr.height) : 0,
gvis: gcs ? gcs.visibility : 'hidden',
gdisp: gcs ? gcs.display : 'none',
ubw: ubw, ubh: ubh, uIn: uIn,
inkAlpha: inkAlpha, effOp: effOp,
inside: pr ? (r.left >= pr.left - 1 && r.top >= pr.top - 1 &&
r.right <= pr.right + 1 && r.bottom <= pr.bottom + 1)
: true,
};
}
}
return {figures, orphans, cols, cards, innerVoids, trackVoids, flexbr,
besideVoids, widows, glueChains, wrapCensus, contrasts,
logos, qrs, header_w: headerW, header_h: headerH,
header_cx: headerCx,
header_content_left: headerContentLeft,
header_content_right: headerContentRight,
header_content_top: headerContentTop,
header_content_bottom: headerContentBottom, headerBlocks,
bannerImgs, cornerSig};
}
"""
def collect_polish_data(page):
"""Run the polish measurement JS on an already-open, already-settled
page and return the raw result dict.
Split out of :func:`cmd_polish` so the same measurement can ride a
SHARED rendered page (``measure --with-polish``) instead of paying a
second Chromium launch. Pure read-only DOM geometry -- it mutates
nothing, so running it after another gate's probes on the same page
yields identical numbers.
"""
return page.evaluate(_POLISH_JS)
def default_polish_args() -> argparse.Namespace:
"""Polish gate thresholds at their CLI defaults, for callers that run
the gates without owning a polish arg namespace (``measure
--with-polish``). Only the attrs :func:`report_polish` reads
directly are set; everything else is read defensively via getattr
with the same DEFAULT_* constants. ``strict`` stays False -- the
merged path is advisory by design.
"""
return argparse.Namespace(
wide_min_ratio=DEFAULT_WIDE_MIN_RATIO,
tall_max_ratio=DEFAULT_TALL_MAX_RATIO,
square_min_ratio=DEFAULT_SQUARE_MIN_RATIO,
max_space_between_fill=DEFAULT_MAX_SPACE_BETWEEN_FILL,
max_card_trailing=DEFAULT_MAX_CARD_TRAILING,
strict=False,
)
def advisory_polish_on_page(page, html_path: Path) -> None:
"""Best-effort polish pass on a page some other gate already rendered
and settled (``measure --with-polish``). Advisory only: prints the
polish report at default thresholds, never raises, never changes the
caller's exit code. Skipped (with a note) when the poster lacks the
measure-role markup polish requires, or if the measurement itself
fails -- a broken advisory pass must not break the hard gate.
"""
try:
role_counts = _preflight.has_required_roles_in_html(html_path)
missing = [r for r in ("poster", "card", "column")
if role_counts.get(r, 0) == 0]
if missing:
_eprint(
f"[measure] --with-polish: skipping polish pass, poster "
f"is missing measure-role markup {missing}"
)
return
data = collect_polish_data(page)
except Exception as e:
_eprint(
f"[measure] --with-polish: polish measurement failed, "
f"skipped ({e})"
)
return
print("[measure] --with-polish advisory report (same rendered page; "
"never gates measure's exit):")
try:
report_polish(data, default_polish_args(), html_path)
except Exception as e:
_eprint(f"[measure] --with-polish: polish report failed ({e})")
def cmd_polish(args: argparse.Namespace) -> int:
try:
from playwright.sync_api import sync_playwright
from playwright.sync_api import TimeoutError as PWTimeoutError
except ImportError:
_eprint("ERROR: playwright not installed. Run:")
_eprint(" python -m pip install playwright")
_eprint(" python -m playwright install chromium")
return 2
html_path = Path(args.html).resolve()
if not html_path.exists():
_eprint(f"ERROR: HTML not found: {ascii_safe(html_path)}")
return 2
# Hard-fail if there's no measurement markup at all. A polish PASS
# on "0 figures, 0 columns, 0 stat-like elements" would be silent
# success on a file the tool can't reason about.
role_counts = _preflight.has_required_roles_in_html(html_path)
must_have = ("poster", "card", "column")
missing = [r for r in must_have if role_counts.get(r, 0) == 0]
if missing:
_eprint(
f"ERROR: polish requires data-measure-role markup on the "
f"poster, columns, and cards. Missing or zero-count: "
f"{missing}. Either add the roles or use a different tool."
)
return 2
resolved = _canvas.resolve_canvas(
html_path, args.canvas, label="[polish]"
)
if resolved is None:
_eprint(
"ERROR: could not find `@page { size: <W> <H> }` in HTML; "
"pass `--canvas <W>x<H>in` or `--canvas 'A0 portrait'`."
)
return 2
canvas, viewport = resolved
with sync_playwright() as p:
browser, _ctx, page = _render.open_print_emulated_page(p, viewport)
nav_timed_out = False
try:
page.goto(html_path.as_uri(), wait_until="networkidle",
timeout=args.mathjax_timeout_ms)
except PWTimeoutError:
# Don't raw-traceback on a hung/slow resource. Record it and
# let settle_page surface a MathJax-specific failure first;
# otherwise fail-fast below. polish must NOT sample a poster
# that never finished loading -- a blocked remote image or web
# font would otherwise sneak through as a false PASS.
nav_timed_out = True
settle = _render.settle_page(
page,
mathjax_timeout_ms=args.mathjax_timeout_ms,
settle_ms=args.settle_ms,
)
fail = _render.hard_fail_on_settle_problems(
settle, mathjax_timeout_ms=args.mathjax_timeout_ms,
)
if fail is not None:
browser.close()
_eprint(f"FAIL: {fail}")
return 1
if nav_timed_out:
browser.close()
_eprint(
"FAIL: page did not reach network-idle within "
f"{args.mathjax_timeout_ms} ms; refusing to polish a "
"partially loaded poster. A blocked/slow remote resource "
"(CDN image, web font, MathJax) is the usual cause -- "
"inline assets, or raise --mathjax-timeout-ms."
)
return 1
data = collect_polish_data(page)
browser.close()
return report_polish(data, args, html_path)
def report_polish(data: dict, args: argparse.Namespace,
html_path: Path) -> int:
"""Apply the visual-polish gates (A-F) to data gathered by
:func:`collect_polish_data` and print the report. Page-free, so the
same reporting runs for standalone ``polish`` and for the merged
``measure --with-polish`` path (which shares one rendered page).
Returns the process exit code (1 only under ``args.strict`` with
warnings present).
"""
warns: list[str] = []
# ---- Gate A: figure sizing by AR ----
# Read defensively: programmatic callers / tests build a Namespace
# directly and may predate this flag (mirrors measure.py's fallback
# style for newly added args).
tall_min = getattr(args, "tall_min_ratio", DEFAULT_TALL_MIN_RATIO)
hero_fill = getattr(
args, "hero_letterbox_fill", DEFAULT_HERO_LETTERBOX_FILL)
hero_ar_mult = getattr(
args, "hero_letterbox_ar_mult", DEFAULT_HERO_LETTERBOX_AR_MULT)
for f in data.get("figures", []):
rw = float(f["rendered_w"])
rh = float(f.get("rendered_h", 0.0))
cw = float(f["card_w"])
nw = float(f["natural_w"])
nh = float(f["natural_h"])
role = f.get("role", "card")
src_l = str(f["src"]).lower()
# A vector image (SVG) can legitimately report zero natural size
# while rendering fine, so never flag it broken. Match the path
# extension (after stripping any ?query / #fragment) plus inline
# SVG data URIs. Imperfect: an SVG behind an extensionless URL
# still slips through; an `img.decode()`-based JS probe would be
# exact. Covers card, hero, and band <img> (see _POLISH_JS).
src_path = src_l.split("?", 1)[0].split("#", 1)[0]
is_svg = (
src_path.endswith((".svg", ".svgz"))
or src_l.startswith("data:image/svg")
)
if (nw <= 0 or nh <= 0) and not is_svg:
warns.append(
f"FIG/BROKEN: '{ascii_safe(f['src'])}' has zero natural "
"size -- the image failed to load (missing file, 404, or "
"an unreachable remote URL); it will be blank in print."
)
continue
# Hero figures: the %-of-card-width AR gates below don't apply (a
# hero panel has no card budget), but a hero image is NOT
# automatically fine -- the HERO/STAGE-LETTERBOX check after
# content_w is computed catches a narrow picture stranded in a
# wide-short stage. (Old code blanket-skipped role=="hero" here.)
if cw <= 0 or rw <= 0:
continue
# AR from natural size when available. An SVG (or any figure that
# reported zero natural size yet rendered) falls back to its
# RENDERED aspect ratio so the sizing gates still apply -- the
# skill recommends converting vector figures to SVG, and a
# zero-natural SVG would otherwise slip every AR gate below.
if nw > 0 and nh > 0:
ar = nw / nh
elif rh > 0:
ar = rw / rh
else:
continue
# Rendered PICTURE width inside the <img> box. `object-fit` decides how
# the bitmap fills the element box; only contain/scale-down/none can
# leave left+right voids INSIDE a (often full-width) box, which the old
# element-box `ratio` missed. Compute the visible picture width so both
# the AR gates AND the beside-text centring test below judge what
# actually prints, not the element box:
# contain : scale to fit -> min(box_w, box_h*AR)
# scale-down : contain but never upscale past natural -> clamp by nw
# none : natural size, box-clipped -> min(box_w, nw)
# fill/cover/'': fill the box width -> box_w
obj_fit = str(f.get("obj_fit", "")).strip().lower()
if obj_fit in ("contain", "scale-down") and rh > 0 and ar > 0:
content_w = min(rw, rh * ar)
if obj_fit == "scale-down" and nw > 0:
content_w = min(content_w, float(nw))
elif obj_fit == "none" and nw > 0:
content_w = min(rw, float(nw))
else:
content_w = rw
# Hero/band branch: stop blanket-exempting hero images. The
# %-of-card AR gates don't fit a hero panel or a full-width band,
# but a narrow-aspect picture dropped into a wide-but-SHORT stage
# is height-constrained and leaves big symmetric side voids. Flag
# exactly that shape (same geometry for both roles).
if role in ("hero", "band"):
sw = float(f.get("stage_w", 0) or 0)
sh = float(f.get("stage_h", 0) or 0)
ol = f.get("off_left")
orr = f.get("off_right")
if sw > 0 and sh > 0 and ol is not None and orr is not None:
fill = content_w / sw
stage_ar = sw / sh
ar_mult = (stage_ar / ar) if ar > 0 else 0.0
# Side void = the img's offset within the stage PLUS half the
# internal object-fit letterbox, so a hero img sized
# width/height:100% with object-fit:contain (element box fills
# the stage, picture letterboxed inside) is judged on the
# visible PICTURE, not the element box. Mirrors the card
# beside-text centring test.
void = max(0.0, rw - content_w)
pic_left = float(ol) + void / 2.0
pic_right = float(orr) + void / 2.0
symmetric = (min(pic_left, pic_right) > 0.15 * sw
and abs(pic_left - pic_right) < 0.12 * sw)
if (fill < hero_fill and ar_mult > hero_ar_mult
and symmetric):
warns.append(
f"{role.upper()}/STAGE-LETTERBOX: "
f"'{ascii_safe(f['src'])}' "
f"(AR={ar:.2f}) fills only {fill * 100:.0f}% of its "
f"{role}-stage width -- the stage is "
f"{stage_ar:.1f}:1, "
f"much wider than the image, so a height-constrained "
f"picture leaves big symmetric side voids. Give the "
f"figure real vertical room (move a secondary diagram "
f"out of the {role} into a card), or constrain the "
f"stage width toward the image's aspect ratio."
)
continue
# Author opt-out for a DELIBERATE image-left/text-right card: a figure
# that shares its card with a real side-by-side / float-wrapped text
# column is sized below the AR thresholds on purpose. Marking the <img>
# `data-fig-layout="beside-text"` records that intent so a later edit
# leaves the layout alone instead of widening to silence the warning.
# It skips only the AR width gates; the FIG/BROKEN check above still
# applies. The documented MISUSE is a CENTRED, text-less small figure
# tagged just to mute the warning (SKILL.md: "not a generic mute").
# Detect it on the visible PICTURE -- element offsets PLUS half the
# internal letterbox void, so a full-width object-fit:contain image
# tagged beside-text can't hide a centred picture behind a full-width
# box: symmetric side voids => centred => fall through and warn; hugged
# to one side => honour. Offsets absent (programmatic/test callers) =>
# honour as before. NOTE: this proves the picture sits to one side, not
# that text actually fills the other -- a side-hugged text-less figure
# is still honoured (accepted residual; the documented misuse is the
# centred one).
if str(f.get("fig_layout", "")).strip() == "beside-text":
ol = f.get("off_left")
orr = f.get("off_right")
if ol is None or orr is None:
continue
void = max(0.0, rw - content_w)
pic_left = float(ol) + void / 2.0
pic_right = float(orr) + void / 2.0
centred = (min(pic_left, pic_right) > 0.08 * cw
and abs(pic_left - pic_right) < 0.12 * cw)
if not centred:
continue
# centred-with-side-voids: opt-out misused, do not skip.
ratio = content_w / cw
if ar > 1.3 and ratio < args.wide_min_ratio:
warns.append(
f"FIG/WIDE: '{ascii_safe(f['src'])}' (AR={ar:.2f}) at "
f"{ratio * 100:.0f}% of card width -- "
f"{args.wide_min_ratio * 100:.0f}% is the defect FLOOR, "
f"not the target: a figure that owns its card reads "
f"best at 90-100% width (field-calibrated -- 70%-era "
f"floors still shipped visible small-stamp cards). "
f"Enlarge, or drop the image-left/text-right wrapper."
)
elif ar < 0.8 and ratio > args.tall_max_ratio:
warns.append(
f"FIG/TALL: '{ascii_safe(f['src'])}' (AR={ar:.2f}) at "
f"{ratio * 100:.0f}% of card width -- a tall figure this "
f"wide gets awkward; shrink to 45-60%, or use a verified "
f"float/beside-text wrap layout."
)
elif ar < 0.8 and ratio < tall_min:
warns.append(
f"FIG/TALL-SMALL: '{ascii_safe(f['src'])}' (AR={ar:.2f}) at "
f"{ratio * 100:.0f}% of card width -- a tall figure this "
f"narrow renders small with wide side margins. Enlarge "
f"toward 45-60%, or wrap text around it with a verified "
f"float/beside-text layout. If the small size is genuinely "
f"intended, this is a soft WARN you can accept."
)
elif 0.8 <= ar <= 1.3 and ratio < args.square_min_ratio:
warns.append(
f"FIG/SQUARE: '{ascii_safe(f['src'])}' (AR={ar:.2f}) at "
f"{ratio * 100:.0f}% of card width -- square figures "
f"sit better at {args.square_min_ratio * 100:.0f}-75%."
)
# ---- Gate A2: beside-text float void ----
# A figure floated beside text whose wrapping text stops short of the
# figure bottom leaves an L-shaped void below the text. The beside-text
# AR opt-out above only proved the figure is side-hugged (not a centred
# mis-tag); it never checked the text fills the other side. This closes
# that residual. deficit = how far the text falls short of the figure
# bottom; text flowing PAST the figure yields a non-positive deficit
# (no warn). The 1.5-line guard avoids flagging a sub-line shortfall.
beside_void = getattr(
args, "beside_void_ratio", DEFAULT_BESIDE_VOID_RATIO)
for bv in data.get("besideVoids", []):
fig_h = float(bv.get("fig_h", 0) or 0)
if fig_h <= 0:
continue
tb = bv.get("text_bottom")
if tb is None:
warns.append(
f"FIG/BESIDE-TEXT-VOID: '{ascii_safe(bv.get('src', ''))}' "
f"floats beside text but has NO wrapping text beside it -- a "
f"text-less float just leaves an L-shaped void. Center the "
f"figure (.figure) with text full-width below instead."
)
continue
deficit = float(bv["fig_bottom"]) - float(tb)
line_h = float(bv.get("line_h", 0) or 0)
ratio = deficit / fig_h
if ratio > beside_void and deficit > 1.5 * max(line_h, 1.0):
warns.append(
f"FIG/BESIDE-TEXT-VOID: '{ascii_safe(bv.get('src', ''))}' -- "
f"the wrapping text stops {ratio * 100:.0f}% of the figure's "
f"height short of its bottom, leaving an L-shaped void beside "
f"the figure's lower half. Best fix: lengthen the text with "
f"paper-sourced detail until it fills the figure height, or "
f"shrink the figure to the text height. Centering (.figure, "
f"text full-width below) only helps a WIDE figure (aspect > "
f"~1.3) -- centering a square or tall figure at a width that "
f"fits the column just trades the L-void for symmetric side "
f"voids and shrinks the figure."
)
# ---- Gate B: typography orphans ----
for n in data.get("orphans", []):
txt: str = n["text"]
if not txt:
continue
last = txt[-1]
if last not in ORPHAN_GLYPHS:
continue
if not re.search(r"\s", txt[:-1]):
continue
ws = (n["ws"] or "").lower()
if "nowrap" in ws or "pre" in ws:
continue
warns.append(
f"ORPHAN: <{ascii_safe(n['tag'])} class='{ascii_safe(n['cls'])}'> "
f"text '{ascii_safe(txt[:48])}' ends with '{ascii_safe(last)}' "
f"and may wrap alone. Apply `white-space: nowrap` or use &nbsp; "
f"before the trailing glyph."
)
# ---- Gate B (prose): a stranded RUNT last line (width-based) ----
# The wrap-geometry sibling of the stat/num orphan above: a `.callout` /
# `.body-text` / `.caption` / `.section-title` / `.card p` / `.card li` /
# `.fb-text` (or a `<br>`-delimited segment of one) whose last visual line
# fills < ~35% of the typeset measure for ordinary prose -- the `.fb-text`
# banner uses the higher ~80% BANNER_FILL_FRAC (see the per-item `banner`
# branch below). Judged by WIDTH, not word count, so a short TWO-word tail flags
# while a single LONG word that fills the line does not. SKILL.md Gate B
# forbids this; the stat/num scan can't see it. Gluing the last two tokens
# with &nbsp; pulls the prior word down onto the last line and widens it
# above the threshold, so the recommended fix still clears the gate. The
# framework banner (`.fb-text`) carries a `banner` flag and a higher bar
# (BANNER_FILL_FRAC): it must read as a FILLED rectangle, so it gets a
# fill-the-rectangle message (reflow via width, font size, and/or rewording
# -- combinable, in moderation) instead of the runt/glue one.
for w in data.get("widows", []):
if w.get("banner"):
warns.append(
f"BANNER WIDOW: <{ascii_safe(w['tag'])} class='{ascii_safe(w['cls'])}'> "
f"the framework banner is the poster's most prominent block, but its "
f"last line fills only {int(w['frac'])}% of the typeset width "
f"('{ascii_safe(w['word'])}') -- it should read as a filled rectangle "
f"(SKILL.md Gate B). Reflow it to a near-full last line by any of, or "
f"a moderate mix of: (a) tune the .fb-text width (its flex ratio vs "
f".banner-stats -- fill jumps non-monotonically, try a few) without "
f"starving the stat boxes; (b) bump the .fb-text font size one --fs-* "
f"step, if it shifts the wrap (also makes the block bolder), without "
f"overflowing the banner or colliding with the stats; (c) expand the "
f"wording with a few truthful, on-message words (keep .fb-text under "
f"~400 chars -- past that the gate only judges a single stranded word, not banner fill); (d) trim it to one-fewer "
f"full line. Keep the change proportionate -- don't push one lever to an "
f"extreme (e.g. a blown-up font) just to clear the gate, and never force "
f"it with text-align: justify / text-align-last or letter-spacing "
f"padding. Re-render and look after. "
f"Context: '{ascii_safe(w['text'])}'."
)
else:
mode = w.get("mode", "std")
if mode == "long":
where = (" (long running prose -- judged by the single-"
"stranded-word bar only)")
elif mode == "generic":
where = (" (unlisted block type, judged by the single-"
"stranded-word bar; a lone stranded word reads "
"wrong in ANY block)")
else:
where = ""
warns.append(
f"WIDOW: <{ascii_safe(w['tag'])} class='{ascii_safe(w['cls'])}'> "
f"wraps to a stranded last line that fills only "
f"{int(w['frac'])}% of the typeset width ('{ascii_safe(w['word'])}'), "
f"a runt (SKILL.md Gate B){where}. Fix by REWORDING first: "
f"expand or trim a few words so the break lands on a phrase "
f"boundary and the last line carries more of the measure (if "
f"alignment is already tuned, swap a word for a longer synonym "
f"to keep the line count). On a CENTERED heading/title use "
f"text-wrap: balance instead. &nbsp;-glue is a LAST RESORT for "
f"a leading marker or a tight stat cell only -- glue at most "
f"two tokens; never chain more (a fused multi-word unit wraps "
f"early and tears a hole in the line above -- the GLUE-CHAIN "
f"gate flags it). Context: '{ascii_safe(w['text'])}'."
)
# ---- Gate B (glue chains): >=3 words fused with &nbsp; ----
# The lazy widow "fix": fusing the last N words so the last line clears
# the runt bar. The fused unit is unbreakable, so it wraps EARLY as a
# whole -- the line above breaks with room to spare and the paragraph
# ships a mid-line hole the WIDOW width test cannot see (it only measures
# the LAST line). Numeric runs (stat comparisons) are exempted JS-side.
for gc in data.get("glueChains", []):
warns.append(
f"GLUE-CHAIN: <{ascii_safe(gc['tag'])} class='{ascii_safe(gc['cls'])}'> "
f"fuses {gc['words']} words with &nbsp; "
f"('{ascii_safe(gc['chain'])}') -- an unbreakable unit this long "
f"wraps early as a whole and tears a hole in the line above. "
f"Unglue it and REWORD the sentence instead (SKILL.md Gate B); "
f"keep &nbsp; for at most two tokens (a leading marker, a stat "
f"cell)."
)
# ---- Gate B (text-wrap census): wrap protection dropped wholesale ----
census = data.get("wrapCensus", [])
unprot = [c for c in census if not c.get("protected")]
min_unprot = getattr(
args, "min_unprotected_wraps", DEFAULT_MIN_UNPROTECTED_WRAPS)
if census and len(unprot) >= min_unprot:
sample_cls = []
for c in unprot:
cls_toks = str(c.get("cls") or "").split()
name = (
("." + cls_toks[0]) if cls_toks
else f"<{c.get('tag', '?')}>"
)
if name not in sample_cls:
sample_cls.append(name)
if len(sample_cls) >= 5:
break
warns.append(
f"TEXT-WRAP: {len(unprot)} of {len(census)} wrapped text blocks "
f"carry neither `text-wrap: pretty` nor `balance` (e.g. "
f"{ascii_safe(', '.join(sample_cls))}) -- the templates' "
f"protective defaults were dropped, which is how custom skeletons "
f"strand single-word widows and ragged titles. Add the base "
f"defenses block (SKILL.md Step 3): `pretty` on prose, "
f"`balance` on centered display text."
)
# ---- Gate C: space-between fill ----
for c in data.get("cols", []):
col_h = float(c["column_h"])
excess = float(c["max_excess_px"])
if col_h <= 0:
continue
fill = excess / col_h
if fill > args.max_space_between_fill:
warns.append(
f"SPACE-BETWEEN: column {c['column_index']} has a "
f"{excess:.0f} px inter-card gap "
f"({fill * 100:.1f}% of column height, stated gap "
f"{c['stated_gap_px']:.0f} px). Balance via "
f"meaningful content, not justify-content. See "
f"Gate C in SKILL.md."
)
# ---- Gate C (one card): trailing whitespace below the last line ----
for c in data.get("cards", []):
ch = float(c["card_h"])
tr = float(c["trailing_px"])
if ch <= 0 or tr <= 0:
continue
ratio = tr / ch
if ratio > args.max_card_trailing:
warns.append(
f"CARD/TRAILING: card {c['card_index']} fills only "
f"{100 - ratio * 100:.0f}% of its height -- {tr:.0f} px "
f"({ratio * 100:.0f}%) blank below the last line. A card "
f"stretched to align (flex:1) but padded with whitespace "
f"clears the bottom-edge gate yet reads as unfinished. Fill "
f"with real content, grow a figure, or shrink the canvas. "
f"See Gate C in SKILL.md."
)
# ---- Gate C (one card): mid-card void between two stacked children ----
iv_ratio = getattr(args, "max_card_inner_void", DEFAULT_CARD_INNER_VOID)
iv_floor = getattr(
args, "min_card_inner_void_px", DEFAULT_CARD_INNER_VOID_PX)
for c in data.get("innerVoids", []):
ch = float(c["card_h"])
excess = float(c["excess"])
if ch <= 0 or excess <= iv_floor:
continue
if excess / ch <= iv_ratio:
continue
between = ""
if c.get("above") and c.get("below"):
between = (f" between <{ascii_safe(c['above'])}> and "
f"<{ascii_safe(c['below'])}>")
warns.append(
f"CARD/INNER-VOID: a <{ascii_safe(c['cls'])}> card has a "
f"{excess:.0f} px gap ({excess / ch * 100:.0f}% of card height, "
f"stated row-gap {c['stated_gap']:.0f} px){between} -- a void in "
f"the MIDDLE of the card, below the last real block and above a "
f"bottom-pinned one. The usual cause is an equal-height row "
f"(grid/flex `align-items: stretch`) of cards with unequal "
f"content where the short card pins its tail with "
f"`margin-top: auto` (or `justify-content: space-*`). Fill the "
f"short card with substance, or drop the bottom-pin / "
f"equal-height stretch so it hugs its content. See Gate C in "
f"SKILL.md."
)
# ---- Gate C (tracks): the same void inside a header/footer track ----
# A vertical masthead spine / side rail is neither a measure column nor a
# `.card`, so SPACE-BETWEEN, CARD/TRAILING and CARD/INNER-VOID are all
# blind there. Same thresholds as the card check. The fix ORDER matters
# (SKILL.md "Slack in a track"): real content first; then a CONSISTENT
# one-step type bump applied to a whole role -- never a single block
# blown up to eat one gap, which ships a patchwork of sizes; then narrow
# the track. Never absorb slack with justify-content: space-*.
for t in data.get("trackVoids", []):
th = float(t.get("track_h", 0) or 0)
excess = float(t.get("excess", 0) or 0)
if th <= 0 or excess <= iv_floor:
continue
if excess / th <= iv_ratio:
continue
between = ""
if t.get("above") and t.get("below"):
between = (f" between <{ascii_safe(t['above'])}> and "
f"<{ascii_safe(t['below'])}>")
sb = (" (`justify-content: space-between` is stretching it -- that "
"is the whitespace-stretch SKILL.md forbids on any track)"
if t.get("space_between") else "")
warns.append(
f"TRACK/INNER-VOID: the {ascii_safe(t.get('role', 'track'))} "
f"track <{ascii_safe(t.get('cls', ''))}> has a {excess:.0f} px "
f"gap ({excess / th * 100:.0f}% of track height, stated row-gap "
f"{t['stated_gap']:.0f} px){between}{sb}. Absorb the slack with "
f"substance, in this order: (1) real content the track earns "
f"(a legend row, a mini-figure, a fuller byline); (2) bump the "
f"track's SUBORDINATE text one --fs-* step -- applied to the "
f"whole role consistently (every legend row, the whole byline), "
f"never one block alone, and keep it below the track's display "
f"register; (3) narrow the track and give the width back to the "
f"body. Never stretch with justify-content: space-*. See Slack "
f"in a track, SKILL.md."
)
# ---- Gate G: composed text/ground contrast ----
# Declared token pairs are style_check's job; this judges what actually
# rendered. Dedup by (class, fg, bg) so one styling mistake repeated in
# ten spans reads as one warning, not ten.
min_contrast = getattr(args, "min_contrast", DEFAULT_MIN_CONTRAST)
seen_contrast: dict[tuple, dict] = {}
for c in data.get("contrasts", []):
ratio = float(c.get("ratio", 99))
if ratio >= min_contrast:
continue
key = (str(c.get("cls", "")), str(c.get("fg", "")),
str(c.get("bg", "")))
prev = seen_contrast.get(key)
if prev is None:
entry = dict(c)
entry["count"] = int(c.get("count", 1) or 1)
seen_contrast[key] = entry
else:
prev["count"] += int(c.get("count", 1) or 1)
if ratio < float(prev.get("ratio", 99)):
prev["ratio"] = ratio
prev["text"] = c.get("text", prev.get("text"))
for entry in sorted(seen_contrast.values(),
key=lambda e: float(e.get("ratio", 99)))[:10]:
more = (f" (+{entry['count'] - 1} more run(s) of this class/color "
f"pair)" if entry["count"] > 1 else "")
warns.append(
f"CONTRAST: <{ascii_safe(entry['tag'])} "
f"class='{ascii_safe(entry['cls'])}'> "
f"'{ascii_safe(entry['text'])}' renders {entry['fg']} on "
f"{entry['bg']} = {entry['ratio']}:1 (floor "
f"{min_contrast}:1){more}. The usual cause: an inline "
f"emphasis/highlight class sets a background but INHERITS its "
f"text color from a different ground -- any class that paints a "
f"background must declare its own `color`. Fix the token pairing "
f"(use the matching *-ink token), then re-render and look."
)
# ---- Gate D: <br> inside a flex container ----
# A <br> that is a direct child of a flex container is blockified into
# a flex item and creates NO line break, so intended multi-line text
# collapses onto one row. Detectable only at render time (getComputed-
# Style), which is why it lives here and not in preflight's static scan.
for fb in data.get("flexbr", []):
cls = str(fb.get("cls", ""))
cls_attr = f' class="{ascii_safe(cls)}"' if cls else ""
warns.append(
f"LAYOUT/FLEX-BR: <{ascii_safe(fb['tag'])}{cls_attr}> is "
f"display:flex (flex-direction:{fb['dir']}) with a direct <br> "
f"child -- the <br> is blockified into a flex item and creates "
f"NO line break, so intended multi-line content collapses onto "
f"one row. Wrap each line in a <span> and use "
f"flex-direction:column, or make the wrapper a plain block."
)
# ---- Gate E: header logos / QR / title squeeze ----
# Header logos live outside any card/hero, so Gates A-D never see
# them. Read defensively (getattr) like tall_min above.
logo_max_w = getattr(
args, "logo_max_width_ratio", DEFAULT_LOGO_MAX_WIDTH_RATIO)
logo_qr_tol = getattr(args, "logo_qr_tol", DEFAULT_LOGO_QR_TOL)
right_max = getattr(
args, "rightblock_max_ratio", DEFAULT_RIGHTBLOCK_MAX_RATIO)
title_min = getattr(args, "title_min_ratio", DEFAULT_TITLE_MIN_RATIO)
title_offset_max = getattr(
args, "title_offset_max", DEFAULT_TITLE_OFFSET_MAX)
header_w = float(data.get("header_w", 0) or 0)
header_h = float(data.get("header_h", 0) or 0)
header_cx = float(data.get("header_cx", 0) or 0)
# Vertical-rail masthead (portrait title spine, DESIGN-AXES Axis 1
# P5): the horizontal-strip calibrations below (LOGO/WIDE %, the QR
# height match, TITLE-SQUEEZED/-OFFCENTER) mis-fire on a tall narrow
# rail and are swapped for rail checks; see RAIL_MIN_ASPECT.
rail_header = header_w > 0 and header_h / header_w > RAIL_MIN_ASPECT
# content-box edges (used by the overflow checks; left/right also cap
# the logo width in rail mode).
content_l = float(data.get("header_content_left", 0) or 0)
content_r = float(data.get("header_content_right", 0) or 0)
content_t = float(data.get("header_content_top", 0) or 0)
content_b = float(data.get("header_content_bottom", 0) or 0)
qr_h = max((float(q.get("rendered_h", 0)) for q in data.get("qrs", [])),
default=0.0)
wide_lo, wide_hi = LOGO_WIDE_QR_BAND
for lg in data.get("logos", []):
lw = float(lg.get("rendered_w", 0))
lh = float(lg.get("rendered_h", 0))
nw = float(lg.get("natural_w", 0))
nh = float(lg.get("natural_h", 0))
src_l = str(lg.get("src", "")).lower()
# Same SVG exemption as FIG/BROKEN above: zero natural size is
# legitimate for vector images.
src_path = src_l.split("?", 1)[0].split("#", 1)[0]
is_svg = (
src_path.endswith((".svg", ".svgz"))
or src_l.startswith("data:image/svg")
)
if (nw <= 0 or nh <= 0) and not is_svg:
warns.append(
f"LOGO/BROKEN: header logo '{ascii_safe(lg['src'])}' has "
f"zero natural size -- the image failed to load (missing "
f"file, 404, or an unreachable remote URL); it will be "
f"blank in print."
)
continue
if rail_header:
# In a rail the logo does not compete with the title for
# horizontal room -- the %-of-header-width cap is meaningless.
# The real rail defect is a logo wider than the rail itself.
rail_w = content_r - content_l
if rail_w > 0 and lw > rail_w + 2.0:
warns.append(
f"LOGO/WIDE: '{ascii_safe(lg['src'])}' renders "
f"{lw:.0f}px wide but the vertical-rail masthead's "
f"content box is only {rail_w:.0f}px -- it spills past "
f"the rail. Shrink the logo (size class on the "
f".logo-slot, or width:100% inside the rail); see Logo "
f"handling in SKILL.md."
)
elif header_w > 0 and lw / header_w > logo_max_w:
warns.append(
f"LOGO/WIDE: '{ascii_safe(lg['src'])}' renders at "
f"{lw / header_w * 100:.0f}% of header width (limit "
f"{logo_max_w * 100:.0f}%) -- it crowds the title block. "
f"Set a size class on the .logo-slot (logo-wide caps a "
f"wordmark); see Logo handling in SKILL.md."
)
# The venue badge sits left of the title at its own scale, and a
# logo-stack is normalized by WIDTH (intentionally NOT QR-height-
# matched) -- for both, only the broken/width checks above apply;
# the QR height match is a height-matched-row rule. A rail
# masthead stacks logos ABOVE the QR, so no height-matched row
# exists there at all.
if (rail_header or lg.get("venue") or lg.get("stacked")
or qr_h <= 0 or lh <= 0):
continue
if "logo-wide" in str(lg.get("slot_classes", "")):
ratio = lh / qr_h
if not (wide_lo <= ratio <= wide_hi):
warns.append(
f"LOGO/QR-MISMATCH: wide logo '{ascii_safe(lg['src'])}' "
f"is {ratio * 100:.0f}% of QR height -- a wide wordmark "
f"reads level at {wide_lo * 100:.0f}-"
f"{wide_hi * 100:.0f}%. Size it via the logo-wide "
f"class or a tokenized variant; see Logo handling "
f"in SKILL.md."
)
elif abs(lh - qr_h) / qr_h > logo_qr_tol:
warns.append(
f"LOGO/QR-MISMATCH: logo '{ascii_safe(lg['src'])}' renders "
f"{lh:.0f}px tall vs QR {qr_h:.0f}px "
f"(>{logo_qr_tol * 100:.0f}% off) -- match heights via the "
f".logo-slot size class so the header strip reads level. "
f"See Logo handling in SKILL.md."
)
if header_w > 0 and not rail_header:
# Title-squeeze / centring ratios only describe a horizontal
# strip; a rail's title runs vertically and has no side blocks.
for hb in data.get("headerBlocks", []):
w = float(hb.get("w", 0))
if w <= 0:
continue
frac = w / header_w
if hb.get("kind") == "right" and frac > right_max:
warns.append(
f"HEADER/TITLE-SQUEEZED: header right block "
f"('{ascii_safe(hb['cls'])}') takes {frac * 100:.0f}% "
f"of header width (limit {right_max * 100:.0f}%) -- "
f"that leaves too little room for the centred title. "
f"Shrink or stack the logos/QR; see Logo handling in "
f"SKILL.md."
)
elif hb.get("kind") == "title":
if frac < title_min:
warns.append(
f"HEADER/TITLE-SQUEEZED: title block squeezed to "
f"{frac * 100:.0f}% of header width (floor "
f"{title_min * 100:.0f}%) -- logos/venue/QR are "
f"crowding the title. Shrink or stack the side "
f"blocks; see Logo handling in SKILL.md."
)
cx = float(hb.get("cx", 0) or 0)
if header_cx > 0 and cx > 0:
off = abs(cx - header_cx) / header_w
if off > title_offset_max:
warns.append(
f"HEADER/TITLE-OFFCENTER: the title sits "
f"{off * 100:.0f}% of header width off the "
f"poster's centre line (limit "
f"{title_offset_max * 100:.0f}%) -- one side "
f"block (logo / venue badge / QR) is heavier "
f"than the other, pushing the centred title "
f"aside. Proper logo/QR sizing and a clean "
f"layout come first; if you can rebalance the "
f"header (shrink, stack, or move the heavier "
f"side, or widen the lighter one) WITHOUT "
f"shrinking the logo/QR below a legible size, "
f"do so -- otherwise it is an accepted "
f"trade-off. See Logo handling in SKILL.md."
)
# Header overflow -- the case the ratio + offset gates miss: both side
# blocks large but balanced keeps the title centred and each side under
# its ratio, yet the row overflows the header content box (the centre
# track is floored at 50%, so it cannot give way). measure's clipping
# gate does not watch the header, so this is the only signal. We test
# the BOX edges against the header content box -- not block-vs-title
# overlap, since a title-block box floored at 50% is intentionally wide
# (text centred inside whitespace) and would overlap a neighbour's box
# without the visible text colliding. Runs in BOTH modes: a rail's
# blocks can spill sideways (too wide for the rail) just as a strip's
# can, and in rail mode the vertical edges are checked too (the rail
# stacks its blocks, so the overflow axis is vertical there).
if header_w > 0 and content_r > content_l:
for hb in data.get("headerBlocks", []):
spills_x = (float(hb.get("right", 0)) > content_r + 2.0
or float(hb.get("left", 0)) < content_l - 2.0)
spills_y = (rail_header and content_b > content_t
and (float(hb.get("bottom", 0)) > content_b + 2.0
or float(hb.get("top", 0)) < content_t - 2.0))
if not (spills_x or spills_y):
continue
if rail_header:
warns.append(
f"HEADER/OVERFLOW: the masthead block "
f"'{ascii_safe(hb.get('cls', ''))}' spills past the "
f"vertical rail's "
f"{'side' if spills_x else 'top/bottom'} edge -- the "
f"rail stacks its blocks, so shrink the block or give "
f"the stack less content; see Logo handling in "
f"SKILL.md."
)
else:
warns.append(
f"HEADER/OVERFLOW: the header block "
f"'{ascii_safe(hb.get('cls', ''))}' spills past the "
f"header edge -- the side blocks (logo / venue badge / "
f"QR) are too wide to sit beside the title at its 50% "
f"floor, so the row overflows instead of shrinking the "
f"title. Shrink or stack the side blocks, or drop one; "
f"see Logo handling in SKILL.md."
)
break
# ---- Gate F: framework-banner image slot ----
# A captioned method figure in the framework banner whose flex-item slot is
# much wider than the image wastes banner width and steals room from the
# body. Fires on EITHER a one-sided dead band (image pinned to one side) OR
# a caption that expands the slot past the image (the half-fix: margin:auto
# evens the gaps but a long single-line caption still sets the width).
# Anchored on the img; the shipped `banner-figure` (width:min-content)
# collapses the slot to the image and never trips it. Gates A/A2 scan
# card/hero/band images but not banner's, so banner images are
# otherwise unchecked.
slot_min_pic_w = getattr(
args, "banner_slot_min_pic_w", DEFAULT_BANNER_SLOT_MIN_PIC_W)
slot_min_pic_h = getattr(
args, "banner_slot_min_pic_h", DEFAULT_BANNER_SLOT_MIN_PIC_H)
for b in data.get("bannerImgs", []):
if b.get("slot_is_img"):
continue # a bare <img> IS its slot -> no over-allocation possible
banner_w = float(b.get("banner_w", 0) or 0)
slot_w = float(b.get("slot_w", 0) or 0)
rw = float(b.get("rendered_w", 0) or 0)
rh = float(b.get("rendered_h", 0) or 0)
nw = float(b.get("natural_w", 0) or 0)
nh = float(b.get("natural_h", 0) or 0)
if rw <= 0 or rh <= 0 or banner_w <= 0 or slot_w <= 0:
continue
# Visible PICTURE width inside the <img> box (object-fit letterbox),
# mirroring Gate A: a contain/scale-down/none image leaves internal
# voids that belong in the side gaps.
if nw > 0 and nh > 0:
ar = nw / nh
elif rh > 0:
ar = rw / rh
else:
continue
obj_fit = str(b.get("obj_fit", "")).strip().lower()
if obj_fit in ("contain", "scale-down") and rh > 0 and ar > 0:
content_w = min(rw, rh * ar)
if obj_fit == "scale-down" and nw > 0:
content_w = min(content_w, nw)
elif obj_fit == "none" and nw > 0:
content_w = min(rw, nw)
else:
content_w = rw
pic_w = content_w
if pic_w < slot_min_pic_w or rh < slot_min_pic_h:
continue # inline icon / small mark, not a method figure
void = max(0.0, rw - content_w)
left_gap = max(0.0, float(b.get("off_left", 0) or 0) + void / 2.0)
right_gap = max(0.0, float(b.get("off_right", 0) or 0) + void / 2.0)
slack = left_gap + right_gap
delta = abs(left_gap - right_gap)
cap_w = float(b.get("caption_like_w", 0) or 0)
overallocated = slack >= max(180.0, 0.035 * banner_w, 0.18 * pic_w)
if not overallocated:
continue
# slack >= 180 here, so the delta/slack ratio is always well-defined.
asymmetric = (
max(left_gap, right_gap) >= max(140.0, 0.030 * banner_w)
and delta >= max(120.0, 0.025 * banner_w, 0.12 * pic_w)
and (delta / slack) >= 0.55
)
caption_expanded = (cap_w >= pic_w * 1.15 and cap_w >= slot_w - 6.0)
if not (asymmetric or caption_expanded):
continue
if asymmetric and caption_expanded:
cause = ("the image is pinned to one side AND a long caption is "
"stretching the figure block")
elif asymmetric:
cause = "the image is pinned to one side of an over-wide block"
else:
cause = ("a long caption is setting the figure-block width -- the "
"image is centred but the slot still stretches to the "
"caption")
warns.append(
f"BANNER/IMAGE-SLOT: '{ascii_safe(b.get('src', ''))}' sits in a "
f"banner figure slot {slot_w:.0f}px wide while the image is only "
f"{pic_w:.0f}px -- {slack:.0f}px of unused width beside it "
f"({cause}). The banner text block beside the figure is its "
f"explanation, so the figure usually needs NO caption -- drop it. "
f"Use the captionless `banner-figure` component (width:min-content "
f"collapses the slot to the image; any short caption wraps at the "
f"image box and never sets the width; centre a block image with "
f"margin-inline:auto, not text-align). See banner-figure in "
f"COMPONENTS.md."
)
# ---- Identity mark (identity-v1) advisories (SOFT nudges) ----
# preflight already HARD-enforces corner PRESENCE, the anonymity
# reverse-check, and at-most-one woven. Polish only nudges: (1) the woven
# authoring mark when it's absent, and (2) confirms the always-on corner
# actually renders (a clipped / hidden corner silently defeats G1).
# Placement-quality / collision / legibility are deferred. Legacy posters
# (no data-ps-identity) get no advisory.
try:
_id_html = html_path.read_text(encoding="utf-8", errors="ignore")
except OSError:
_id_html = ""
# Structure-aware scan (shared with preflight) so a woven example inside
# a comment or an inert <template> can neither fake nor suppress the
# advisory.
if _preflight.identity_woven_missing(_id_html):
warns.append(
"IDENTITY/WOVEN: no woven identity mark "
"(data-ps-mark=\"woven\") -- add one registration-mark glyph "
"riding EXISTING content (a best/target marker, a terminal "
"period, an inline bullet, ...); it is the second identity "
"layer beyond the always-on corner signature. Never let it "
"solely carry a scientific claim (Ours/Best/target must stay "
"readable from text or table styling)."
)
_cs = data.get("cornerSig")
if _cs is not None:
_ink = _cs.get("inkAlpha")
_eff = _cs.get("effOp")
_ubw = _cs.get("ubw")
_ubh = _cs.get("ubh")
if (_cs.get("w", 0) < 1 or _cs.get("h", 0) < 1
or _cs.get("vis") == "hidden" or _cs.get("disp") == "none"
or (_cs.get("op") or 0) <= 0
or not _cs.get("hasGlyph", True)
or _cs.get("gw", 0) < 1 or _cs.get("gh", 0) < 1
or _cs.get("gvis") == "hidden" or _cs.get("gdisp") == "none"
or (_ubw is not None and _ubw < 0.5)
or (_ubh is not None and _ubh < 0.5)
or not _cs.get("uIn", True)
or (_ink if _ink is not None else 1) <= 0
or (_eff if _eff is not None else 1) <= 0):
warns.append(
"IDENTITY/CORNER: the corner signature is present in markup "
"but does not paint (zero-size / hidden / display:none / "
"opacity 0 on it or an ancestor / transparent ink / no #psReg "
"use, or the reference resolves to nothing, is display:none "
"inside the symbol, or is shifted outside its viewport) -- it "
"would not appear on the PDF. Restore its size, visibility "
"and ink."
)
elif not _cs.get("inside", True):
warns.append(
"IDENTITY/CORNER: the corner signature renders outside the "
"poster box -- reposition it into the bottom-right padding "
"safe zone."
)
print(f"[polish] {ascii_safe(html_path.name)}")
print(f" figures checked : {len(data.get('figures', []))}")
print(f" stat-like elements : {len(data.get('orphans', []))}")
print(f" prose widows : {len(data.get('widows', []))}")
print(f" glue chains : {len(data.get('glueChains', []))}")
print(f" wrapped text blocks : {len(data.get('wrapCensus', []))}")
print(f" space-between cols : {len(data.get('cols', []))}")
print(f" cards checked : {len(data.get('cards', []))}")
print(f" inner-void cards : {len(data.get('innerVoids', []))}")
print(f" tracks w/ void geom : {len(data.get('trackVoids', []))}")
print(f" contrast pairs (<7) : {len(data.get('contrasts', []))}")
print(f" beside-text floats : {len(data.get('besideVoids', []))}")
print(f" flex/<br> parents : {len(data.get('flexbr', []))}")
print(f" header logos : {len(data.get('logos', []))}")
if rail_header:
print(f" header masthead : vertical rail "
f"({header_w:.0f}x{header_h:.0f}px) -- horizontal "
f"calibrations (LOGO/WIDE %, LOGO/QR-MISMATCH, TITLE-*) "
f"swapped for rail checks")
print(f" banner images : {len(data.get('bannerImgs', []))}")
print(f" warnings : {len(warns)}")
for w in warns:
print(f" WARN: {w}")
if args.strict and warns:
_eprint("[polish] FAIL -- --strict and warnings present")
return 1
print("[polish] PASS" if not warns
else "[polish] OK (warnings only)")
return 0

Xet Storage Details

Size:
139 kB
·
Xet hash:
e16fc7cd473e3780967b3edd12ac798204356eecd613f54befd5a50186e091ba

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.