sabaridsnfuji's picture
download
raw
21.1 kB
"""Column-feasibility pre-check (``pack``) -- ADVISORY, run once
before entering the Step 4 measure loop.
The measure loop's worst failure mode is a column that CANNOT be
brought into band by figure resizing alone: the agent oscillates on it
for many rounds before concluding the cards must be re-packed across
columns. ``pack`` detects that up front by *actually probing* the two
endpoints in the browser: it temporarily forces every eligible card
``<img>`` to the smallest / largest width the Gate A aspect-ratio bands
tolerate, reads the REAL resulting column bottoms (so flex, floats,
figure rows, and text reflow are all accounted for by the layout engine
itself, not a paper model), then restores the original styles.
Verdict per column, against the footer-gap window
``allowed = [strip_top - max_gap, strip_top - min_gap]``:
* ``REPACK_RECOMMENDED`` -- even with every figure at its Gate A
floor the column bottom stays below the window: figure resizing
alone cannot fit it. Move a card out / trim text.
* ``FIGURE_ONLY_UNDERFILL`` -- even with every figure at its Gate A
ceiling the bottom stays above the window: figures alone cannot
fill it; the residual needs content (or the card set re-packed).
* ``OK`` -- the window is reachable within the probed endpoints.
Plus one cross-column check: after clamping each OK column's reachable
range to the window, the theoretical minimum spread
``max(lo_i) - min(hi_i)`` must stay under ``--max-spread``.
Honest limits (why this is advisory, NEVER a hard gate):
* The endpoints are Gate A's WARN thresholds, not physical minima --
"no feasible state found inside the polish warning bands", not
"cannot fit".
* Only the two endpoints are probed; the envelope between them is an
interpolation. Text reflow makes column bottoms non-monotonic in
figure width, so a state between the endpoints can (rarely) fall
outside the envelope -- another reason verdicts stay advisory.
* The probe clears each image's height/min/max constraints while
forcing the width, so it measures feasibility with those
constraints relaxed, not under the current CSS verbatim.
* ``data-fig-layout="beside-text"`` figures are left untouched (they
opted out of the AR bands) and broken/icon-sized images are
skipped.
* Hero panels are NOT modelled: their bottoms join measure's spread
gate, but pack has no resize policy for a hero stage. When a hero
is present the cross-column check is skipped and the report says so
loudly (``UNSUPPORTED_HERO``) rather than silently ignoring it.
* Enlarging figures can also hit the asset gate's per-figure /
total-area caps (``asset_check.py``) -- the recommended-width band
is not automatically the asset-feasible band.
Mechanism inspired by ResearchStudio paper2poster's ``pack`` pre-check;
the endpoint-probing implementation is posterly's own (see NOTICE.md).
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any
from . import canvas as _canvas
from . import render as _render
from .measure import group_layout, pick_strip
from .textutil import ascii_safe
#: Gate A bands, (min_width_ratio, max_width_ratio) of the card's
#: border-box width, keyed by shape. Floors mirror polish's WARN
#: thresholds (FIG/WIDE 0.65, FIG/SQUARE 0.55, FIG/TALL-SMALL 0.36);
#: ceilings mirror the documented "aim for" band tops (SKILL Gate A:
#: wide 100%, square 75%, tall 60%). Overridable via the same-named
#: CLI flags polish uses for its floors.
AR_WIDE_MIN, AR_WIDE_MAX = 0.65, 1.00
AR_SQUARE_MIN, AR_SQUARE_MAX = 0.55, 0.75
AR_TALL_MIN, AR_TALL_MAX = 0.36, 0.60
EXIT_OK = 0
EXIT_STRICT_FAIL = 1
EXIT_USAGE = 2
def _eprint(*args: Any, **kw: Any) -> None:
print(*args, file=sys.stderr, **kw)
# Phase 1 (probe): geometry snapshot + per-image metadata, no mutation.
# Phase 2 (endpoints): force widths -> snapshot -> restore, twice.
# One evaluate call per phase; image ordinals are positions in
# querySelectorAll('[data-measure-role="card"] img') document order,
# stable across the two calls because the DOM is not mutated between
# them (style attributes are restored before returning).
_PACK_JS = r"""
(cfg) => {
const collect = () =>
Array.from(document.querySelectorAll('[data-measure-role]')).map(n => {
const r = n.getBoundingClientRect();
return {
role: n.getAttribute('data-measure-role') || '',
tag: n.tagName.toLowerCase(),
cls: n.className || '',
x: r.left, y: r.top, w: r.width, h: r.height,
bottom: r.bottom, right: r.right,
};
});
const imgs = Array.from(
document.querySelectorAll('[data-measure-role="card"] img'));
if (cfg.probe) {
const meta = imgs.map((img, i) => {
const r = img.getBoundingClientRect();
const card = img.closest('[data-measure-role="card"]');
const cr = card ? card.getBoundingClientRect() : { width: 0 };
return {
i: i,
src: img.getAttribute('src') || '',
fig_layout: img.getAttribute('data-fig-layout') || '',
rendered_w: r.width, rendered_h: r.height,
card_w: cr.width,
natural_w: img.naturalWidth || 0,
natural_h: img.naturalHeight || 0,
};
});
return { base: collect(), meta: meta };
}
const saved = imgs.map(im => im.getAttribute('style'));
const apply = (widths) => {
for (const [idx, w] of widths) {
const im = imgs[idx];
if (!im) continue;
im.style.setProperty('width', w + 'px', 'important');
im.style.setProperty('height', 'auto', 'important');
im.style.setProperty('max-width', 'none', 'important');
im.style.setProperty('max-height', 'none', 'important');
im.style.setProperty('min-width', '0', 'important');
im.style.setProperty('min-height', '0', 'important');
}
};
const restore = () => imgs.forEach((im, i) => {
if (saved[i] === null) im.removeAttribute('style');
else im.setAttribute('style', saved[i]);
});
apply(cfg.lo);
const lo = collect();
restore();
apply(cfg.hi);
const hi = collect();
restore();
return { lo: lo, hi: hi };
}
"""
def band_for_ar(
ar: float,
*,
wide_min: float = AR_WIDE_MIN,
square_min: float = AR_SQUARE_MIN,
tall_min: float = AR_TALL_MIN,
) -> tuple[float, float]:
"""(min_ratio, max_ratio) of card width for a figure of aspect
ratio ``ar`` -- Gate A's shape classes."""
if ar > 1.3:
return wide_min, AR_WIDE_MAX
if ar >= 0.8:
return square_min, AR_SQUARE_MAX
return tall_min, AR_TALL_MAX
def _looks_like_svg(src: str) -> bool:
"""Same heuristic polish uses for the FIG/BROKEN SVG exemption:
a ``.svg`` path/URL (query/fragment tolerated) or an SVG data URI.
An extensionless SVG URL is not recognised (known shared gap)."""
s = src.split("#", 1)[0].split("?", 1)[0].strip().lower()
return s.endswith(".svg") or src.strip().lower().startswith(
"data:image/svg"
)
def plan_endpoints(
meta: list[dict[str, Any]],
*,
wide_min: float,
square_min: float,
tall_min: float,
) -> tuple[list[list[float]], list[list[float]], list[dict[str, Any]]]:
"""Decide per-image endpoint widths. Returns ``(lo, hi, skipped)``
where lo/hi are ``[ordinal, width_px]`` pairs for the JS and
``skipped`` records images left untouched (with a reason)."""
lo: list[list[float]] = []
hi: list[list[float]] = []
skipped: list[dict[str, Any]] = []
for m in meta:
# Classification reads the authored ``src`` only: srcset /
# <picture> selection (currentSrc) is not modelled -- posterly
# posters use plain <img src>.
has_natural = m["natural_w"] > 0 and m["natural_h"] > 0
reason = None
if m["fig_layout"] == "beside-text":
reason = "beside-text opt-out (left at authored size)"
elif not has_natural and not _looks_like_svg(m.get("src", "")):
# Before the icon check: a broken raster's default 16x16
# placeholder box would otherwise read as "inline icon".
# And only a recognised SVG may fall back to its rendered
# AR below: a broken raster with CSS-forced dimensions also
# reports zero natural size but a non-zero box, and probing
# it would size a blank rectangle.
reason = "broken image (zero natural size, not an SVG)"
elif not has_natural and (
m["rendered_w"] <= 0 or m["rendered_h"] <= 0
):
# Recognised SVG (by elimination) with nothing rendered:
# broken too -- and it must not read as "inline icon".
reason = "broken image (SVG with a zero-size rendered box)"
elif m["rendered_w"] < 50:
reason = "inline icon (<50px)"
elif m["card_w"] <= 0:
reason = "card width unreadable"
elif has_natural:
ar = m["natural_w"] / m["natural_h"]
else:
# Recognised SVG with a real box (zero natural size is
# normal for SVG): AR from the rendered box.
ar = m["rendered_w"] / m["rendered_h"]
if reason is not None:
skipped.append({"src": m["src"], "reason": reason})
continue
b_lo, b_hi = band_for_ar(
ar, wide_min=wide_min, square_min=square_min,
tall_min=tall_min,
)
lo.append([m["i"], b_lo * m["card_w"]])
hi.append([m["i"], b_hi * m["card_w"]])
return lo, hi, skipped
def pack_verdicts(
cols: list[tuple[str, float, float]],
allowed: tuple[float, float],
max_spread: float,
) -> tuple[list[dict[str, Any]], float | None]:
"""Pure verdict logic (unit-testable without Chromium).
``cols``: ``(name, reachable_lo_bottom, reachable_hi_bottom)`` per
column. ``allowed``: the footer-gap bottom window. Returns
``(per_column_results, cross_bound)`` where ``cross_bound`` is the
theoretical minimum spread across OK columns when it is
infeasible (``>= max_spread``), else ``None``.
"""
a_lo, a_hi = allowed
results: list[dict[str, Any]] = []
inter: list[tuple[float, float]] = []
for name, r_lo, r_hi in cols:
if r_lo > r_hi:
r_lo, r_hi = r_hi, r_lo
if r_lo > a_hi:
results.append({
"name": name, "verdict": "REPACK_RECOMMENDED",
"detail": (
f"over-full by ~{r_lo - a_hi:.0f}px at the Gate A "
"floor endpoint -- no feasible state found in the "
"probed width bands; move a card out or trim text"
),
})
elif r_hi < a_lo:
results.append({
"name": name, "verdict": "FIGURE_ONLY_UNDERFILL",
"detail": (
f"~{a_lo - r_hi:.0f}px short at the Gate A ceiling "
"endpoint -- the residual needs content "
"(paper-sourced text / an extra card), not figure "
"growth"
),
})
else:
clamp = (max(r_lo, a_lo), min(r_hi, a_hi))
inter.append(clamp)
results.append({
"name": name, "verdict": "OK",
"detail": (
f"window reachable (bottoms {clamp[0]:.0f}.."
f"{clamp[1]:.0f}px within the probed endpoints)"
),
})
cross: float | None = None
if len(inter) >= 2:
bound = max(lo for lo, _ in inter) - min(hi for _, hi in inter)
if bound >= max_spread:
cross = bound
return results, cross
def cmd_pack(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 EXIT_USAGE
html_path = Path(args.html).resolve()
if not html_path.exists():
_eprint(f"ERROR: HTML not found: {ascii_safe(html_path)}")
return EXIT_USAGE
resolved = _canvas.resolve_canvas(html_path, args.canvas, label="[pack]")
if resolved is None:
_eprint(
"ERROR: could not find `@page { size: <W> <H> }` in HTML "
"and no --canvas given."
)
return EXIT_USAGE
_canvas_dims, viewport = resolved
with sync_playwright() as p:
browser, _ctx, page = _render.open_print_emulated_page(p, viewport)
try:
page.goto(html_path.as_uri(), wait_until="networkidle",
timeout=args.mathjax_timeout_ms)
except PWTimeoutError:
browser.close()
_eprint("ERROR: page did not reach network-idle; cannot "
"probe a partially loaded poster.")
return EXIT_USAGE
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"ERROR: {fail}")
return EXIT_USAGE
probe = page.evaluate(_PACK_JS, {"probe": True})
lo_widths, hi_widths, skipped = plan_endpoints(
probe["meta"],
wide_min=args.wide_min_ratio,
square_min=args.square_min_ratio,
tall_min=args.tall_min_ratio,
)
endpoints = page.evaluate(
_PACK_JS, {"probe": False, "lo": lo_widths, "hi": hi_widths}
)
browser.close()
base_cols, heros, _strips, _footers = group_layout(probe["base"])
if not base_cols:
_eprint("ERROR: no [data-measure-role=\"column\"] found.")
return EXIT_USAGE
def _bottoms_by_index(snapshot: list[dict[str, Any]]) -> dict[int, float]:
cols, _h, _s, _f = group_layout(snapshot)
return {
ci: (col["last_card_bottom"]
if col["last_card_bottom"] is not None
else col["box"]["bottom"])
for ci, col in cols.items()
}
lo_b = _bottoms_by_index(endpoints["lo"])
hi_b = _bottoms_by_index(endpoints["hi"])
# Anchor the target window on the LO-state strip, not the base
# state: an over-full flex column PUSHES the strip down with it
# (min-height:auto floors the column at its content), so the
# base-state strip legitimizes exactly the overflow pack must
# detect. With every figure at its floor the strip sits at its
# least-pushed -- i.e. designed -- position (in the shipped grid
# templates it doesn't move at all).
lo_cols, _lh, lo_strips, lo_footers = group_layout(endpoints["lo"])
lo_max_bottom = max(
[b for b in lo_b.values()] or [0.0]
)
next_strip, next_name = pick_strip(lo_strips, lo_footers, lo_max_bottom)
if next_strip is None:
_eprint(
"ERROR: no footer-strip/footer below content -- pack needs "
"the footer-gap anchor to define the target window."
)
return EXIT_USAGE
_vw, vh = viewport
# The WHOLE below-columns block must fit, not just the anchor
# strip: a footer under an in-canvas strip can be the element
# pushed off the page (both landscape templates ship strip+footer).
downstream_bottom = max(
(el["bottom"] for el in lo_strips + lo_footers
if el["bottom"] >= next_strip["y"]),
default=next_strip["bottom"],
)
canvas_overflow = downstream_bottom > vh + 2.0
allowed = (
next_strip["y"] - args.max_gap,
next_strip["y"] - args.min_gap,
)
cols_in = [
(f"col{ci}", lo_b.get(ci, 0.0), hi_b.get(ci, 0.0))
for ci in sorted(base_cols)
]
if canvas_overflow:
# Even at the figure floors the layout pushes the strip/footer
# block past the canvas: the poster is over-full regardless of
# any window arithmetic -- a GLOBAL non-OK state (never
# "[pack] OK", and --strict fails). Attribute it to the columns
# whose bottoms leave no room for the gap plus everything below
# the strip (the strip itself and any footer under it).
downstream = downstream_bottom - next_strip["y"]
blamed = 0
results = []
for name, r_lo, r_hi in cols_in:
over = (
min(r_lo, r_hi) + args.min_gap + downstream > vh
)
blamed += bool(over)
results.append({
"name": name,
"verdict": "REPACK_RECOMMENDED" if over else "OK",
"detail": (
(f"bottom {min(r_lo, r_hi):.0f}px + gap + the "
f"{downstream:.0f}px strip/footer below cannot fit "
f"the {vh:.0f}px canvas even with figures at their "
"Gate A floors -- move a card out or cut text")
if over else
"column bottoms fit the canvas at figure floors"
),
})
if not blamed:
print(
"[pack] NOTE: no single column accounts for the "
"overflow -- fixed content above/below the columns "
"(header, banner, strip margins) is oversized for this "
"canvas."
)
cross = None
else:
results, cross = pack_verdicts(cols_in, allowed, args.max_spread)
if heros:
# The hero's bottom joins measure's spread gate but pack
# has no resize policy for it -- a cross-column bound
# computed WITHOUT the hero would be misleading in every
# consumer (stdout, JSON, --strict alike).
cross = None
print()
if canvas_overflow:
print(f"[pack] CANVAS OVERFLOW: the strip/footer block below "
f"the columns extends to {downstream_bottom:.0f}px -- "
f"past the {vh:.0f}px canvas even with every figure at "
"its Gate A floor. Content below the canvas is LOST in "
"print.")
print(f"[pack] target window: column bottoms in "
f"{allowed[0]:.0f}..{allowed[1]:.0f} px "
f"({next_name} top {next_strip['y']:.0f} px at figure floors, "
f"gap {args.min_gap:.0f}..{args.max_gap:.0f} px)")
print("[pack] probed endpoints = Gate A width bands "
"(floors are polish WARN thresholds, not physical minima)")
for (name, r_lo, r_hi), res in zip(cols_in, results):
print(f" {name:6s} probed envelope {min(r_lo, r_hi):8.0f}.."
f"{max(r_lo, r_hi):8.0f} px -> {res['verdict']}")
print(f" {res['detail']}")
for s in skipped:
print(f" (skipped {ascii_safe(s['src'])}: {s['reason']})")
if heros:
print(
"[pack] UNSUPPORTED_HERO: a hero panel is present. Its "
"bottom joins measure's spread gate but pack has no resize "
"policy for a hero stage -- the cross-column check is "
"SKIPPED; judge the hero/column balance in the measure loop."
)
elif cross is not None:
print(
f"[pack] CROSS-COLUMN: even inside the probed endpoints the "
f"minimum possible spread is ~{cross:.0f}px "
f">= --max-spread {args.max_spread:.0f}px -- no figure "
"sizing makes these columns meet; re-pack cards across "
"columns before entering the loop."
)
print(
"[pack] advisory only; the envelope covers the two probed "
"endpoints, not every width in between. Note: enlarging "
"figures toward the ceiling can hit asset_check's area caps "
"(if you use the asset gate) -- the width band is not "
"automatically asset-feasible."
)
bad = [r for r in results if r["verdict"] != "OK"]
infeasible = bool(bad) or cross is not None or canvas_overflow
if not infeasible:
print("[pack] OK -- every column can reach the window within "
"the probed figure bands.")
if args.json_out:
Path(args.json_out).write_text(
json.dumps({
"allowed_bottom_window": list(allowed),
"canvas_overflow": canvas_overflow,
"columns": [
{**res, "probed_envelope": [min(lo, hi), max(lo, hi)]}
for (n, lo, hi), res in zip(cols_in, results)
],
"cross_column_min_spread": cross,
"hero_present": bool(heros),
"skipped_images": skipped,
}, indent=2),
encoding="utf-8",
)
print(f"[pack] report -> {ascii_safe(args.json_out)}")
if args.strict and infeasible:
return EXIT_STRICT_FAIL
return EXIT_OK

Xet Storage Details

Size:
21.1 kB
·
Xet hash:
2cea29bccc1838cd388b5fb1f74b2a6e1db26868ac4a87f003d35a3c2a905173

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