Buckets:
| """Alignment + gap-to-strip measurement — the HARD gate. | |
| This is the only gate that decides whether columns visually align. It | |
| print-emulates the HTML in headless Chromium, reads the geometry of | |
| every ``[data-measure-role]`` element, and reports two numbers: | |
| - **spread**: max−min of last-card-bottoms across all columns | |
| (plus any hero panel). Aim < 3 px; default fail threshold 5 px. | |
| - **gap → footer-strip/footer**: distance from the last card's | |
| bottom to the next horizontal strip. Aim [30, 50] px so card | |
| shadows clear but cards don't visually float. | |
| Non-negotiables built in: an empty column hard-fails (fallback to | |
| column.bottom is risky); missing footer-strip/footer hard-fails; a | |
| MathJax typeset error / timeout / silent CDN block hard-fails. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import math | |
| import sys | |
| from pathlib import Path | |
| from typing import Any | |
| from . import budget as _budget | |
| from . import canvas as _canvas | |
| from . import polish as _polish | |
| from . import render as _render | |
| from .textutil import ascii_safe | |
| def _eprint(*args: Any, **kw: Any) -> None: | |
| print(*args, file=sys.stderr, **kw) | |
| #: Hard ceiling for the whitespace between consecutive stacked cards in a | |
| #: column. Same ceiling as the footer gap: anything wider reads as a void | |
| #: in print. Shared by the CLI default in poster_check.py and the getattr | |
| #: fallback in cmd_measure. | |
| DEFAULT_MAX_INTERCARD_GAP = 50.0 | |
| #: Hard floor for the same gap. The shipped card shadow is | |
| #: ``0 2u 6u`` (offset ~7.6 px + blur ~22.7 px at print scale, u = 1mm); | |
| #: a gap under ~12 px buries the shadow core under the next card, so the | |
| #: stack reads as one fused slab instead of separate cards. Floor sits | |
| #: well under the shipped 6u (~22.7 px) design gap; tune (or 0 to | |
| #: disable) for shadowless custom themes. | |
| DEFAULT_MIN_INTERCARD_GAP = 12.0 | |
| def intercard_gaps(cards: list[dict]) -> list[float]: | |
| """Vertical gaps between consecutive *rows* of cards in one column. | |
| Cards are grouped into rows by vertical-overlap chaining (sorted by | |
| top; a card whose top sits above the current row's bottom joins that | |
| row), so two half-width cards sitting side by side count as ONE row | |
| and don't produce a bogus negative/huge "gap". Returns one gap per | |
| consecutive row pair. Pure function so the grouping rule is | |
| unit-testable without Chromium. | |
| """ | |
| if len(cards) < 2: | |
| return [] | |
| rows: list[list[float]] = [] # [top, bottom] per row | |
| for c in sorted(cards, key=lambda c: c["y"]): | |
| if rows and c["y"] < rows[-1][1]: | |
| rows[-1][1] = max(rows[-1][1], c["bottom"]) | |
| else: | |
| rows.append([c["y"], c["bottom"]]) | |
| return [rows[i][0] - rows[i - 1][1] for i in range(1, len(rows))] | |
| _MEASURE_JS = r""" | |
| () => { | |
| const nodes = Array.from(document.querySelectorAll('[data-measure-role]')); | |
| let cardOrdinal = 0; | |
| return nodes.map(n => { | |
| const r = n.getBoundingClientRect(); | |
| const cs = window.getComputedStyle(n); | |
| const role = n.getAttribute('data-measure-role') || ''; | |
| // Edit-target anchor for cards: the section-title text (fallback: | |
| // the card's own text), with MathJax containers stripped -- typeset | |
| // math mutates the DOM, so its innerText would not match the source | |
| // HTML the agent greps. This is a LOCATOR, not verbatim source. | |
| let anchor = ''; | |
| let cardIdx = -1; | |
| if (role === 'card') { | |
| cardIdx = cardOrdinal++; | |
| const src = n.querySelector('.section-title') || n; | |
| const clone = src.cloneNode(true); | |
| clone.querySelectorAll( | |
| 'mjx-container, mjx-assistive-mml, script, style' | |
| ).forEach(e => e.remove()); | |
| anchor = (clone.textContent || '') | |
| .replace(/\s+/g, ' ').trim().slice(0, 60); | |
| } | |
| return { | |
| role: role, | |
| tag: n.tagName.toLowerCase(), | |
| cls: n.className || '', | |
| anchor: anchor, | |
| card_idx: cardIdx, | |
| x: r.left, y: r.top, w: r.width, h: r.height, | |
| bottom: r.bottom, right: r.right, | |
| // For the content-clipping gate: the computed overflow plus the | |
| // scroll-vs-client deltas. `overflow != visible` decouples the | |
| // border-box (read above) from the real content extent; a positive | |
| // (scroll - client) is content sitting past the box edge that print | |
| // silently clips. Integer-rounded by the browser, so a small | |
| // tolerance on the Python side absorbs sub-pixel noise. | |
| overflow_x: cs.overflowX, overflow_y: cs.overflowY, | |
| scroll_h: n.scrollHeight, client_h: n.clientHeight, | |
| scroll_w: n.scrollWidth, client_w: n.clientWidth, | |
| }; | |
| }); | |
| } | |
| """ | |
| def compute_adjustment_hints( | |
| bottoms: list[tuple[str, float]], | |
| strip_top: float, | |
| *, | |
| min_gap: float, | |
| max_gap: float, | |
| max_spread: float, | |
| epsilon: float = 0.5, | |
| ) -> tuple[tuple[float, float], list[tuple[str, float, str]]]: | |
| """Per-column adjustment hints for a failed measure run. | |
| Returns ``((band_lo, band_hi), adjustments)`` where ``adjustments`` | |
| is one ``(name, current_bottom, hint)`` per column / hero row. | |
| ``[band_lo, band_hi]`` is the **shared passing band**: when every | |
| column bottom lands inside this ONE band, both gates pass by | |
| construction -- the band sits inside the footer-gap window | |
| ``[strip_top - max_gap, strip_top - min_gap]``, and its width is | |
| ``max_spread - epsilon``, so the worst-case pairwise spread of | |
| in-band columns stays strictly under ``max_spread`` (the gate is a | |
| strict ``<``; ``epsilon`` keeps the two-columns-at-opposite-edges | |
| case from landing exactly ON the threshold). | |
| Per column, ``hint`` is one of: | |
| * ``"keep"`` -- the bottom is already inside the shared band. | |
| NOTE: deliberately NOT ``|delta| <= max_spread``: two columns at | |
| opposite edges of a ±max_spread tolerance would both read | |
| "keep" while their pairwise spread is 2x the gate. | |
| * ``"grow ~N px [safe +lo..+hi]"`` / ``"trim ~N px [safe ...]"`` | |
| -- the point estimate targets the band centre; the safe range | |
| is the full signed-delta interval into the band, integer- | |
| rounded INWARD (never widened past the true band). | |
| Pure function so the rule is unit-testable without Chromium. | |
| """ | |
| center = strip_top - (min_gap + max_gap) / 2.0 | |
| half = max(0.0, (max_spread - epsilon) / 2.0) | |
| band_lo = max(center - half, strip_top - max_gap) | |
| band_hi = min(center + half, strip_top - min_gap) | |
| if band_lo > band_hi: # degenerate flags (e.g. min_gap > max_gap) | |
| band_lo = band_hi = center | |
| out: list[tuple[str, float, str]] = [] | |
| for name, b in bottoms: | |
| if band_lo <= b <= band_hi: | |
| out.append((name, b, "keep")) | |
| continue | |
| delta = center - b # +ve grow, -ve trim | |
| d_lo = math.ceil(band_lo - b) # inward rounding: | |
| d_hi = math.floor(band_hi - b) # never overpromise the band | |
| verb = "grow" if delta > 0 else "trim" | |
| if d_lo > d_hi: | |
| # No whole-pixel delta lands inside the band (band narrower | |
| # than 1 px after inward rounding). Do NOT call anything | |
| # "safe" here -- give the fractional target instead, with | |
| # the fewest decimals whose DISPLAYED value still lands | |
| # in-band (a legal --max-spread 0.5 collapses the band to | |
| # a point, where two decimals can miss; six decimals leave | |
| # a <=5e-7 px residual, not actionable in CSS anyway). | |
| for prec in (2, 3, 4, 5, 6): | |
| if band_lo <= b + round(delta, prec) <= band_hi: | |
| break | |
| out.append(( | |
| name, b, | |
| f"{verb} ~{int(round(abs(delta)))} px " | |
| f"(no whole-px safe delta; aim {delta:+.{prec}f} px)", | |
| )) | |
| continue | |
| out.append(( | |
| name, b, | |
| f"{verb} ~{int(round(abs(delta)))} px " | |
| f"[safe {d_lo:+d}..{d_hi:+d}]", | |
| )) | |
| return (band_lo, band_hi), out | |
| def format_band(lo: float, hi: float) -> str: | |
| """Display the band rounded INWARD (lower edge up, upper edge | |
| down): after snapping edges within 2 ULP of a display grid point | |
| onto it, the printed range is never wider than the true one. Two | |
| decimals for normal bands, six for the sub-0.05px bands a tiny | |
| --max-spread can produce. A band narrower than 1e-6 px collapses | |
| to its centre (a <=5e-7 px approximation -- the only case where | |
| the display is not strictly inside the band).""" | |
| bp = 2 if (hi - lo) >= 0.05 else 6 | |
| scale = 10.0 ** bp | |
| def _snap(scaled: float) -> float: | |
| # An on-grid edge can scale to just above its integer | |
| # (2500.01 * 100 = 250001.00000000003) and get needlessly | |
| # ceil'd a whole step inward -- snap near-integer products. | |
| nearest = round(scaled) | |
| if abs(scaled - nearest) <= 2 * math.ulp(scaled): | |
| return float(nearest) | |
| return scaled | |
| disp_lo = math.ceil(_snap(lo * scale)) / scale | |
| disp_hi = math.floor(_snap(hi * scale)) / scale | |
| if disp_lo > disp_hi: # inward rounding inverted a hairline band | |
| disp_lo = disp_hi = round((lo + hi) / 2, 6) | |
| return f"{disp_lo:.{bp}f}..{disp_hi:.{bp}f}" | |
| def source_card_lines(html_text: str) -> list[int]: | |
| """Source line number of each ``data-measure-role="card"`` opening | |
| tag, in source order (== DOM order for static poster HTML). | |
| Reuses preflight's stack-based role scanner. Returns ``[]`` when the | |
| HTML can't be parsed -- callers must then omit line numbers rather | |
| than print wrong ones. | |
| """ | |
| from .preflight import _RoleNestingChecker | |
| try: | |
| parser = _RoleNestingChecker() | |
| parser.feed(html_text) | |
| except Exception: | |
| return [] | |
| return [ln for role, _p, ln, _t in parser.roles if role == "card"] | |
| def format_edit_targets( | |
| columns: list[dict[str, Any]], | |
| card_lines: list[int], | |
| total_dom_cards: int, | |
| *, | |
| bottom_tol: float = 0.5, | |
| ) -> list[str]: | |
| """Human lines for the edit-targets block (pure, unit-testable). | |
| ``columns``: one dict per column -- | |
| ``{"name", "hint", "cards": [{"card_idx", "x", "y", "bottom", | |
| "h", "anchor"}, ...]}`` (cards in any order; sorted here). | |
| ``card_lines``: source line per DOM card ordinal; used only when its | |
| length matches ``total_dom_cards`` (otherwise the mapping is not | |
| trustworthy and line numbers are omitted). | |
| Cards whose bottom ties the column bottom (within ``bottom_tol``) | |
| are ALL marked -- several cards can define the bottom jointly. | |
| """ | |
| have_lines = len(card_lines) == total_dom_cards and total_dom_cards > 0 | |
| out: list[str] = [] | |
| out.append( | |
| "[measure] edit targets -- cards per column, top-to-bottom." | |
| ) | |
| out.append( | |
| " Locate by source line (L<n>) or by grepping the quoted anchor;" | |
| ) | |
| out.append( | |
| " do NOT re-read the whole file. Anchors are section-title text" | |
| ) | |
| out.append( | |
| " with math stripped -- confirm uniqueness before editing." | |
| ) | |
| for col in columns: | |
| hint = col.get("hint") or "" | |
| out.append(f" {col['name']}" + (f" ({hint}):" if hint else ":")) | |
| cards = sorted( | |
| col.get("cards", []), key=lambda c: (c["y"], c["x"]) | |
| ) | |
| col_bottom = max((c["bottom"] for c in cards), default=None) | |
| for c in cards: | |
| line = ( | |
| f"L{card_lines[c['card_idx']]}" | |
| if have_lines and 0 <= c["card_idx"] < len(card_lines) | |
| else "L?" | |
| ) | |
| mark = ( | |
| " <- bottom card (sets the column bottom)" | |
| if col_bottom is not None | |
| and abs(c["bottom"] - col_bottom) <= bottom_tol | |
| else "" | |
| ) | |
| anchor = ascii_safe(c.get("anchor") or "") | |
| out.append( | |
| f" card#{c['card_idx']:<3d} {line:<7s} " | |
| f"h={c['h']:6.0f}px \"{anchor}\"{mark}" | |
| ) | |
| return out | |
| def group_layout( | |
| data: list[dict[str, Any]], | |
| ) -> tuple[ | |
| dict[int, dict[str, Any]], | |
| list[dict[str, Any]], | |
| list[dict[str, Any]], | |
| list[dict[str, Any]], | |
| ]: | |
| """Group raw ``[data-measure-role]`` boxes into the layout model. | |
| Returns ``(columns, heros, footer_strips, footers)``. ``columns`` is | |
| ``{index: {"box", "cards", "last_card_bottom"}}`` with cards | |
| attached to the column whose x-range contains the card's horizontal | |
| midpoint. Shared by ``measure`` and ``pack`` so both read the same | |
| geometry the same way. | |
| """ | |
| columns: dict[int, dict[str, Any]] = {} | |
| heros: list[dict[str, Any]] = [] | |
| footer_strips: list[dict[str, Any]] = [] | |
| footers: list[dict[str, Any]] = [] | |
| col_index = 0 | |
| for el in data: | |
| role = el["role"] | |
| if role == "column": | |
| columns[col_index] = {"box": el, "last_card_bottom": None} | |
| col_index += 1 | |
| elif role == "hero": | |
| heros.append(el) | |
| elif role == "footer-strip": | |
| footer_strips.append(el) | |
| elif role == "footer": | |
| footers.append(el) | |
| def x_overlaps(card: dict, box: dict) -> bool: | |
| cx_mid = card["x"] + card["w"] / 2 | |
| return box["x"] <= cx_mid <= box["x"] + box["w"] | |
| for el in data: | |
| if el["role"] != "card": | |
| continue | |
| for _ci, col in columns.items(): | |
| if x_overlaps(el, col["box"]): | |
| col.setdefault("cards", []).append(el) | |
| prev = col["last_card_bottom"] | |
| if prev is None or el["bottom"] > prev: | |
| col["last_card_bottom"] = el["bottom"] | |
| break | |
| return columns, heros, footer_strips, footers | |
| def pick_strip( | |
| footer_strips: list[dict[str, Any]], | |
| footers: list[dict[str, Any]], | |
| target: float, | |
| ) -> tuple[dict[str, Any] | None, str | None]: | |
| """The strip the footer-gap gate anchors on: the footer-strip (or, | |
| failing that, footer) whose top sits nearest ``target``.""" | |
| def _pick_nearest(strips: list[dict[str, Any]], | |
| tgt: float) -> dict[str, Any] | None: | |
| if not strips: | |
| return None | |
| return min(strips, key=lambda s: abs(s["y"] - tgt)) | |
| if footer_strips: | |
| return _pick_nearest(footer_strips, target), "footer-strip" | |
| if footers: | |
| return _pick_nearest(footers, target), "footer" | |
| return None, None | |
| def cmd_measure(args: argparse.Namespace) -> int: | |
| """Budget-aware wrapper: the geometry gate lives in | |
| ``_measure_once``; this adds the consecutive-failure circuit | |
| breaker (see ``_posterly.budget``).""" | |
| html_path = Path(args.html).resolve() | |
| if not html_path.exists(): | |
| _eprint(f"ERROR: HTML not found: {ascii_safe(html_path)}") | |
| return 2 | |
| cap = int( | |
| getattr(args, "measure_budget", _budget.DEFAULT_MEASURE_BUDGET) | |
| or 0 | |
| ) | |
| bpath = _budget.budget_path(html_path) | |
| if getattr(args, "reset_budget", False): | |
| # Honoured even with --measure-budget 0: deleting the state is | |
| # exactly what the flag asks for. A FAILED removal (after the | |
| # zero-write fallback also failed) must not let the stale count | |
| # fire a phantom pre-render breaker right after we claimed a | |
| # fresh start -- disable the budget for this run. | |
| warn = _budget.clear(bpath, html_path.name) | |
| if warn is None: | |
| print("[measure] budget reset (--reset-budget)") | |
| else: | |
| _eprint(f"[measure] budget-state warning: {warn} -- " | |
| "circuit breaker DISABLED for this run") | |
| cap = 0 | |
| if cap > 0: | |
| count, warn = _budget.load_count(bpath, html_path.name) | |
| if warn: | |
| _eprint(f"[measure] budget-state warning: {warn}") | |
| if count >= cap: | |
| _eprint(_budget.breaker_banner(count, cap, pre_render=True)) | |
| return _budget.EXIT_BUDGET_EXHAUSTED | |
| rc, measured = _measure_once(args, html_path) | |
| if cap > 0: | |
| if rc == 0: | |
| warn = _budget.clear(bpath, html_path.name) | |
| if warn: | |
| _eprint( | |
| f"[measure] budget-state warning: {warn} -- a " | |
| "later run may inherit this stale count; remove " | |
| f"{bpath.name} by hand or pass --reset-budget" | |
| ) | |
| elif rc == 1 and measured: | |
| count, warn = _budget.load_count(bpath, html_path.name) | |
| count += 1 | |
| warn2 = _budget.record_failure(bpath, html_path.name, count) | |
| for w in (warn, warn2): | |
| if w: | |
| _eprint(f"[measure] budget-state warning: {w}") | |
| print( | |
| f"[measure] consecutive failed measurements: " | |
| f"{count}/{cap} (resets on PASS or --reset-budget)" | |
| ) | |
| if count >= cap: | |
| _eprint(_budget.breaker_banner(count, cap)) | |
| return _budget.EXIT_BUDGET_EXHAUSTED | |
| return rc | |
| def _measure_once( | |
| args: argparse.Namespace, html_path: Path | |
| ) -> tuple[int, bool]: | |
| """One render + gate evaluation. | |
| Returns ``(rc, measured)``. ``measured`` is True only when the page | |
| produced geometry (the JS probe ran) -- the budget wrapper counts | |
| only those failures; environment/navigation problems are not loop | |
| iterations. | |
| """ | |
| 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, False | |
| resolved = _canvas.resolve_canvas( | |
| html_path, args.canvas, label="[measure]" | |
| ) | |
| if resolved is None: | |
| _eprint( | |
| "ERROR: could not find `@page { size: <W> <H> }` in HTML. " | |
| "Add an @page rule (units: in/mm/cm/pt) or pass " | |
| "`--canvas <W>x<H>in` / `--canvas 'A0 portrait'`. " | |
| "Refusing to silently fall back." | |
| ) | |
| return 2, False | |
| 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. A HARD gate must NOT measure 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, False | |
| if nav_timed_out: | |
| browser.close() | |
| _eprint( | |
| "FAIL: page did not reach network-idle within " | |
| f"{args.mathjax_timeout_ms} ms; refusing to measure 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, False | |
| data = page.evaluate(_MEASURE_JS) | |
| # Optional merged polish pass on the SAME rendered page -- one | |
| # Chromium launch per loop round instead of two. Advisory only: | |
| # it prints before the measure verdict below and never touches | |
| # this gate's exit code; the loop's final soft gate remains a | |
| # standalone `polish --strict` run. | |
| if getattr(args, "with_polish", False): | |
| _polish.advisory_polish_on_page(page, html_path) | |
| browser.close() | |
| if args.json_out: | |
| Path(args.json_out).write_text( | |
| json.dumps(data, indent=2), encoding="utf-8" | |
| ) | |
| print(f"[measure] raw data -> {ascii_safe(args.json_out)}") | |
| # Canvas-fill gate (coarse early diagnostic). The position-align | |
| # check below is the authoritative rule — any poster whose bbox | |
| # aligns to the page within `--position-tol-px` already fills | |
| # ≈ 100 % of the canvas. This ratio check fires earlier on two | |
| # specific failure modes with a more diagnostic error message: | |
| # (a) missing `[data-measure-role="poster"]` — measure can't | |
| # anchor the layout, so a silent PASS would be misleading; | |
| # (b) ratio FAR outside the band (e.g. 42 % when the poster | |
| # forgot the `@media print { :root { --u: 1mm } }` override | |
| # and rendered at screen scale, or 200 % when hardcoded | |
| # `width` exceeded `@page size`). The error message points | |
| # at the common print-scale bug. | |
| # For borderline 95–99 % cases, the position gate is the truth. | |
| # Safe-area design belongs as internal padding on a full-bleed | |
| # `.poster`, NOT as a smaller poster (which would clip the bbox | |
| # alignment check). | |
| poster_box = next((el for el in data if el["role"] == "poster"), None) | |
| if poster_box is None: | |
| _eprint( | |
| "FAIL: no [data-measure-role=\"poster\"] element found on " | |
| "the page. Add it to the root poster container -- measure " | |
| "needs it to verify the canvas-fill, and preflight already " | |
| "rejects pages without it." | |
| ) | |
| return 1, True | |
| vw, vh = viewport | |
| fill_w = poster_box["w"] / vw | |
| fill_h = poster_box["h"] / vh | |
| lo = args.min_canvas_fill | |
| hi = args.max_canvas_fill | |
| if not (lo <= fill_w <= hi) or not (lo <= fill_h <= hi): | |
| _eprint( | |
| f"FAIL: [data-measure-role=\"poster\"] fills " | |
| f"{fill_w * 100:.0f}% x {fill_h * 100:.0f}% of the print " | |
| f"viewport (target {lo * 100:.0f}% - {hi * 100:.0f}% in " | |
| f"BOTH dimensions). Common cause when too small: missing " | |
| f"`@media print {{ :root {{ --u: 1mm }} }}` so the poster " | |
| f"keeps the screen-mode unit scale in print. Common cause " | |
| f"when too large: hardcoded `width` exceeds `@page size`." | |
| ) | |
| return 1, True | |
| # Positional check: poster must be anchored to the page's origin | |
| # within `--position-tol-px`. A `transform: translateX(50 px)` would | |
| # silently clip the right side of the print PDF; size alone can't | |
| # see this. | |
| tol = args.position_tol_px | |
| pos_problems = [] | |
| if abs(poster_box["x"]) > tol: | |
| pos_problems.append(f"x={poster_box['x']:.1f} (expected ~= 0)") | |
| if abs(poster_box["y"]) > tol: | |
| pos_problems.append(f"y={poster_box['y']:.1f} (expected ~= 0)") | |
| if abs(poster_box["right"] - vw) > tol: | |
| pos_problems.append( | |
| f"right={poster_box['right']:.1f} (expected ~= {vw})" | |
| ) | |
| if abs(poster_box["bottom"] - vh) > tol: | |
| pos_problems.append( | |
| f"bottom={poster_box['bottom']:.1f} (expected ~= {vh})" | |
| ) | |
| if pos_problems: | |
| _eprint( | |
| "FAIL: [data-measure-role=\"poster\"] is not aligned to " | |
| f"the page (tolerance +/-{tol:.1f} px):\n" | |
| " " + ", ".join(pos_problems) + ".\n" | |
| "Fix: make `.poster` full-bleed in print --\n" | |
| " @media print {\n" | |
| " .poster { width: 100%; height: 100%;\n" | |
| " margin: 0; padding: 0 }\n" | |
| " html,body { margin: 0; padding: 0 }\n" | |
| " }\n" | |
| "Then drop any `transform: translate*` / " | |
| "`position: absolute` offsets.\n" | |
| "Also check: put `@media print` AFTER the screen " | |
| "`.poster` rule." | |
| ) | |
| return 1, True | |
| # Canvas-overflow gate (HARD). The poster box is now confirmed the | |
| # right size and origin -- but its CONTENT can still be wider/taller | |
| # than the canvas and get sliced off at the page boundary, invisibly | |
| # to every check above: the poster BOX stays 24x36 while its children | |
| # spill past. This is the single most damaging silent failure -- an | |
| # entire right (or bottom) strip of the poster vanishing in print -- | |
| # and nothing else catches it: canvas-fill/position read the poster | |
| # box, the clip gate below scans only card/column/hero/band, and the | |
| # spread/gap gates read vertical bottoms. `scrollWidth/Height` | |
| # includes the overflowing content in BOTH overflow modes (hidden: | |
| # clipped by the poster; visible: clipped by the page) -- verified in | |
| # Chromium -- and stays == client for a well-formed poster, so any | |
| # positive delta is a real off-canvas slice, not a mode artifact. | |
| # MathJax's off-screen `mjx-assistive-mml` a11y nodes are clipped to | |
| # 1px and do NOT inflate it (verified against math posters), so | |
| # unlike the clip gate this can safely read the poster root. | |
| # | |
| # SCOPE: `scrollWidth/Height` in an LTR document only grows for | |
| # overflow past the RIGHT/BOTTOM edge -- the direction the real bug | |
| # produced. Content pushed off the LEFT/TOP by a negative offset is | |
| # clipped WITHOUT inflating scrollWidth, so it is not caught here (a | |
| # grossly displaced poster is caught by position-align above; a | |
| # correctly-placed poster with a left-bled child stays an eyeball | |
| # gap). Any positive delta is genuine root scrollable overflow -- most | |
| # often the implicit-column case below, but equally e.g. a child | |
| # sliding under a thick opaque border, or an off-canvas hidden / | |
| # decorative layer; all are real "a fixed print canvas must not | |
| # scroll" failures, which is exactly why this stays a hard gate. | |
| # | |
| # The classic trigger: a `.poster` grid that declares | |
| # `grid-template-rows` but not `grid-template-columns`, so its single | |
| # implicit `auto` column grows to a wide child's max-content and | |
| # every full-width band overflows the canvas. Fix: pin the column | |
| # axis with `grid-template-columns: minmax(0, 1fr)` (or a track set | |
| # summing to the content width) -- the same minmax(0,.) defense the | |
| # body rows already carry. | |
| over_w = poster_box.get("scroll_w", 0) - poster_box.get("client_w", 0) | |
| over_h = poster_box.get("scroll_h", 0) - poster_box.get("client_h", 0) | |
| over_axes: list[str] = [] | |
| if over_w > args.max_clip_px: | |
| over_axes.append(f"{over_w:.0f}px past the right edge") | |
| if over_h > args.max_clip_px: | |
| over_axes.append(f"{over_h:.0f}px past the bottom edge") | |
| if over_axes: | |
| _eprint( | |
| "FAIL: the poster root has scrollable overflow -- content " | |
| "extends past the canvas box and is clipped at the page " | |
| "boundary (the box is the right size; the content spills " | |
| "beyond it):\n" | |
| " " + ", ".join(over_axes) + ".\n" | |
| f"(tolerance {args.max_clip_px:.0f} px). The usual cause is a " | |
| "`.poster` grid that sets `grid-template-rows` but no " | |
| "`grid-template-columns`: the implicit `auto` column grows to " | |
| "a wide child's max-content and every full-width row overflows " | |
| "the canvas. Fix: pin the column axis with " | |
| "`grid-template-columns: minmax(0, 1fr)` (or a track set that " | |
| "sums to the content width). Also check for a fixed-width " | |
| "child (a table, a `width:` in the wrong unit, an un-wrapped " | |
| "`nowrap` line) forcing the layout wider than the canvas." | |
| ) | |
| return 1, True | |
| # Content-clipping gate (HARD). Everything below reads each element's | |
| # border-box edge -- but `overflow` other than `visible` DECOUPLES that | |
| # box from the real content extent: anything past the edge is clipped | |
| # in print and silently lost, while the box (and so every spread/gap | |
| # number below) still looks clean. The classic trap is a flex | |
| # card/column/hero: when its overflow is hidden/scroll/auto its | |
| # `min-height: auto` is floored toward 0, so flexbox shrinks the | |
| # over-full item back inside its track and clips the overflow -- turning | |
| # a too-full poster into a false PASS. (A fixed-/max-height box with any | |
| # non-visible overflow clips the same way, without the flex step.) Catch | |
| # it directly by comparing scroll-size to client-size on the role | |
| # containers themselves. | |
| # | |
| # Scope is deliberately those role containers (card, column, hero, | |
| # band), NOT a full-descendant sweep: the latter trips over MathJax's | |
| # off-screen `<mjx-assistive-mml>` a11y nodes (overflow:hidden, a few | |
| # px of intrinsic overflow) and would false-fail every math poster. | |
| # card/column/hero are the roles whose bottoms feed spread/gap below; | |
| # `band` (the full-width portrait content band) never enters the | |
| # spread, but a hidden clip inside it loses print content just the | |
| # same, so it is scanned here too. Known limitation: an author-built | |
| # inner panel that clips via its own `max-height; overflow:hidden` | |
| # (e.g. a scroll-box around a wide table) is NOT scanned -- only the | |
| # role container itself. `overflow: visible` is never flagged: that | |
| # content spills VISIBLY and the existing gap/spread gate already sees | |
| # the displaced box -- only the *hidden* clip is invisible to it. | |
| clip_overflows = {"hidden", "clip", "scroll", "auto"} | |
| clip_problems: list[str] = [] | |
| for el in data: | |
| if el["role"] not in ("card", "column", "hero", "band"): | |
| continue | |
| oy = str(el.get("overflow_y") or "").lower() | |
| ox = str(el.get("overflow_x") or "").lower() | |
| dy = el.get("scroll_h", 0) - el.get("client_h", 0) | |
| dx = el.get("scroll_w", 0) - el.get("client_w", 0) | |
| axes: list[str] = [] | |
| if oy in clip_overflows and dy > args.max_clip_px: | |
| axes.append(f"{dy:.0f}px below the box (overflow-y: {oy})") | |
| if ox in clip_overflows and dx > args.max_clip_px: | |
| axes.append(f"{dx:.0f}px past the right (overflow-x: {ox})") | |
| if axes: | |
| cls = el.get("cls", "") | |
| ident = f"{el['role']} <{el['tag']}" + ( | |
| f" class=\"{cls}\"" if cls else "") + ">" | |
| clip_problems.append(f"{ident}: " + ", ".join(axes)) | |
| if clip_problems: | |
| _eprint( | |
| "FAIL: content overflows its box and is CLIPPED by " | |
| "overflow:hidden/clip/scroll/auto -- print drops it silently " | |
| "while the box still looks aligned:\n" | |
| + "\n".join(" " + p for p in clip_problems) | |
| + f"\n(tolerance {args.max_clip_px:.0f} px). Fix: remove the " | |
| "`overflow` rule so the content overflows VISIBLY -- measure " | |
| "then reports a negative gap pointing at the real 'too much " | |
| "content' problem -- then cut content, shrink fonts, or enlarge " | |
| "the canvas. Do NOT use overflow:hidden to make a too-full " | |
| "column 'pass': a flex item with overflow other than visible " | |
| "has min-height auto -> 0, so flexbox shrinks it and clips the " | |
| "overflow." | |
| ) | |
| return 1, True | |
| columns, heros, footer_strips, footers = group_layout(data) | |
| empty_cols = [ | |
| ci for ci, col in columns.items() | |
| if col["last_card_bottom"] is None | |
| ] | |
| if empty_cols and not args.allow_empty_column: | |
| _eprint( | |
| f"ERROR: columns with no cards detected: " | |
| f"{['col' + str(i) for i in empty_cols]}. " | |
| "Add cards or pass --allow-empty-column." | |
| ) | |
| return 1, True | |
| # Intra-column whitespace gate (HARD). The spread/gap gates only read | |
| # the LAST card's bottom -- `justify-content: space-between` (or a big | |
| # margin) pins the first card to the top and the last to the bottom, | |
| # so an under-filled column reads spread ~= 0 and a clean footer gap | |
| # while a void sits mid-column, plainly visible in print. (Observed | |
| # in the wild: 98-135 px voids against a 22.7 px design row-gap, with | |
| # polish's relative-threshold warn silent.) Gate: every gap between | |
| # consecutive stacked card rows must stay under --max-intercard-gap. | |
| # The same band has a floor: a gap under --min-intercard-gap buries | |
| # the card's drop shadow (`0 2u 6u` in the shipped templates) under | |
| # the next card, fusing the stack into one slab. | |
| max_icg = getattr( | |
| args, "max_intercard_gap", DEFAULT_MAX_INTERCARD_GAP | |
| ) | |
| min_icg = getattr( | |
| args, "min_intercard_gap", DEFAULT_MIN_INTERCARD_GAP | |
| ) | |
| icg_problems: list[str] = [] | |
| icg_tight: list[str] = [] | |
| icg_worst: tuple[str, float] | None = None | |
| icg_tightest: tuple[str, float] | None = None | |
| for ci, col in columns.items(): | |
| gaps_c = intercard_gaps(col.get("cards", [])) | |
| if not gaps_c: | |
| continue | |
| g = max(gaps_c) | |
| g_lo = min(gaps_c) | |
| if icg_worst is None or g > icg_worst[1]: | |
| icg_worst = (f"col{ci}", g) | |
| if icg_tightest is None or g_lo < icg_tightest[1]: | |
| icg_tightest = (f"col{ci}", g_lo) | |
| if g > max_icg: | |
| icg_problems.append( | |
| f"col{ci}: {g:.1f} px between stacked cards" | |
| ) | |
| if g_lo < min_icg: | |
| icg_tight.append( | |
| f"col{ci}: {g_lo:.1f} px between stacked cards" | |
| ) | |
| bottoms: list[tuple[str, float]] = [] | |
| for ci, col in columns.items(): | |
| b = col["last_card_bottom"] | |
| if b is None: | |
| b = col["box"]["bottom"] | |
| bottoms.append((f"col{ci}", b)) | |
| for hi, hero in enumerate(heros): | |
| bottoms.append( | |
| (f"hero{hi}" if len(heros) > 1 else "hero", hero["bottom"]) | |
| ) | |
| if not bottoms: | |
| _eprint( | |
| "ERROR: no columns or hero found. " | |
| 'Did you add data-measure-role="column"?' | |
| ) | |
| return 2, True | |
| bs = [b for _, b in bottoms] | |
| spread = max(bs) - min(bs) | |
| max_bottom = max(bs) | |
| next_strip, next_name = pick_strip(footer_strips, footers, max_bottom) | |
| gap_range: tuple[float, float] | None = None | |
| gaps: list[tuple[str, float]] = [] | |
| if next_strip is not None: | |
| for name, b in bottoms: | |
| gaps.append((name, next_strip["y"] - b)) | |
| gap_range = (min(g for _, g in gaps), max(g for _, g in gaps)) | |
| print() | |
| print(f"[measure] columns found: {len(columns)}" | |
| + (f" (+ {len(heros)} hero)" if heros else "")) | |
| for name, b in bottoms: | |
| print(f" {name:6s} last-card-bottom = {b:8.2f} px") | |
| print(f" spread = {spread:.2f} px (target < {args.max_spread} px)") | |
| if icg_worst is not None: | |
| print(f" intercard gap in [{icg_tightest[1]:.2f} ({icg_tightest[0]})," | |
| f" {icg_worst[1]:.2f} ({icg_worst[0]})] px" | |
| f" (target [{min_icg}, {max_icg}])") | |
| if next_strip is not None: | |
| lo, hi = gap_range # type: ignore[misc] | |
| print(f" gap -> {next_name} in [{lo:.2f}, {hi:.2f}] px" | |
| f" (target [{args.min_gap}, {args.max_gap}])") | |
| else: | |
| print(" gap -> (no footer-strip or footer below content)") | |
| ok = True | |
| if spread >= args.max_spread: | |
| _eprint(f"FAIL: spread {spread:.2f} >= max {args.max_spread}") | |
| ok = False | |
| if icg_problems: | |
| _eprint( | |
| "FAIL: intra-column whitespace void (max intercard gap " | |
| f"{max_icg:.0f} px):\n" | |
| + "\n".join(" " + p for p in icg_problems) | |
| + "\nColumns must be filled by CONTENT, not stretched " | |
| "whitespace. Do NOT use `justify-content: space-between` / " | |
| "`space-around` (or oversized margins) to fake bottom " | |
| "alignment -- it pins the last card to the bottom so spread " | |
| "reads ~0 while a void sits mid-column. Fix: grow figures or " | |
| "text, rebalance cards across columns, or use a fixed " | |
| "row-gap, then re-measure." | |
| ) | |
| ok = False | |
| if icg_tight: | |
| _eprint( | |
| "FAIL: stacked cards too tight (min intercard gap " | |
| f"{min_icg:.0f} px):\n" | |
| + "\n".join(" " + p for p in icg_tight) | |
| + "\nA gap this small buries the card's drop shadow under " | |
| "the next card, fusing the stack into one slab. Fix: restore " | |
| "the column's design row-gap (shipped templates use 6u " | |
| "~= 22.7 px) and absorb the height elsewhere (trim content " | |
| "or shrink a figure); for a deliberately shadowless theme, " | |
| "lower --min-intercard-gap." | |
| ) | |
| ok = False | |
| if next_strip is not None: | |
| lo, hi = gap_range # type: ignore[misc] | |
| if lo < args.min_gap: | |
| _eprint(f"FAIL: min gap {lo:.2f} < {args.min_gap}") | |
| ok = False | |
| if hi > args.max_gap: | |
| _eprint(f"FAIL: max gap {hi:.2f} > {args.max_gap}") | |
| ok = False | |
| elif not args.allow_no_footer_gap: | |
| _eprint( | |
| "FAIL: no footer-strip or footer found below content. " | |
| "Pass --allow-no-footer-gap to skip this gate." | |
| ) | |
| ok = False | |
| if ok: | |
| print("[measure] PASS") | |
| return 0, True | |
| # Failure path: surface per-column adjustment hints so the next | |
| # iteration is a directed edit, not a guess. The math is mechanical | |
| # (shared passing band inside the gap window; signed delta per | |
| # column), but readers reliably mis-derive it under time pressure -- | |
| # a fixed gate failure was costing roughly an extra rebuild per | |
| # loop. Only print when the geometry is sane enough to give a | |
| # meaningful target: we need a footer-strip/footer (the anchor) and | |
| # at least one column bottom. Skip when the only failure is | |
| # `spread`-without-strip; the raw column dump above is already | |
| # actionable in that case. | |
| adjustments: list[tuple[str, float, str]] = [] | |
| if next_strip is not None and bottoms: | |
| (band_lo, band_hi), adjustments = compute_adjustment_hints( | |
| bottoms, | |
| next_strip["y"], | |
| min_gap=args.min_gap, | |
| max_gap=args.max_gap, | |
| max_spread=args.max_spread, | |
| ) | |
| print() | |
| print("[measure] suggested adjustments:") | |
| print( | |
| f" shared passing band: {format_band(band_lo, band_hi)} px " | |
| "(EVERY column bottom must land in" | |
| ) | |
| print( | |
| " this one band; then gap and spread both pass. Anchor: " | |
| f"{next_name} top {next_strip['y']:.0f} px)" | |
| ) | |
| for name, b, hint in adjustments: | |
| print(f" {name:6s} {b:8.2f} px -> {hint}") | |
| # The px magnitudes below are heuristics at typical print scale | |
| # (~3000 px canvas) and don't scale with the canvas -- they are | |
| # approximate by design; the per-column deltas above are exact. | |
| print( | |
| " Tip: a body paragraph adds/removes ~25 px per wrapped line," | |
| " a callout ~60-90 px," | |
| ) | |
| print( | |
| " a small figure ~80-150 px. Prefer trimming the tallest" | |
| " column first." | |
| ) | |
| # Edit-targets block: only for the fill/alignment failures where | |
| # "which card do I edit" is the next question (spread / footer gap / | |
| # intercard). Canvas-scale, position, and clip failures get their | |
| # own targeted messages above -- card targets would misdirect the | |
| # fix toward content. Printed even without a footer anchor (a | |
| # spread-only failure still needs a card to grow/trim). | |
| fill_failure = ( | |
| spread >= args.max_spread | |
| or bool(icg_problems) | |
| or bool(icg_tight) | |
| or ( | |
| next_strip is not None and gap_range is not None | |
| and (gap_range[0] < args.min_gap or gap_range[1] > args.max_gap) | |
| ) | |
| ) | |
| if fill_failure: | |
| try: | |
| html_text = html_path.read_text( | |
| encoding="utf-8", errors="replace" | |
| ) | |
| card_lines = source_card_lines(html_text) | |
| except OSError: | |
| card_lines = [] | |
| total_dom_cards = sum(1 for el in data if el["role"] == "card") | |
| hint_by_name = {name: hint for name, _b, hint in adjustments} | |
| target_cols = [] | |
| for ci, col in sorted(columns.items()): | |
| target_cols.append({ | |
| "name": f"col{ci}", | |
| "hint": hint_by_name.get(f"col{ci}", ""), | |
| "cards": [ | |
| { | |
| "card_idx": c.get("card_idx", -1), | |
| "x": c["x"], "y": c["y"], | |
| "bottom": c["bottom"], "h": c["h"], | |
| "anchor": c.get("anchor", ""), | |
| } | |
| for c in col.get("cards", []) | |
| ], | |
| }) | |
| print() | |
| for line in format_edit_targets( | |
| target_cols, card_lines, total_dom_cards | |
| ): | |
| print(line) | |
| _eprint("[measure] FAIL -- alignment gate not met") | |
| return 1, True | |
Xet Storage Details
- Size:
- 41.3 kB
- Xet hash:
- bd5fcbb75cbd440209e04495f332714a0f4416ed58b56dd6347dbd47a625612c
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.