From cb59627d7982b5bb13256e7f78c15ef1d94ebae5 Mon Sep 17 00:00:00 2001 From: "yalun.dai" Date: Thu, 13 Aug 2026 17:11:20 +0000 Subject: [PATCH] Fix standalone poster HTML fidelity --- .../skills/paper2poster/SKILL.md | 10 +- .../paper2poster/scripts/check_poster.py | 15 + .../paper2poster/scripts/render_poster.py | 1687 ++++++++++++++++- .../scripts/utils/font_fidelity.py | 320 ++++ .../paper2poster/scripts/utils/render.py | 23 +- 5 files changed, 1944 insertions(+), 111 deletions(-) create mode 100644 ResearchStudio-Reel/skills/paper2poster/scripts/utils/font_fidelity.py diff --git a/ResearchStudio-Reel/skills/paper2poster/SKILL.md b/ResearchStudio-Reel/skills/paper2poster/SKILL.md index 037e3dc..159d4e9 100644 --- a/ResearchStudio-Reel/skills/paper2poster/SKILL.md +++ b/ResearchStudio-Reel/skills/paper2poster/SKILL.md @@ -1,6 +1,6 @@ --- name: paper2poster -description: Render a pre-extracted paper's structured 9-section spec (`paper_spec.md`) into a single-page HTML academic poster, fit the layout to the page via an iterative measured-fill loop, and export it to print-ready PDF + PNG thumbnail. Requires the upstream `paper2assets` skill to have produced the input `/` package (`manifest.json` at the root + an `assets/` folder holding `meta/paper_spec.md`, `meta/text.txt`, `meta/figures.json`, `meta/metadata.json`, zero or more `figures/*.png`, `logos/`, `qr/`) first. Use when the user wants an HTML poster, PDF/PNG export, or PPTX from a paper they already have extracted assets for, for example "render the poster", "make the poster from this spec", "export poster to PDF", or "paper2poster". The three skills paper2assets → paper2poster → html2pptx run in sequence, each invokable on its own. +description: Render a pre-extracted paper's structured 9-section spec (`paper_spec.md`) into a single-page HTML academic poster, fit the layout to the page via an iterative measured-fill loop, and export it to print-ready PDF + PNG thumbnail. Requires the upstream `paper2assets` skill to have produced an output bundle with `manifest.json` at the root and an `assets/` folder holding the paper metadata and media. Use when the user wants an HTML poster, PDF/PNG export, or PPTX from extracted paper assets, for example "render the poster", "make the poster from this spec", "export poster to PDF", or "paper2poster". The three skills paper2assets → paper2poster → html2pptx run in sequence, each invokable on its own. allowed-tools: Bash(*), Read, Write, Edit, Grep, Glob, AskUserQuestion, WebFetch, WebSearch --- @@ -322,7 +322,7 @@ The first three are **full themes** (they retheme the header + page background); v1–v4 each render the conference **logo** when `assets/logos/_venue.png` exists (Step 6 `fetch_conf_logo.py`), else a text venue/year fallback in the same chip; v5 uses a text venue badge by design. All work for **2–6 institutions** (empty `LOGO_n` slots auto-hide). Logos are sized to **fill** their zone (single venue logo + a 2-row institution grid), and the logo chips **theme to the chosen style** via `--tb-chip-bg` / `--tb-chip-shadow`: solid → flat white chip (no shadow) on the accent band; framed → flat white chip on the white card; **simple → transparent chip (no frame), logos sit directly on the white header**. Default: pass **`--header random`** — `compose_poster.py` picks one DETERMINISTICALLY from the configured seed from **all five (v1–v5)**; v5 fills its own titlebar QR via `{{HDR_QR_*}}` (see the QR contract). Override via `POSTER_HEADER={v1|v2|v3|v4|v5}`. -**Default font policy:** the poster body font defaults to **Arial** — a cross-platform-safe family pre-installed on Mac + Windows PowerPoint, so the exported `.pptx` needs **no font embedding** and round-trips cleanly. To override, edit the chosen template's `--font-latin` CSS variable (in the `:root` block) to any of the 8 PPT-safe families: `Calibri | Aptos | Cambria | Arial | "Times New Roman" | Verdana | Georgia | "Trebuchet MS"`. The optional `POSTER_FONT` env var, when set, carries the same choice — but the default lives in the templates, not in any external script. To use **Inter** (the bundled webfont — more editorial, but not pre-installed), flip `--font-latin` back to `Inter, …` *and* run the html2pptx Inter embed step so the `.pptx` ships the font; the 4 Inter `@font-face` blocks stay defined (inert) in every template for exactly this one-line override. +**Default font policy:** the poster body defaults to **Arial**, and `--font-latin` may select any of the 8 Mac/Windows PowerPoint-safe family names: `Calibri | Aptos | Cambria | Arial | "Times New Roman" | Verdana | Georgia | "Trebuchet MS"`. These proprietary OS fonts are not reliably installed on the Linux renderer or every HTML client. Before browser geometry is measured, `check_poster.py` / `render_poster.py` therefore freeze their **browser** rendering to a self-contained, open-licensed DejaVu face of the same category (Sans or Serif), stored under `assets/fonts/` with its license but exposed under the requested CSS family name. This makes fill measurements, standalone HTML, PDF, and PNG deterministic while html2pptx continues emitting the requested native family name into `.pptx`; the deck can still have small metric differences because PowerPoint uses the real OS font. Literal Georgia/Arial/etc. in every artifact requires separately licensed webfont files. The optional `POSTER_FONT` env var, when set, carries the same choice — but the default lives in the templates. To use **Inter** (the bundled webfont — more editorial), flip `--font-latin` back to `Inter, …` *and* run the html2pptx Inter embed step so the `.pptx` ships the font; the 4 Inter `@font-face` blocks stay defined in every template for exactly this override. **Composition catalog (landscape and portrait):** @@ -461,9 +461,11 @@ python references/fit_logos.py --poster /poster.html python ~/.claude/skills/paper2poster/scripts/render_poster.py /poster.html ``` -Run this **before Step 7 (html2pptx)** so the expand is baked into `poster.html` *before* html2pptx reads it — the editable `poster.pptx` then matches the PDF/PNG instead of shipping the pre-expand layout. The script reads `@page { size: }` from the HTML, mirrors the bundled Inter webfonts into `/assets/fonts/` (so the poster.html + its `assets/fonts/` stay self-contained for sharing across platforms), opens Chromium with print emulation, waits for MathJax to settle, applies the render-time expand, **bakes that expand back into `poster.html`**, then writes `/poster.pdf` and `/poster.png` (0.35× scale by default). +Run this **before Step 7 (html2pptx)** so the expand is baked into `poster.html` *before* html2pptx reads it — the editable `poster.pptx` then matches the PDF/PNG instead of shipping the pre-expand layout. The script reads `@page { size: }` from the HTML, mirrors the bundled Inter webfonts into `/assets/fonts/`, freezes any selected OS-font stack to its licensed portable browser face, opens Chromium with print emulation, waits for MathJax to settle, applies the render-time expand, **bakes that expand back into `poster.html`**, then writes `/poster.pdf` and `/poster.png` (0.35× scale by default). The poster HTML and `assets/fonts/` therefore stay self-contained for sharing across platforms. -**Render-time "expand" (automatic, on by default).** Right before writing the PDF/PNG, `render_poster.py` runs one render-time fill pass: for every under-filled card it grows the row-gaps *between* the card's inner rows until the content reaches `POSTER_EXPAND_THRESHOLD` (default **0.98**). This makes a poster that converged at the 0.90 FULL gate read as visually full — no trailing whitespace — *without* re-grinding the fill loop to a tighter, ~2× slower gate. It is safe by construction, on two guardrails: (1) **figures are never resized** — they stay `flex:0 0 auto`, so a card's `` keeps its exact pixel dimensions and aspect ratio even when the card it lives in is filled; (2) **a card is reverted if filling it would change its column/container height** (parent-height guard) — so a flex `.grow` card absorbs the fill *inside* its column (column bottom unchanged → fills the trailing column-bottom whitespace), while a grid/content card that would push the fixed-canvas layout taller is left alone. A card also stops at its **bottom-padding ceiling** (never eats padding → column bottoms stay aligned), so smaller cards finish a bit under 0.98 — that ceiling, `1 − padBot/cardHeight`, is their real "full". The expand result is then **persisted into `poster.html`** as a single `\s*', + flags=re.IGNORECASE | re.DOTALL, + ) + return pattern.subn("\n", text) + + +def _strip_derived_render_styles(html_path: Path) -> set[str]: + """Strip stale expand/scan renderer output before a fresh measurement.""" + text = html_path.read_text(encoding="utf-8") + removed: set[str] = set() + for style_id in ("poster-expand-baked", "poster-scan-suppress"): + text, count = _strip_derived_style_block(text, style_id) + if count: + removed.add(style_id) + if removed: + html_path.write_text(text, encoding="utf-8") + return removed + + +def _append_style_at_end(text: str, block: str) -> str: + """Append a durable style after every existing author style. + + The final ```` is the stable insertion point used by the generated + posters. Falling back to EOF keeps malformed/minimal fixtures usable. + """ + import re + + closes = list(re.finditer(r"", text, flags=re.IGNORECASE)) + if not closes: + return text.rstrip() + "\n" + block + "\n" + at = closes[-1].start() + return text[:at].rstrip() + "\n" + block + "\n" + text[at:] + + +def _ensure_unscaled_layout_timer_guard(html_path: Path) -> bool: + """Run recurring geometry fitters against the unscaled poster canvas. + + Some legacy/model-authored posters keep a figure fitted with a short + ``setInterval`` callback that reads ``getBoundingClientRect()``. The + standalone poster controller deliberately scales the complete fixed canvas + with a CSS transform. A timer that reads the transformed rectangle and + writes that screen-space width back as CSS pixels applies the scale twice. + + Install a tiny early guard only when an authored script contains both + primitives. It preserves timer behavior and arguments, but temporarily + clears the poster's exact inline transform around each interval callback, + then restores the same value and priority in ``finally``. This deliberately + does not call ``__fitPosterStage``: that controller recomputes a transform + from the viewport and would overwrite an external thumbnail/backfill scale. + Wrapping every interval on an affected legacy page also covers callbacks + written as ``() => enforce()`` whose own source hides the geometry read. + The block is renderer-owned and idempotent so rerenders never accumulate + shims. + """ + import re + + original = html_path.read_text(encoding="utf-8") + pattern = re.compile( + rf'\s*]*\bid\s*=\s*["\']' + rf'{re.escape(_LAYOUT_TIMER_GUARD_ID)}["\'])[^>]*>.*?\s*', + flags=re.IGNORECASE | re.DOTALL, + ) + text = pattern.sub("\n", original) + authored_scripts = re.findall( + r"]*>(.*?)", + text, + flags=re.IGNORECASE | re.DOTALL, + ) + # The scheduler and the fitter are often authored in separate blocks + # (for example, one block exports ``fit`` and a later block installs the + # interval). Treat the page's authored scripts as one program when + # deciding whether the guard is required; requiring both primitives in a + # single ``''' + opening_head = re.search(r"]*>", text, flags=re.IGNORECASE) + if opening_head: + at = opening_head.end() + text = text[:at] + "\n" + guard + text[at:] + else: + text = guard + "\n" + text + if text == original: + return False + html_path.write_text(text, encoding="utf-8") + return True + + +def _bake_expand_into_html(html_path: Path, baked: list) -> bool: """Persist the render-time expand into the deliverable poster.html. The expand pass grows each under-filled card's inner row-gaps in the live @@ -120,26 +401,632 @@ def _bake_expand_into_html(html_path: Path, baked: list) -> None: ' - txt = html_path.read_text(encoding="utf-8") - if 'id="poster-expand-baked"' in txt: - txt = re.sub(r'', block, txt, flags=re.S) - elif "" in txt: - txt = txt.replace("", block + "\n", 1) - else: - txt += "\n" + block + original = html_path.read_text(encoding="utf-8") + txt, _ = _strip_derived_style_block(original, "poster-expand-baked") + if baked: + rules = "\n".join( + f' .section[data-section="{sid}"]{{ row-gap: {gap} !important; }}' + for sid, gap in baked + ) + block = f'' + txt = _append_style_at_end(txt, block) + if txt == original: + return False html_path.write_text(txt, encoding="utf-8") + return True + + +_EXPAND_SNAPSHOT_JS = r""" +() => { + const rectOf = el => { + if (!el) return null; + const r = el.getBoundingClientRect(); + return {x:r.x, y:r.y, w:r.width, h:r.height}; + }; + // Match the polish gate for object-fit:contain. scale-down differs when the + // intrinsic image is already smaller than its CSS box, so preserve its + // unscaled natural size in that case. + const paintedDims = (img, r) => { + const nw = img.naturalWidth || 0; + const nh = img.naturalHeight || 0; + const fit = getComputedStyle(img).objectFit || 'fill'; + if (nw <= 0 || nh <= 0 || r.width <= 0 || r.height <= 0) + return {w:r.width, h:r.height, nw:nw, nh:nh, fit:fit}; + const boxAR = r.width / r.height; + const natAR = nw / nh; + const contained = natAR > boxAR + ? {w:r.width, h:r.width / natAR} + : {w:r.height * natAR, h:r.height}; + if (fit === 'contain') + return {...contained, nw:nw, nh:nh, fit:fit}; + if (fit === 'scale-down') { + const scaled = contained.w <= nw && contained.h <= nh; + return scaled + ? {...contained, nw:nw, nh:nh, fit:fit} + : {w:nw, h:nh, nw:nw, nh:nh, fit:fit}; + } + if (fit === 'none') + return {w:Math.min(nw, r.width), h:Math.min(nh, r.height), + nw:nw, nh:nh, fit:fit}; + // fill and cover both paint the visible element box completely. + return {w:r.width, h:r.height, nw:nw, nh:nh, fit:fit}; + }; + const sectionNodes = Array.from( + document.querySelectorAll('.section[data-section]') + ); + const parentNodes = []; + const parentKey = node => { + if (!node) return 'parent:none'; + let index = parentNodes.indexOf(node); + if (index < 0) { parentNodes.push(node); index = parentNodes.length - 1; } + return 'parent:' + index; + }; + const sections = sectionNodes.map((sec, index) => ({ + key:'section:' + index, + sid:sec.getAttribute('data-section') || '', + parentKey:parentKey(sec.parentElement), + rect:rectOf(sec), + parentRect:rectOf(sec.parentElement), + rowGap:parseFloat(getComputedStyle(sec).rowGap) || 0, + })); + const media = []; + sectionNodes.forEach((sec, sectionIndex) => { + const section = sections[sectionIndex]; + const eligibleImages = new Set(); + Array.from(sec.querySelectorAll('img')).forEach((img, imageIndex) => { + if (img.closest('.section[data-section]') !== sec) return; + const r = img.getBoundingClientRect(); + if (r.width < 50 || r.height < 1) return; + eligibleImages.add(img); + const p = paintedDims(img, r); + media.push({ + key:section.key + '|img:' + imageIndex, + sectionKey:section.key, sid:section.sid, + parentKey:section.parentKey, kind:'img', + src:img.getAttribute('src') || '', + currentSrc:img.currentSrc || '', + boxW:r.width, boxH:r.height, + paintedW:p.w, paintedH:p.h, + nw:p.nw, nh:p.nh, fit:p.fit, + }); + }); + // A fixture or legacy poster may use a painted .figure block instead of + // an . Keep these fallbacks even when another real image exists + // elsewhere in the same card. + Array.from(sec.querySelectorAll('figure, .figure')) + .forEach((node, figureIndex) => { + if (node.closest('.section[data-section]') !== sec) return; + if (Array.from(node.querySelectorAll('img')) + .some(img => eligibleImages.has(img))) return; + const r = node.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) return; + media.push({ + key:section.key + '|figure:' + figureIndex, + sectionKey:section.key, sid:section.sid, + parentKey:section.parentKey, kind:'figure', + src:'', currentSrc:'', boxW:r.width, boxH:r.height, + paintedW:r.width, paintedH:r.height, + nw:0, nh:0, fit:'fallback', + }); + }); + }); + return {sections:sections, media:media}; +} +""" + +def _capture_expand_snapshot(page) -> dict: + """Capture the natural section/parent/media geometry in one JS source.""" + result = page.evaluate(_EXPAND_SNAPSHOT_JS) + if not isinstance(result, dict): + raise RuntimeError("expand snapshot returned malformed data") + return result + + +def _wait_for_images_decoded(page, *, timeout_ms: int, label: str) -> bool: + """Wait until every document image has loaded and decoded. + + A same-context reload can reuse Chromium's decoded-image cache while a + standalone reopen cannot. Durable geometry must therefore be measured + only after a bounded decode wait in the fresh context used for capture. + """ + try: + status = page.evaluate( + """timeoutMs => Promise.race([ + Promise.all(Array.from(document.images).map(async img => { + if (!img.complete) { + await new Promise(resolve => { + img.addEventListener('load', resolve, {once:true}); + img.addEventListener('error', resolve, {once:true}); + }); + } + if (typeof img.decode === 'function') { + try { await img.decode(); } catch (_) {} + } + return img.complete && img.naturalWidth > 0 + && img.naturalHeight > 0; + })).then(results => results.every(Boolean) ? 'ok' : 'failed'), + new Promise(resolve => setTimeout( + () => resolve('timeout'), timeoutMs)), + ])""", + timeout_ms, + ) + except Exception as exc: + _eprint( + f"[render_preview] WARN: {label} image decode check failed: " + f"{ascii_safe(exc)}." + ) + return False + if status != "ok": + _eprint( + f"[render_preview] WARN: {label} images did not decode cleanly " + f"within {timeout_ms} ms ({ascii_safe(status)})." + ) + return False + return True + + +def _validate_durable_expand( + page, + records: list[dict], + baseline: dict, +) -> dict: + """Validate provisional rules against the whole natural poster snapshot. + + A gap added in one card can make an on-load fitter shrink a figure in a + sibling card. Looking only inside the candidate card would miss that + collateral change, so validation covers every section, its immediate + parent, and every visible card image/figure fallback. Failures remove all + rules in the affected original parent scope; an un-attributable failure + removes every remaining rule (fail closed). + """ + after = _capture_expand_snapshot(page) + result = page.evaluate( + """({records, baseline, after, gapTol, geomTol, + mediaRelTol, figMin, figMax}) => { + const beforeSections = Array.isArray(baseline && baseline.sections) + ? baseline.sections : []; + const beforeMedia = Array.isArray(baseline && baseline.media) + ? baseline.media : []; + const close = (a, b, tol) => Number.isFinite(a) + && Number.isFinite(b) && Math.abs(a - b) <= tol; + const rectClose = (a, b) => !!a && !!b + && ['x', 'y', 'w', 'h'].every(k => close(a[k], b[k], geomTol)); + // One CSS pixel is the maximum media drift. The 0.5% allowance is + // only a tighter tolerance for small media, never a looser large-box + // tolerance. + const mediaTol = (a, b) => Math.min( + geomTol, + mediaRelTol * Math.max(Math.abs(a), Math.abs(b)), + ); + const scopedReasons = new Map(); + const globalReasons = []; + const addFailure = (scope, reason) => { + const key = String(scope || ''); + if (!key) { globalReasons.push(reason); return; } + if (!scopedReasons.has(key)) scopedReasons.set(key, []); + const reasons = scopedReasons.get(key); + if (!reasons.includes(reason)) reasons.push(reason); + }; + const recordsByScope = new Map(); + records.forEach(record => { + const scope = String(record.scope || ''); + if (!recordsByScope.has(scope)) recordsByScope.set(scope, []); + recordsByScope.get(scope).push(record); + }); + + const style = document.getElementById('poster-expand-baked'); + if (!!style !== (records.length > 0)) + globalReasons.push('persisted expand style presence does not match rules'); + if (document.documentElement.dataset.posterExpandEphemeral) + globalReasons.push('page still carries a live-only expand marker'); + + const beforeSectionByKey = new Map( + beforeSections.map(item => [item.key, item]) + ); + const sectionByKey = new Map(after.sections.map(item => [item.key, item])); + const currentNodes = Array.from( + document.querySelectorAll('.section[data-section]') + ); + for (const record of records) { + const sid = String(record.sid || ''); + const matches = currentNodes.filter( + sec => sec.getAttribute('data-section') === sid + ); + if (matches.length !== 1) { + addFailure(record.scope, + 'expected one durable section for ' + sid + ', found ' + matches.length); + continue; + } + const targetGap = parseFloat(record.gap); + const durableGap = parseFloat(getComputedStyle(matches[0]).rowGap); + if (!close(targetGap, durableGap, gapTol)) + addFailure(record.scope, 'row-gap lost after reload: ' + sid); + } + + if (beforeSections.length !== after.sections.length) + globalReasons.push('section set changed'); + const sectionCount = Math.min(beforeSections.length, after.sections.length); + for (let i = 0; i < sectionCount; i += 1) { + const before = beforeSections[i]; + const current = after.sections[i]; + if (before.key !== current.key || before.sid !== current.sid + || before.parentKey !== current.parentKey) { + globalReasons.push('section identity/order changed at index ' + i); + continue; + } + if (!rectClose(before.rect, current.rect)) + addFailure(before.parentKey, 'section geometry changed: ' + before.sid); + if (!rectClose(before.parentRect, current.parentRect)) + addFailure(before.parentKey, 'parent geometry changed: ' + before.sid); + const target = records.find(record => record.sid === before.sid); + const expectedGap = target ? parseFloat(target.gap) : before.rowGap; + if (!close(expectedGap, current.rowGap, gapTol)) + addFailure(before.parentKey, 'unexpected row-gap changed: ' + before.sid); + } + + const beforeMediaByKey = new Map(beforeMedia.map(item => [item.key, item])); + const afterMediaByKey = new Map(after.media.map(item => [item.key, item])); + for (const before of beforeMedia) { + const current = afterMediaByKey.get(before.key); + if (!current) { + addFailure(before.parentKey, 'media removed: ' + before.key); + continue; + } + if (before.kind !== current.kind || before.sectionKey !== current.sectionKey + || before.sid !== current.sid || before.src !== current.src + || before.currentSrc !== current.currentSrc || before.fit !== current.fit) { + addFailure(before.parentKey, 'media identity/source changed: ' + before.key); + continue; + } + const dims = [ + ['box width', before.boxW, current.boxW], + ['box height', before.boxH, current.boxH], + ['painted width', before.paintedW, current.paintedW], + ['painted height', before.paintedH, current.paintedH], + ]; + for (const [label, oldValue, newValue] of dims) { + if (!close(oldValue, newValue, mediaTol(oldValue, newValue))) + addFailure(before.parentKey, + 'media ' + label + ' changed: ' + before.key); + } + if (before.nw !== current.nw || before.nh !== current.nh) + addFailure(before.parentKey, + 'media intrinsic dimensions changed: ' + before.key); + if (current.kind === 'img' && (current.nw <= 0 || current.nh <= 0)) + addFailure(before.parentKey, 'media failed to load: ' + before.key); + const beforeSec = beforeSectionByKey.get(before.sectionKey); + const sec = sectionByKey.get(current.sectionKey); + const beforeWr = beforeSec && beforeSec.rect && beforeSec.rect.w > 0 + ? before.paintedW / beforeSec.rect.w : 0; + const beforeHr = beforeSec && beforeSec.rect && beforeSec.rect.h > 0 + ? before.paintedH / beforeSec.rect.h : 0; + const wr = sec && sec.rect && sec.rect.w > 0 + ? current.paintedW / sec.rect.w : 0; + const hr = sec && sec.rect && sec.rect.h > 0 + ? current.paintedH / sec.rect.h : 0; + // Legacy natural pages can already be outside the finishing band. + // Reject only a new threshold crossing here; the strict geometry + // comparisons above still reject any expand-created size drift. + const beforeFill = Math.max(beforeWr, beforeHr); + const fill = Math.max(wr, hr); + if (beforeFill + 1e-6 >= figMin && fill + 1e-6 < figMin) + addFailure(before.parentKey, + 'media fill dropped below floor: ' + before.key); + if ((beforeWr <= figMax && wr > figMax) + || (beforeHr <= figMax && hr > figMax)) + addFailure(before.parentKey, + 'media overflowed its section: ' + before.key); + } + for (const current of after.media) { + if (!beforeMediaByKey.has(current.key)) + addFailure(current.parentKey, 'media added: ' + current.key); + } + + const unattributed = [...scopedReasons.keys()].filter( + scope => !(recordsByScope.get(scope) || []).length + ); + if (unattributed.length) { + globalReasons.push(...unattributed.map(scope => + 'collateral change outside a candidate scope: ' + scope)); + } + const failures = []; + for (const record of records) { + const reasons = [ + ...(scopedReasons.get(String(record.scope || '')) || []), + ...globalReasons, + ]; + if (reasons.length) + failures.push({sid:String(record.sid || ''), reasons:reasons}); + } + const snapshotReasons = [ + ...globalReasons, + ...[...scopedReasons.values()].flat(), + ]; + return { + failures:failures, + snapshotOk:snapshotReasons.length === 0, + snapshotReasons:snapshotReasons, + }; + }""", + { + "records": records, + "baseline": baseline, + "after": after, + "gapTol": _EXPAND_GAP_TOLERANCE_PX, + "geomTol": _EXPAND_GEOMETRY_TOLERANCE_PX, + "mediaRelTol": _EXPAND_MEDIA_REL_TOLERANCE, + "figMin": _EXPAND_FIG_MIN_RATIO, + "figMax": _EXPAND_FIG_MAX_RATIO, + }, + ) + if not isinstance(result, dict): + return { + "failures": [ + {"sid": str(record.get("sid", "")), + "reasons": ["durable validator returned malformed data"]} + for record in records + ], + "snapshotOk": False, + "snapshotReasons": ["durable validator returned malformed data"], + } + return result + + +def _settle_loaded_durable_page( + page, + *, + timeout_ms: int, + playwright_timeout_error, + label: str, +) -> bool: + """Settle one loaded durable page and fail closed for expand validation.""" + stable = True + try: + page.wait_for_load_state("networkidle", timeout=timeout_ms) + except playwright_timeout_error: + _eprint( + f"[render_preview] WARN: {label} never went idle within " + f"{timeout_ms} ms; continuing to the bounded settle check." + ) + except Exception as exc: + _eprint( + f"[render_preview] WARN: {label} load-state check failed: " + f"{ascii_safe(exc)}." + ) + stable = False + if not _wait_for_images_decoded( + page, + timeout_ms=timeout_ms, + label=label, + ): + stable = False + try: + durable_settle = _render.settle_page( + page, + mathjax_timeout_ms=timeout_ms, + settle_ms=1500, + ) + except Exception as exc: + _eprint( + f"[render_preview] WARN: {label} settle failed: " + f"{ascii_safe(exc)}; provisional expand rules will fail closed." + ) + return False + if durable_settle.mathjax_status == "timeout": + stable = False + _eprint( + f"[render_preview] WARN: MathJax typeset timed out after {label} " + f"({timeout_ms} ms)." + ) + elif durable_settle.mathjax_status == "error": + stable = False + _eprint( + f"[render_preview] WARN: MathJax error after {label}: " + f"{ascii_safe(durable_settle.mathjax_error)}" + ) + if (durable_settle.mathjax_intended and + durable_settle.tex_without_mathjax): + stable = False + _eprint( + f"[render_preview] WARN: {label} intended to load MathJax but no " + " rendered -- PDF will show raw $...$ text." + ) + return stable + + +def _reload_and_settle_after_bake( + page, + *, + timeout_ms: int, + playwright_timeout_error, + label: str, +) -> bool: + """Reload renderer-owned CSS and report whether geometry fully settled. + + Rendering remains a soft path for MathJax/network failures, but provisional + expand rules are optional. A rule may be kept only when the durable page + completed the same settle cycle used for final capture; otherwise its + validator fails closed and removes it. + """ + navigated = True + try: + # ``Page.reload`` can detach a ``file:`` target with net::ERR_FAILED + # when the renderer has just atomically rewritten that same file. + # Navigating the existing page to its identical URL preserves the + # BrowserContext/cache semantics required by this warm check without + # depending on Chromium's reload fast path. + page.goto( + page.url.split("#", 1)[0], + timeout=timeout_ms, + wait_until="domcontentloaded", + ) + except playwright_timeout_error: + navigated = False + _eprint( + f"[render_preview] WARN: {label} did not reach " + "`domcontentloaded` within " + f"{timeout_ms} ms; provisional expand rules will fail closed." + ) + except Exception as exc: + _eprint( + f"[render_preview] WARN: {label} failed: {ascii_safe(exc)}; " + "provisional expand rules will fail closed." + ) + return False + return _settle_loaded_durable_page( + page, + timeout_ms=timeout_ms, + playwright_timeout_error=playwright_timeout_error, + label=label, + ) and navigated + + +def _new_print_context_page(browser, viewport: tuple[int, int]): + """Create an isolated print-emulated page without warming its context.""" + width, height = viewport + context = browser.new_context( + viewport={"width": width, "height": height}, + ) + page = context.new_page() + _render.route_mathjax_local(page) + _render.route_katex_local(page) + page.emulate_media(media="print") + page.set_viewport_size({"width": width, "height": height}) + return context, page + + +def _open_fresh_durable_page( + browser, + viewport: tuple[int, int], + html_path: Path, + *, + timeout_ms: int, + playwright_timeout_error, + label: str, +): + """Navigate once in a brand-new BrowserContext, then fully settle it.""" + context, page = _new_print_context_page(browser, viewport) + navigated = True + try: + page.goto( + html_path.as_uri(), + timeout=timeout_ms, + wait_until="domcontentloaded", + ) + except playwright_timeout_error: + navigated = False + _eprint( + f"[render_preview] WARN: {label} did not reach " + "`domcontentloaded` within " + f"{timeout_ms} ms; provisional expand rules will fail closed." + ) + except Exception as exc: + _eprint( + f"[render_preview] WARN: {label} failed: {ascii_safe(exc)}; " + "provisional expand rules will fail closed." + ) + return context, page, False + stable = _settle_loaded_durable_page( + page, + timeout_ms=timeout_ms, + playwright_timeout_error=playwright_timeout_error, + label=label, + ) + return context, page, stable and navigated -def _bake_scan_suppress_into_html(html_path: Path) -> None: + +def _capture_style_targets_and_freeze(context, page): + """Disable page scripts and return CDP nodes used for thumbnail scaling. + + The accepted durable page may still contain recurring author fitters. They + are harmless at the 1x print viewport but can observe the renderer's later + thumbnail transform and write screen-space dimensions back into the fixed + canvas. Freeze script execution before either artifact is captured; CDP + DOM commands remain available for the live-only thumbnail transform. + """ + session = context.new_cdp_session(page) + session.send("DOM.enable") + root = session.send("DOM.getDocument", {"depth": 1}).get("root") or {} + root_id = int(root.get("nodeId") or 0) + if not root_id: + raise RuntimeError("CDP did not return a document node") + + def query(*selectors: str) -> int: + for selector in selectors: + result = session.send( + "DOM.querySelector", + {"nodeId": root_id, "selector": selector}, + ) + node_id = int(result.get("nodeId") or 0) + if node_id: + return node_id + return 0 + + def style_of(node_id: int) -> str: + attributes = session.send( + "DOM.getAttributes", {"nodeId": node_id} + ).get("attributes") or [] + for index in range(0, len(attributes) - 1, 2): + if attributes[index].lower() == "style": + return str(attributes[index + 1]) + return "" + + poster_id = query('[data-measure-role="poster"]', ".poster", "body") + body_id = query("body") + html_id = query("html") + if not poster_id or not body_id or not html_id: + raise RuntimeError("CDP could not resolve poster/body/html nodes") + targets = { + "poster": (poster_id, style_of(poster_id)), + "body": (body_id, style_of(body_id)), + "html": (html_id, style_of(html_id)), + } + session.send("Emulation.setScriptExecutionDisabled", {"value": True}) + return session, targets + + +def _apply_thumbnail_transform_cdp(session, targets: dict, scale: float) -> None: + """Apply the thumbnail-only style while page JavaScript stays disabled.""" + declarations = ( + ( + "poster", + "transform-origin:top left!important;" + f"transform:scale({scale})!important;", + ), + ("body", "margin:0!important;"), + ("html", "margin:0!important;"), + ) + # A minimal document can legitimately fall back to as its poster. + # Coalesce by CDP node id so the later body-margin declaration extends the + # transform instead of rebuilding the same node from its original style and + # accidentally erasing that transform. + updates: dict[int, tuple[str, list[str]]] = {} + for key, declaration in declarations: + node_id, original = targets[key] + if node_id not in updates: + updates[node_id] = (str(original), []) + updates[node_id][1].append(declaration) + for node_id, (original, additions) in updates.items(): + prefix = original.strip() + if prefix and not prefix.endswith(";"): + prefix += ";" + session.send( + "DOM.setAttributeValue", + { + "nodeId": node_id, + "name": "style", + "value": prefix + "".join(additions), + }, + ) + + +def _bake_scan_suppress_into_html(html_path: Path) -> bool: """Persist a render-time Scan-to-Read suppression into poster.html. When the aspect-ratio guard (below) decides the Scan-to-Read section is too @@ -157,13 +1044,10 @@ def _bake_scan_suppress_into_html(html_path: Path) -> None: ' .section[data-section="scan-to-read"] { display: none !important; }\n' '') txt = html_path.read_text(encoding="utf-8") - if 'id="poster-scan-suppress"' in txt: - return # already suppressed (idempotent) - if "" in txt: - txt = txt.replace("", block + "\n", 1) - else: - txt += "\n" + block + txt, _ = _strip_derived_style_block(txt, "poster-scan-suppress") + txt = _append_style_at_end(txt, block) html_path.write_text(txt, encoding="utf-8") + return True def _autopack_header_logos(html_path: Path) -> None: @@ -194,26 +1078,28 @@ def _autopack_header_logos(html_path: Path) -> None: _eprint(f"[render_preview] fit_logos auto-pack skipped ({e})") -def main() -> int: - args = build_parser().parse_args() - - html_path = Path(args.html).resolve() - if not html_path.exists(): - _eprint(f"ERROR: HTML not found: {ascii_safe(html_path)}") - return 2 +def _render_staged( + args: argparse.Namespace, + html_path: Path, + pdf_path: Path, + png_path: Path, +) -> int: + """Render a disposable HTML working copy into staged artifacts. + ``html_path`` is deliberately not the user's deliverable. Every renderer + mutation (font freeze, logo fitting, stale-style removal, and the fresh + derived bake) lands on this adjacent working copy. The caller promotes it + only after both staged exports have completed successfully. + """ + # Expand and scan suppression are renderer-derived final state. Measure a + # fresh source layout on every invocation; otherwise an older bake feeds + # back into its own rerender and POSTER_* = 0 cannot disable it. + _strip_derived_render_styles(html_path) + _ensure_unscaled_layout_timer_guard(html_path) _sync_bundled_fonts(html_path) + freeze_system_font_webfont(html_path) _autopack_header_logos(html_path) # Step 5.9, auto-run so it's never skipped - pdf_path = ( - Path(args.pdf) if args.pdf - else html_path.with_name(html_path.stem + ".pdf") - ) - png_path = ( - Path(args.png) if args.png - else html_path.with_name(html_path.stem + ".png") - ) - resolved = _canvas.resolve_canvas( html_path, args.canvas, label="[render_preview]" ) @@ -234,7 +1120,7 @@ def main() -> int: sync_playwright, PWTimeoutError = pw with sync_playwright() as p_: - browser, _ctx, page = _render.open_print_emulated_page( + browser, ctx, page = _render.open_print_emulated_page( p_, viewport ) # Soft path: a hung CDN (blocked MathJax fetch, unreachable @@ -263,6 +1149,12 @@ def main() -> int: f"loaded (likely a slow/blocked external resource)." ) + _initial_images_ready = _wait_for_images_decoded( + page, + timeout_ms=args.mathjax_timeout_ms, + label="initial load", + ) + settle = _render.settle_page( page, mathjax_timeout_ms=args.mathjax_timeout_ms, @@ -341,12 +1233,13 @@ def main() -> int: # Expand deliverable: stretch each under-filled card's inner content to # ~POSTER_EXPAND_THRESHOLD of the card (default 0.98) by growing the - # row-gaps BETWEEN its rows -- COLUMN bottoms stay aligned and FIGURES ARE - # NEVER RESIZED. Figure cards are NOT skipped: growing the gaps between - # rows only adds whitespace between rows; templates pin figures at - # `flex:0 0 auto` (natural size), so the keeps its exact aspect ratio - # (verified: img w/h unchanged). Two guardrails: (a) the slack cap -- never - # push content past the bottom padding; (b) the PARENT-height revert -- if + # row-gaps BETWEEN its rows -- COLUMN bottoms stay aligned. Figure/image + # cards participate too, but only provisionally: after the rule is baked + # and the page's on-load figure fitter runs again, a durable validation + # removes any rule that changes figure dimensions or drops the strict + # >=90% figure-fill gate. Two immediate guardrails remain: (a) the slack + # cap -- never push content past the bottom padding; (b) the + # PARENT-height revert -- if # growing the gap changes the card's CONTAINER (column/grid) height, undo # it. (b) is deliberately on the parent, not the card: a flex:1 grow card # absorbs the fill inside its column (column height unchanged -> bottoms @@ -361,72 +1254,490 @@ def main() -> int: _expand_t = float(os.environ.get("POSTER_EXPAND_THRESHOLD", "0.98")) except Exception: _expand_t = 0.98 - if _expand_t > 0: + _expand_records = [] + _expand_baseline = {} + _expand_failed = False + _baked = [] + if _expand_t > 0 and _initial_images_ready: try: - page.evaluate( - """(T) => { - document.querySelectorAll('.section').forEach(sec => { - // Figure cards are NOT skipped: growing the row-gaps BETWEEN - // rows never resizes a figure (figure{flex:0 0 auto}). The - // guardrails are the slack cap + the parent-height revert. + # This is the one natural-layout capture. It completes before + # the proposal evaluator is allowed to mutate any row-gap. + _expand_baseline = _capture_expand_snapshot(page) + _expand_result = page.evaluate( + """({T, baseline}) => { + // Phase 1 is strictly read-only. Every proposal and the + // target gap is collected before a single row-gap changes, + // so DOM order cannot make later proposals depend on an + // earlier candidate. + const sectionNodes = Array.from( + document.querySelectorAll('.section[data-section]') + ); + const sidCounts = new Map(); + sectionNodes.forEach(sec => { + const sid = sec.getAttribute('data-section') || ''; + sidCounts.set(sid, (sidCounts.get(sid) || 0) + 1); + }); + const proposals = []; + sectionNodes.forEach((sec, sectionIndex) => { + const sid = sec.getAttribute('data-section') || ''; + // One CSS selector cannot persist two different live + // element decisions. Fail closed on malformed duplicate + // ids rather than baking an ambiguous rule. + if (!sid || sidCounts.get(sid) !== 1) return; const kids = Array.from(sec.children).filter(k => k.classList && !k.classList.contains('listen-btn') && !k.classList.contains('dbg-badge') && !k.classList.contains('dbg-bbox')); - if (kids.length < 2) return; // need >=2 rows to add a gap + if (kids.length < 2) return; + const oldInlineGap = sec.style.getPropertyValue('row-gap'); + const oldInlinePriority = sec.style.getPropertyPriority('row-gap'); + const authoredInlineImportant = [ + 'row-gap', 'gap', 'grid-row-gap' + ].some(prop => sec.style.getPropertyPriority(prop) === 'important'); + if (authoredInlineImportant) return; const sb = sec.getBoundingClientRect(); - const bot = Math.max.apply(null, kids.map(k => k.getBoundingClientRect().bottom)); + const bot = Math.max.apply(null, + kids.map(k => k.getBoundingClientRect().bottom)); const cur = (bot - sb.top) / sb.height; - if (cur >= T) return; // already at/above target + if (cur >= T) return; const cs = getComputedStyle(sec); const padBot = parseFloat(cs.paddingBottom) || 0; - // getBoundingClientRect is post-transform (screen) px but - // paddingBottom is layout px; convert padding by the live - // scale so the slack cap is in the same coordinate frame. - const s = sec.offsetHeight ? sb.height / sec.offsetHeight : 1; - const slack = (sb.bottom - padBot * s) - bot; // px before content hits padding + const scale = sec.offsetHeight ? sb.height / sec.offsetHeight : 1; + const slack = (sb.bottom - padBot * scale) - bot; if (slack <= 1) return; const add = Math.min((T - cur) * sb.height, slack); const per = add / (kids.length - 1); const curGap = parseFloat(cs.rowGap) || 0; - // Revert if the CONTAINER (column/grid) height changes: a - // grow card absorbs the fill in-column (no change -> keep); - // a card that would push its container taller is undone, so - // no column bottom ever moves and the poster never overflows. - const par = sec.parentElement; - const pH0 = par ? par.getBoundingClientRect().height : 0; - sec.style.rowGap = (curGap + per) + 'px'; - if (par && Math.abs(par.getBoundingClientRect().height - pH0) > 1) { - sec.style.rowGap = curGap + 'px'; - } + proposals.push({ + sec:sec, sid:sid, + scope:baseline.sections[sectionIndex].parentKey, + gap:(curGap + per) + 'px', + parent:sec.parentElement, + oldInlineGap:oldInlineGap, + oldInlinePriority:oldInlinePriority, + }); }); + + // Phase 2 mutates only after every natural baseline and + // target gap is frozen. The immediate parent guard stays + // local to each mutation; the durable global validator + // below catches sibling and cross-card collateral changes. + const records = []; + if (proposals.length) + document.documentElement.dataset.posterExpandEphemeral = '1'; + for (const proposal of proposals) { + const par = proposal.parent; + const liveParentH = par ? par.getBoundingClientRect().height : 0; + proposal.sec.style.setProperty( + 'row-gap', proposal.gap, 'important' + ); + if (par && Math.abs( + par.getBoundingClientRect().height - liveParentH + ) > 1) { + if (proposal.oldInlineGap) + proposal.sec.style.setProperty( + 'row-gap', proposal.oldInlineGap, + proposal.oldInlinePriority, + ); + else + proposal.sec.style.removeProperty('row-gap'); + continue; + } + records.push({ + sid:proposal.sid, + scope:proposal.scope, + gap:getComputedStyle(proposal.sec).rowGap || '0px', + }); + } + if (!records.length) + delete document.documentElement.dataset.posterExpandEphemeral; + return records; }""", - _expand_t, + {"T": _expand_t, "baseline": _expand_baseline}, ) page.wait_for_timeout(150) # Persist the expand into the deliverable html so poster.html, # its `D` overlay, the PDF/PNG, and the downstream html2pptx read # all show the same expanded layout (not the pre-expand one). - _baked = page.evaluate( - """() => { - const o = []; - document.querySelectorAll('.section[data-section]').forEach(sec => { - if (sec.style && sec.style.rowGap) - o.push([sec.getAttribute('data-section'), sec.style.rowGap]); - }); - return o; - }""" + if not isinstance(_expand_result, list): + raise RuntimeError("expand pass returned malformed data") + _expand_records = list(_expand_result) + if _expand_records and not _expand_baseline: + raise RuntimeError("expand pass omitted its natural baseline") + _baked = [ + [record["sid"], record["gap"]] + for record in _expand_records + ] + except Exception as exc: + _expand_failed = True + _expand_records = [] + _expand_baseline = {} + _baked = [] + _eprint( + "[render_preview] WARN: provisional expand failed: " + f"{ascii_safe(exc)}; reloading the natural persisted HTML." ) - if _baked: - _bake_expand_into_html(html_path, _baked) - except Exception: - pass + elif _expand_t > 0: + _eprint( + "[render_preview] WARN: render-time expand skipped because " + "the natural image baseline was not fully decoded." + ) - # Persist the scan suppression into poster.html so the editable HTML, - # the PDF/PNG, and the downstream html2pptx read all hide the section - # (the live page already does; this makes it durable on disk). + # Persist renderer-derived state in cascade order: scan first, expand + # LAST. The latter must follow autofit and every other author style so + # the computed gap captured above is still the winner after reload. + _html_mutated = False if _scan_suppressed: - _bake_scan_suppress_into_html(html_path) + _html_mutated = _bake_scan_suppress_into_html(html_path) + if _baked: + _html_mutated = _bake_expand_into_html(html_path, _baked) or _html_mutated + + # Render artifacts only from the durable HTML. Provisional rules are + # monotonically pruned by original parent scope until the reloaded page + # matches the complete natural geometry/media snapshot. A clean state + # must survive one extra identical-rule reload before capture; this + # catches load-count-dependent fitters and late cascade overrides. + _survivors = list(_expand_records) + _had_provisional_expand = bool(_survivors) + _durable_ready = True + if _html_mutated or _expand_failed: + _durable_ready = _reload_and_settle_after_bake( + page, + timeout_ms=args.mathjax_timeout_ms, + playwright_timeout_error=PWTimeoutError, + label="post-bake reload", + ) + if _expand_failed and not _durable_ready: + _eprint( + "[render_preview] ERROR: provisional expand failed and the " + "natural persisted HTML could not be reloaded reliably; " + "refusing to capture a possibly live-only DOM." + ) + browser.close() + return 2 + + if _had_provisional_expand: + _clean_validations = 0 + while True: + if not _durable_ready and _survivors: + _validation = { + "failures": [ + { + "sid": str(record.get("sid", "")), + "reasons": ["durable settle incomplete"], + } + for record in _survivors + ], + "snapshotOk": False, + "snapshotReasons": ["durable settle incomplete"], + } + else: + try: + _validation = _validate_durable_expand( + page, _survivors, _expand_baseline, + ) + except Exception as exc: + _reason = f"durable validation failed: {ascii_safe(exc)}" + _validation = { + "failures": [ + { + "sid": str(record.get("sid", "")), + "reasons": [_reason], + } + for record in _survivors + ], + "snapshotOk": False, + "snapshotReasons": [_reason], + } + + _failures = list(_validation.get("failures") or []) + _snapshot_ok = bool(_validation.get("snapshotOk")) + if not _failures and _snapshot_ok: + if _clean_validations >= 1: + break + _clean_validations += 1 + _durable_ready = _reload_and_settle_after_bake( + page, + timeout_ms=args.mathjax_timeout_ms, + playwright_timeout_error=PWTimeoutError, + label="post-expand confirmation reload", + ) + continue + + if _survivors: + _failed_sids = { + str(failure.get("sid", "")) + for failure in _failures + if str(failure.get("sid", "")) + } + _known_sids = { + str(record.get("sid", "")) for record in _survivors + } + # Unknown/unattributed validation output cannot safely pick + # one rule. Remove all remaining provisional rules. + if (not _failed_sids + or not _failed_sids.issubset(_known_sids)): + _failed_sids = set(_known_sids) + _next_survivors = [ + record for record in _survivors + if str(record.get("sid", "")) not in _failed_sids + ] + if len(_next_survivors) >= len(_survivors): + _failed_sids = set(_known_sids) + _next_survivors = [] + for failure in _failures: + sid = str(failure.get("sid", "")) + if sid not in _failed_sids: + continue + reasons = "; ".join( + str(reason) + for reason in (failure.get("reasons") or []) + ) or "durable validation failed" + _eprint( + f"[render_preview] expand rollback {sid!r}: " + f"{ascii_safe(reasons)}" + ) + if not _failures: + reasons = "; ".join( + str(reason) for reason in + (_validation.get("snapshotReasons") or []) + ) or "unattributed durable snapshot mismatch" + _eprint( + "[render_preview] expand rollback (all rules): " + f"{ascii_safe(reasons)}" + ) + _survivors = _next_survivors + _bake_expand_into_html( + html_path, + [ + [record["sid"], record["gap"]] + for record in _survivors + ], + ) + _durable_ready = _reload_and_settle_after_bake( + page, + timeout_ms=args.mathjax_timeout_ms, + playwright_timeout_error=PWTimeoutError, + label="post-expand rollback reload", + ) + _clean_validations = 0 + continue + + # All optional rules are already gone. If the natural page + # still differs from the read-only baseline, capturing it would + # expose a live-only/stale document. Abort the staged render so + # the user's previous HTML/PDF/PNG transaction stays untouched. + _reasons = "; ".join( + str(reason) for reason in + (_validation.get("snapshotReasons") or []) + ) or "natural durable snapshot mismatch" + _eprint( + "[render_preview] ERROR: natural HTML did not restore " + f"after expand rollback: {ascii_safe(_reasons)}" + ) + browser.close() + return 2 + + # Same-context reloads reuse storage, decoded images, and other browser + # caches. They are useful for cheaply pruning ordinary cascade/refit + # failures above, but they do not prove that the baked HTML survives a + # real standalone open. Confirm each remaining fixed point in a brand- + # new BrowserContext. A rejected candidate is closed; after its parent + # scope is removed from the bake, the next attempt gets another new + # context rather than a warmed retry. The accepted cold page becomes + # the capture page, so validation and artifacts share one DOM. + _needs_fresh_confirmation = bool( + _html_mutated or _expand_failed or _had_provisional_expand + ) + if _needs_fresh_confirmation: + _fresh_attempt = 0 + while True: + _fresh_attempt += 1 + _fresh_ctx, _fresh_page, _fresh_ready = ( + _open_fresh_durable_page( + browser, + viewport, + html_path, + timeout_ms=args.mathjax_timeout_ms, + playwright_timeout_error=PWTimeoutError, + label=( + "fresh-context expand confirmation " + f"#{_fresh_attempt}" + ), + ) + ) + if not _fresh_ready: + _fresh_validation = { + "failures": [ + { + "sid": str(record.get("sid", "")), + "reasons": ["fresh-context settle incomplete"], + } + for record in _survivors + ], + "snapshotOk": False, + "snapshotReasons": [ + "fresh-context settle incomplete" + ], + } + elif _expand_baseline: + try: + _fresh_validation = _validate_durable_expand( + _fresh_page, + _survivors, + _expand_baseline, + ) + except Exception as exc: + _reason = ( + "fresh-context durable validation failed: " + f"{ascii_safe(exc)}" + ) + _fresh_validation = { + "failures": [ + { + "sid": str(record.get("sid", "")), + "reasons": [_reason], + } + for record in _survivors + ], + "snapshotOk": False, + "snapshotReasons": [_reason], + } + else: + # Scan suppression and a failed/no-op provisional pass can + # mutate the staged HTML without producing an expand + # baseline. A fully settled one-navigation cold page is + # still required, but there is no optional geometry record + # to compare or prune. + _fresh_validation = { + "failures": [], + "snapshotOk": True, + "snapshotReasons": [], + } + + _fresh_failures = list( + _fresh_validation.get("failures") or [] + ) + _fresh_snapshot_ok = bool( + _fresh_validation.get("snapshotOk") + ) + if (not _fresh_failures and _fresh_snapshot_ok + and _fresh_ready): + _old_ctx = ctx + ctx, page = _fresh_ctx, _fresh_page + try: + _old_ctx.close() + except Exception: + pass + break + + try: + _fresh_ctx.close() + except Exception: + pass + + if _survivors: + _failed_sids = { + str(failure.get("sid", "")) + for failure in _fresh_failures + if str(failure.get("sid", "")) + } + _known_sids = { + str(record.get("sid", "")) for record in _survivors + } + if (not _failed_sids + or not _failed_sids.issubset(_known_sids)): + _failed_sids = set(_known_sids) + _next_survivors = [ + record for record in _survivors + if str(record.get("sid", "")) not in _failed_sids + ] + if len(_next_survivors) >= len(_survivors): + _failed_sids = set(_known_sids) + _next_survivors = [] + for failure in _fresh_failures: + sid = str(failure.get("sid", "")) + if sid not in _failed_sids: + continue + reasons = "; ".join( + str(reason) + for reason in (failure.get("reasons") or []) + ) or "fresh-context durable validation failed" + _eprint( + f"[render_preview] expand rollback {sid!r}: " + f"{ascii_safe(reasons)}" + ) + if not _fresh_failures: + reasons = "; ".join( + str(reason) for reason in + (_fresh_validation.get("snapshotReasons") or []) + ) or "unattributed fresh-context snapshot mismatch" + _eprint( + "[render_preview] expand rollback (all rules): " + f"{ascii_safe(reasons)}" + ) + _survivors = _next_survivors + _bake_expand_into_html( + html_path, + [ + [record["sid"], record["gap"]] + for record in _survivors + ], + ) + continue + + _reasons = "; ".join( + str(reason) for reason in + (_fresh_validation.get("snapshotReasons") or []) + ) or "natural fresh-context snapshot mismatch" + _eprint( + "[render_preview] ERROR: natural HTML did not survive " + "a fresh-context confirmation after expand rollback: " + f"{ascii_safe(_reasons)}" + ) + browser.close() + return 2 + + # If the provisional script itself raised after partially touching the + # live DOM, the reload above is the rollback. Verify a live-only marker + # did not survive and that capture is still on the staged persisted URL. + try: + _final_page_state = page.evaluate( + """() => ({ + ephemeral:document.documentElement.dataset.posterExpandEphemeral || '', + hasExpand:!!document.getElementById('poster-expand-baked'), + })""" + ) + except Exception as exc: + _eprint( + "[render_preview] ERROR: could not verify final persisted " + f"HTML before capture: {ascii_safe(exc)}" + ) + browser.close() + return 2 + _expected_expand = bool(_survivors) + if (page.url.split("#", 1)[0] != html_path.as_uri() + or bool(_final_page_state.get("ephemeral")) + or bool(_final_page_state.get("hasExpand")) != _expected_expand): + _eprint( + "[render_preview] ERROR: final browser page is not the final " + "persisted expand state; refusing to capture stale HTML." + ) + browser.close() + return 2 + + try: + _capture_cdp, _capture_style_targets = ( + _capture_style_targets_and_freeze(ctx, page) + ) + except Exception as exc: + _eprint( + "[render_preview] ERROR: could not freeze the accepted " + "durable page before capture: " + f"{ascii_safe(exc)}" + ) + browser.close() + return 2 # ---- PDF: exact poster size, print-emulated ---- pdf_scale = _pdf_content_scale(canvas, viewport) @@ -453,17 +1764,10 @@ def main() -> int: # the print viewport, apply the scale transform, and `clip` the # screenshot to the scaled region. s = args.thumb_scale - page.evaluate( - f"""() => {{ - const el = document.querySelector( - '[data-measure-role="poster"]') - || document.querySelector('.poster') - || document.body; - el.style.transformOrigin = 'top left'; - el.style.transform = 'scale({s})'; - document.body.style.margin = '0'; - document.documentElement.style.margin = '0'; - }}""" + _apply_thumbnail_transform_cdp( + _capture_cdp, + _capture_style_targets, + s, ) thumb_w = int(round(w_in * 96 * s)) thumb_h = int(round(h_in * 96 * s)) @@ -476,6 +1780,187 @@ def main() -> int: browser.close() + return 0 + + +def _temporary_path( + parent: Path, + *, + prefix: str, + suffix: str, + mode: int | None = None, +) -> Path: + """Reserve a unique adjacent path suitable for Chromium or an HTML copy. + + ``mkstemp`` deliberately creates private ``0600`` files. That is right for + rollback backups, but HTML/PDF/PNG deliverables are public bundle assets. + Their intended mode must be applied to the *staged* inode before promotion + so permissions are committed atomically with the file contents. + """ + fd, raw = tempfile.mkstemp(dir=parent, prefix=prefix, suffix=suffix) + os.close(fd) + path = Path(raw) + try: + if mode is not None: + path.chmod(mode) + except BaseException: + path.unlink(missing_ok=True) + raise + return path + + +def _promote_render_transaction(pairs: list[tuple[Path, Path]]) -> None: + """Promote all staged files, rolling every target back on any failure. + + ``os.replace`` is atomic for each same-directory file. A small backup + journal extends that property across the HTML/PDF/PNG set: if any replace + fails, already-promoted targets are removed and all prior files are put + back byte-for-byte before the exception escapes. + """ + targets = [target for _staged, target in pairs] + if len(set(targets)) != len(targets): + raise ValueError("HTML, PDF, and PNG output paths must be distinct") + + for staged, target in pairs: + if not staged.is_file() or staged.stat().st_size <= 0: + raise RuntimeError(f"staged render output is missing or empty: {staged}") + if target.exists() and not target.is_file(): + raise IsADirectoryError(f"render output target is not a file: {target}") + + backups: dict[Path, Path] = {} + promoted: list[Path] = [] + try: + for staged, target in pairs: + if target.exists(): + backup = _temporary_path( + target.parent, + prefix=f".{target.name}.", + suffix=".render-backup", + ) + backup.unlink() + os.replace(target, backup) + backups[target] = backup + os.replace(staged, target) + promoted.append(target) + except BaseException: + rollback_errors: list[str] = [] + for target in reversed(promoted): + try: + if target.exists(): + target.unlink() + except OSError as exc: + rollback_errors.append(f"remove {target}: {exc}") + for target, backup in backups.items(): + try: + if backup.exists(): + os.replace(backup, target) + except OSError as exc: + rollback_errors.append(f"restore {target}: {exc}") + if rollback_errors: + _eprint( + "[render_preview] ERROR: render promotion rollback was " + "incomplete: " + "; ".join(rollback_errors) + ) + raise + else: + for backup in backups.values(): + try: + if backup.exists(): + backup.unlink() + except OSError as exc: + # The committed deliverables are complete; a stale private + # backup is cleanup debt, not a failed render transaction. + _eprint( + f"[render_preview] WARN: could not remove render backup " + f"{ascii_safe(backup)}: {ascii_safe(exc)}" + ) + + +def main() -> int: + args = build_parser().parse_args() + + deliverable_html = Path(args.html).resolve() + if not deliverable_html.is_file(): + _eprint(f"ERROR: HTML not found: {ascii_safe(deliverable_html)}") + return 2 + + pdf_path = ( + Path(args.pdf).resolve() if args.pdf + else deliverable_html.with_name(deliverable_html.stem + ".pdf") + ) + png_path = ( + Path(args.png).resolve() if args.png + else deliverable_html.with_name(deliverable_html.stem + ".png") + ) + targets = (deliverable_html, pdf_path, png_path) + if len(set(targets)) != len(targets): + _eprint("ERROR: HTML, PDF, and PNG output paths must be distinct") + return 2 + if not pdf_path.parent.is_dir() or not png_path.parent.is_dir(): + _eprint("ERROR: PDF and PNG output directories must already exist") + return 2 + + staged_html = _temporary_path( + deliverable_html.parent, + prefix=f".{deliverable_html.name}.", + suffix=".render.html", + ) + staged_pdf = _temporary_path( + pdf_path.parent, + prefix=f".{pdf_path.name}.", + suffix=".render.pdf", + mode=_PUBLIC_ARTIFACT_MODE, + ) + staged_png = _temporary_path( + png_path.parent, + prefix=f".{png_path.name}.", + suffix=".render.png", + mode=_PUBLIC_ARTIFACT_MODE, + ) + staged_paths = (staged_html, staged_pdf, staged_png) + font_journal: _FontAssetJournal | None = None + try: + shutil.copy2(deliverable_html, staged_html) + # copy2 preserves the source mode (including legacy 0700/0755 poster + # HTML). Normalize the staged inode before rendering so the final + # three-file promotion commits content and public-readability together. + staged_html.chmod(_PUBLIC_ARTIFACT_MODE) + font_journal = _FontAssetJournal(deliverable_html) + result = _render_staged(args, staged_html, staged_pdf, staged_png) + if result != 0: + return result + + _promote_render_transaction( + [ + (staged_pdf, pdf_path), + (staged_png, png_path), + # HTML is the commit marker: never expose the freshly baked + # source until both binary artifacts are ready to accompany it. + (staged_html, deliverable_html), + ] + ) + font_journal.commit() + finally: + if font_journal is not None and not font_journal.closed: + active_error = sys.exc_info()[0] + try: + font_journal.rollback() + except Exception as exc: + _eprint( + "[render_preview] ERROR: " + f"{ascii_safe(exc)}" + ) + # Do not hide an exception already in flight, but a failed + # rollback after a normal non-zero return is itself fatal. + if active_error is None: + raise + for staged in staged_paths: + try: + if staged.exists(): + staged.unlink() + except OSError: + pass + print( f"[render_preview] PDF -> {ascii_safe(pdf_path)} " f"({pdf_path.stat().st_size / 1024:.1f} KB)" diff --git a/ResearchStudio-Reel/skills/paper2poster/scripts/utils/font_fidelity.py b/ResearchStudio-Reel/skills/paper2poster/scripts/utils/font_fidelity.py new file mode 100644 index 0000000..259db3c --- /dev/null +++ b/ResearchStudio-Reel/skills/paper2poster/scripts/utils/font_fidelity.py @@ -0,0 +1,320 @@ +"""Portable browser-font preparation for generated poster bundles. + +The poster composer intentionally exposes familiar Mac/Windows PowerPoint +family names. Those proprietary fonts are not guaranteed to be installed on +the Linux renderer or on an HTML viewer's machine, so the same CSS can resolve +to different glyph metrics and wrap differently. This module freezes browser +rendering to a licensed DejaVu face while retaining the requested CSS family +name for the native PPTX handoff. +""" +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path + +from .cli_common import eprint + + +_FIDELITY_VERSION = "4" +_LICENSE_NAME = "RS-DejaVu-LICENSE.txt" + +_PORTABLE_FAMILIES = { + "calibri": ("Calibri", "DejaVu Sans"), + "aptos": ("Aptos", "DejaVu Sans"), + "arial": ("Arial", "DejaVu Sans"), + "verdana": ("Verdana", "DejaVu Sans"), + "trebuchet ms": ("Trebuchet MS", "DejaVu Sans"), + "cambria": ("Cambria", "DejaVu Serif"), + "times new roman": ("Times New Roman", "DejaVu Serif"), + "georgia": ("Georgia", "DejaVu Serif"), +} + +_FIDELITY_PATTERN = re.compile( + r'' + r'(?:\s*)?', + flags=re.IGNORECASE | re.DOTALL, +) + + +def managed_font_asset_names() -> frozenset[str]: + """Return every filename this module may create under ``assets/fonts``. + + ``render_poster`` uses this closed set for its rollback journal. Keep the + names source-derived so adding another portable family automatically puts + its files inside the same render transaction. + """ + names = {_LICENSE_NAME} + for _requested_family, source_family in _PORTABLE_FAMILIES.values(): + source_slug = source_family.replace(" ", "") + names.add(f"RS-{source_slug}-Regular.ttf") + names.add(f"RS-{source_slug}-Bold.ttf") + return frozenset(names) + + +def _copy_public_asset_atomic(source: Path, target: Path) -> None: + """Install one public font asset without exposing a partial overwrite.""" + target.parent.mkdir(parents=True, exist_ok=True) + descriptor, raw = tempfile.mkstemp( + dir=target.parent, + prefix=f".{target.name}.", + suffix=".font-next", + ) + temporary = Path(raw) + try: + os.close(descriptor) + shutil.copyfile(source, temporary) + temporary.chmod(0o644) + with temporary.open("rb") as stream: + os.fsync(stream.fileno()) + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + + +def _resolve_dejavu(family_name: str, style: str) -> Path | None: + """Resolve only the explicitly requested DejaVu family via fontconfig.""" + try: + match = subprocess.run( + [ + "fc-match", "-f", "%{family}\t%{style}\t%{file}\n", + f"{family_name}:style={style}", + ], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + except (FileNotFoundError, subprocess.TimeoutExpired): + return None + if match.returncode != 0 or not match.stdout.strip(): + return None + fields = match.stdout.strip().split("\t", 2) + if len(fields) != 3: + return None + family, _actual_style, filename = fields + # fc-match always returns *some* fallback. Redistribute only the requested + # DejaVu family covered by the license copied below. + if family.split(",", 1)[0].strip().casefold() != family_name.casefold(): + return None + path = Path(filename) + return path if path.is_file() else None + + +def freeze_system_font_webfont(html_path: Path) -> bool: + """Freeze selectable OS-font stacks to redistributable browser faces. + + Serif selections map to DejaVu Serif and sans-serif selections map to + DejaVu Sans. The exact open-licensed faces and their license notice are + copied into the deliverable, then exposed through ``@font-face`` rules + carrying the *requested* family name. HTML/PDF/PNG therefore use one + custom face on every client, while html2pptx continues to emit the user's + requested native family into PowerPoint. + + If the HTML already supplies an independent custom ``@font-face`` for the + selected family, it is treated as intentionally licensed and left alone. + Any obsolete fidelity block is removed when freezing no longer applies. + Returns ``True`` whenever the HTML file was changed. + """ + html_path = Path(html_path) + text = html_path.read_text(encoding="utf-8", errors="ignore") + + # Exclude our own prior block while looking for an independently supplied + # (for example, licensed Georgia) author face. + text_without_fidelity = _FIDELITY_PATTERN.sub("", text) + declaration = re.search( + r"--font-latin\s*:\s*([^;]+);", text, flags=re.IGNORECASE, + ) + if not declaration: + if text_without_fidelity == text: + return False + html_path.write_text(text_without_fidelity, encoding="utf-8") + eprint( + "[paper2poster] removed stale portable-font fidelity block: " + "the poster no longer declares --font-latin." + ) + return True + first_family = declaration.group(1).split(",", 1)[0].strip().strip("\"'") + selected = _PORTABLE_FAMILIES.get(first_family.casefold()) + if selected is None: + if text_without_fidelity == text: + return False + html_path.write_text(text_without_fidelity, encoding="utf-8") + eprint( + "[paper2poster] removed stale portable-font fidelity block: " + f"{first_family} uses its own browser font configuration." + ) + return True + requested_family, source_family = selected + # Keep the source family in the asset URL. A generic PosterFont.ttf URL + # can remain cached as Serif after a poster switches Georgia -> Arial (or + # vice versa), even though the file on the server has been overwritten. + source_slug = source_family.replace(" ", "") + regular_name = f"RS-{source_slug}-Regular.ttf" + bold_name = f"RS-{source_slug}-Bold.ttf" + + for face in re.findall( + r"@font-face\s*\{.*?\}", + text_without_fidelity, + flags=re.IGNORECASE | re.DOTALL, + ): + family = re.search( + r"font-family\s*:\s*([^;}]+)", face, flags=re.IGNORECASE, + ) + if ( + family + and family.group(1).strip().strip("\"'").casefold() + == requested_family.casefold() + ): + if text_without_fidelity == text: + return False + html_path.write_text(text_without_fidelity, encoding="utf-8") + eprint( + "[paper2poster] removed stale portable-font fidelity block: " + f"the poster supplies its own {requested_family} @font-face." + ) + return True + + out_fonts = html_path.parent / "assets" / "fonts" + prior = _FIDELITY_PATTERN.search(text) + if prior: + requested_attr = re.search( + r'data-requested-family=["\']([^"\']+)["\']', + prior.group(0), + flags=re.IGNORECASE, + ) + source_attr = re.search( + r'data-source-family=["\']([^"\']+)["\']', + prior.group(0), + flags=re.IGNORECASE, + ) + version_attr = re.search( + r'data-fidelity-version=["\']([^"\']+)["\']', + prior.group(0), + flags=re.IGNORECASE, + ) + refit_tag = re.search( + r']*>', + prior.group(0), + flags=re.IGNORECASE, + ) + refit_version_attr = ( + re.search( + r'data-fidelity-version=["\']([^"\']+)["\']', + refit_tag.group(0), + flags=re.IGNORECASE, + ) + if refit_tag + else None + ) + assets_complete = all( + (out_fonts / name).is_file() + and (out_fonts / name).stat().st_size + > (100_000 if name.endswith(".ttf") else 100) + for name in (regular_name, bold_name, _LICENSE_NAME) + ) + if ( + requested_attr + and source_attr + and version_attr + and refit_version_attr + and assets_complete + and requested_attr.group(1).casefold() + == requested_family.casefold() + and source_attr.group(1).casefold() == source_family.casefold() + and version_attr.group(1) == _FIDELITY_VERSION + and refit_version_attr.group(1) == _FIDELITY_VERSION + ): + return False + + regular = _resolve_dejavu(source_family, "Book") + bold = _resolve_dejavu(source_family, "Bold") + license_candidates = ( + Path("/usr/share/doc/fonts-dejavu-core/copyright"), + Path("/usr/share/licenses/ttf-dejavu/LICENSE"), + Path("/usr/share/licenses/dejavu-fonts/LICENSE"), + ) + license_path = next((p for p in license_candidates if p.is_file()), None) + if regular is None or bold is None or license_path is None: + eprint( + f"[paper2poster] WARN: {requested_family} is not portable on " + f"this host: the licensed {source_family} fallback or its license " + "notice could not be located; continuing with the platform font " + "stack." + ) + return False + + out_fonts.mkdir(parents=True, exist_ok=True) + _copy_public_asset_atomic(regular, out_fonts / regular_name) + _copy_public_asset_atomic(bold, out_fonts / bold_name) + _copy_public_asset_atomic(license_path, out_fonts / _LICENSE_NAME) + + block = f''' +''' + if _FIDELITY_PATTERN.search(text): + text = _FIDELITY_PATTERN.sub(block, text, count=1) + elif "" in text: + text = text.replace("", block + "\n", 1) + else: + text = block + "\n" + text + html_path.write_text(text, encoding="utf-8") + eprint( + f"[paper2poster] froze {requested_family} browser rendering to " + f"bundled {source_family} (PPTX family remains {requested_family})." + ) + return True diff --git a/ResearchStudio-Reel/skills/paper2poster/scripts/utils/render.py b/ResearchStudio-Reel/skills/paper2poster/scripts/utils/render.py index 2d55cc8..27f5058 100644 --- a/ResearchStudio-Reel/skills/paper2poster/scripts/utils/render.py +++ b/ResearchStudio-Reel/skills/paper2poster/scripts/utils/render.py @@ -6,8 +6,9 @@ 1. Print-emulated Chromium context at the correct viewport. 2. MathJax detection + bounded typeset wait (so a stuck CDN can't hang the script forever). -3. ``document.fonts.ready`` + two RAFs + a fixed settle ms — so - the layout is locked before any geometry is read. +3. ``document.fonts.ready`` + one shared resize/refit notification + two RAFs + + a fixed settle ms — so template figure fitting observes the final math + and font metrics before any geometry is read. 4. A sanity check that catches the "page has ``$…$`` TeX in body text but no rendered ````" case — MathJax never ran (CDN blocked, script error, …). Measurement / polish must NOT @@ -270,7 +271,7 @@ def settle_page( mathjax_timeout_ms: int = 15000, settle_ms: int = 500, ) -> SettleResult: - """Wait for MathJax, fonts, two RAFs, and an extra fixed ms. + """Wait for MathJax/fonts, refit resize listeners, and an extra fixed ms. Returns a :class:`SettleResult` rather than raising — the caller decides whether each flag is a hard fail or a soft warning. @@ -320,7 +321,14 @@ def settle_page( mj_status = "error" mj_error = str(e) - # 3) Fonts (best-effort) + two RAFs + fixed settle ms. + # 3) Fonts (best-effort), then notify the template's existing resize -> + # relayout path. Figure fitting normally runs on ``load``, which can be + # earlier than asynchronous MathJax typesetting or webfont settlement. + # Without this final notification, slack/polish can measure one set of + # pinned figure dimensions while render_poster (or a later browser open) + # observes another. Dispatch first, then wait two RAFs: the template's + # resize listener schedules its relayout on the first frame, and the + # second frame observes that completed geometry. try: page.evaluate( "() => document.fonts && document.fonts.ready " @@ -329,8 +337,11 @@ def settle_page( except Exception: pass page.evaluate( - "() => new Promise(r => " - "requestAnimationFrame(() => requestAnimationFrame(r)))" + "() => {" + " window.dispatchEvent(new Event('resize'));" + " return new Promise(r => " + " requestAnimationFrame(() => requestAnimationFrame(r)));" + "}" ) page.wait_for_timeout(settle_ms)