From cb59627d7982b5bb13256e7f78c15ef1d94ebae5 Mon Sep 17 00:00:00 2001
From: "yalun.dai" ` 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*\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 "
+ "
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'
+'''
+ 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 ``