From f5a1802bffef7a5bfaac181dfb76d5ca4f8ee1a8 Mon Sep 17 00:00:00 2001 From: "yalun.dai" Date: Sat, 15 Aug 2026 13:04:48 +0000 Subject: [PATCH] Fix poster HTML and Reel pixel fidelity --- .../html2pptx/scripts/html_to_pptx.py | 35 +- .../paper2poster/references/fit_logos.py | 205 ++- .../skills/paper2poster/requirements.txt | 5 + .../paper2poster/scripts/render_poster.py | 1577 +++++++++++++++-- .../scripts/utils/font_fidelity.py | 863 +++++++-- .../paper2reel/assets/reel-wordmark.png | Bin 0 -> 15440 bytes .../scripts/build_poster_slides_view.py | 286 ++- 7 files changed, 2676 insertions(+), 295 deletions(-) create mode 100644 ResearchStudio-Reel/skills/paper2reel/assets/reel-wordmark.png diff --git a/ResearchStudio-Reel/skills/paper2poster/html2pptx/scripts/html_to_pptx.py b/ResearchStudio-Reel/skills/paper2poster/html2pptx/scripts/html_to_pptx.py index c491d16..3d00c4f 100644 --- a/ResearchStudio-Reel/skills/paper2poster/html2pptx/scripts/html_to_pptx.py +++ b/ResearchStudio-Reel/skills/paper2poster/html2pptx/scripts/html_to_pptx.py @@ -121,7 +121,10 @@ EMBEDDED_FONT_FAMILIES: set[str] = {"inter"} -def _pick_font_family(font_family_css: str) -> str: +def _pick_font_family( + font_family_css: str, + font_fidelity: dict | None = None, +) -> str: """Pick the first font from a CSS font-family stack, applying our alias map. Walks the list left-to-right until it finds a name we recognize as installed (or maps to one), avoiding generic CSS keywords like sans-serif @@ -129,10 +132,23 @@ def _pick_font_family(font_family_css: str) -> str: if not font_family_css: return "" GENERIC = {"sans-serif", "serif", "monospace", "cursive", "fantasy"} + fidelity = font_fidelity or {} + requested = str(fidelity.get("requested") or "").strip() + browser_faces = { + str(fidelity.get(key) or "").strip().casefold() + for key in ("source", "fallback") + if str(fidelity.get(key) or "").strip() + } for raw in font_family_css.split(","): name = raw.strip().strip('"').strip("'") if not name: continue + # paper2poster's browser fidelity block deliberately computes to the + # exact Linux-rendered face (plus a metric-identical bundled fallback). + # That is a browser portability detail: editable PowerPoint should keep + # the user's requested native family such as Calibri or Arial. + if requested and name.casefold() in browser_faces: + return requested if name in FONT_ALIASES: return FONT_ALIASES[name] if name.lower() in GENERIC: @@ -1690,10 +1706,16 @@ def _route_katex(route): _pushMath(k, _tex.trim(), k.closest('.katex-display') !== null); } + const _fidelity = document.getElementById('poster-font-fidelity'); + const font_fidelity = _fidelity ? { + requested:_fidelity.getAttribute('data-requested-family') || '', + source:_fidelity.getAttribute('data-source-family') || '', + fallback:_fidelity.getAttribute('data-fallback-family') || '', + } : null; return {viewport_w: window.innerWidth, viewport_h: window.innerHeight, body_w: document.body.scrollWidth, body_h: document.body.scrollHeight, - elements, text_blocks, math_blocks}; + elements, text_blocks, math_blocks, font_fidelity}; }""") # Rasterize each equation region as a PNG fallback while the page is open. # PowerPoint renders the native OMML we emit in Pass 3, but LibreOffice/ @@ -2165,7 +2187,10 @@ def emu_y(px): return _px_to_emu(px, slide_h_emu, body_h_px) run.text = chip_text fsz_px = el.get("font_size_px", 14) or 14 run.font.size = Pt(fsz_px * 72 / 96 * slide_scale) - fam = _pick_font_family(el.get("font_family", "Inter")) + fam = _pick_font_family( + el.get("font_family", "Inter"), + dom.get("font_fidelity"), + ) if fam: run.font.name = fam fw = el.get("font_weight", "400") @@ -2741,7 +2766,9 @@ def _has_non_embedded_font_run(rs): run.text = _hyphenate(run.text) font = run.font ff = run_data.get("font_family") or "" - first_family = _pick_font_family(ff) + first_family = _pick_font_family( + ff, dom.get("font_fidelity"), + ) # OOXML's classic embedded-font spec gives each typeface only # 4 slots (Regular / Bold / Italic / BoldItalic) — there's # no slot for SemiBold (600) or ExtraBold (800). If we just diff --git a/ResearchStudio-Reel/skills/paper2poster/references/fit_logos.py b/ResearchStudio-Reel/skills/paper2poster/references/fit_logos.py index 200dc69..32131fd 100644 --- a/ResearchStudio-Reel/skills/paper2poster/references/fit_logos.py +++ b/ResearchStudio-Reel/skills/paper2poster/references/fit_logos.py @@ -26,6 +26,7 @@ from __future__ import annotations import argparse, json, sys, urllib.parse from dataclasses import dataclass +from html.parser import HTMLParser from itertools import product from pathlib import Path @@ -422,11 +423,202 @@ def _load_approved_logo_paths(base: Path) -> tuple[bool, list[Path]]: return true;}""" +@dataclass +class _SourceNode: + """One source-HTML element with exact character offsets. + + ``HTMLParser`` is used only as a locator. Replacements are spliced into the + original string, rather than serialising the parsed document, so whitespace, + attribute quoting, scripts, MathJax output, and fitted body geometry outside + the explicitly controlled nodes remain byte-for-byte unchanged. + """ + + tag: str + attrs: dict[str, str] + start: int + end: int | None + parent: _SourceNode | None + + +class _SourceSpanParser(HTMLParser): + """Locate element spans without rewriting or executing the source HTML.""" + + _VOID_TAGS = frozenset({ + "area", "base", "br", "col", "embed", "hr", "img", "input", "link", + "meta", "param", "source", "track", "wbr", + }) + + def __init__(self, source: str): + super().__init__(convert_charrefs=False) + self.source = source + self.nodes: list[_SourceNode] = [] + self._stack: list[_SourceNode] = [] + self._line_starts = [0] + for i, char in enumerate(source): + if char == "\n": + self._line_starts.append(i + 1) + + def _offset(self) -> int: + line, column = self.getpos() + return self._line_starts[line - 1] + column + + @staticmethod + def _attr_map(attrs) -> dict[str, str]: + return {name: (value or "") for name, value in attrs} + + def _open_node(self, tag: str, attrs, end: int | None) -> _SourceNode: + node = _SourceNode( + tag=tag.lower(), + attrs=self._attr_map(attrs), + start=self._offset(), + end=end, + parent=self._stack[-1] if self._stack else None, + ) + self.nodes.append(node) + return node + + def handle_starttag(self, tag, attrs): + raw = self.get_starttag_text() or "" + open_end = self._offset() + len(raw) + node = self._open_node(tag, attrs, open_end if tag.lower() in self._VOID_TAGS else None) + if node.end is None: + self._stack.append(node) + + def handle_startendtag(self, tag, attrs): + raw = self.get_starttag_text() or "" + self._open_node(tag, attrs, self._offset() + len(raw)) + + def handle_endtag(self, tag): + wanted = tag.lower() + match = next((i for i in range(len(self._stack) - 1, -1, -1) + if self._stack[i].tag == wanted), None) + if match is None: + return + start = self._offset() + close_end = self.source.find(">", start) + if close_end < 0: + return + node = self._stack[match] + node.end = close_end + 1 + del self._stack[match:] + + +def _has_class(node: _SourceNode, name: str) -> bool: + return name in node.attrs.get("class", "").split() + + +def _scan_insert_anchor(nodes: list[_SourceNode]) -> _SourceNode | None: + """Find the same source-side insertion anchor used by ``_SCAN_JS``.""" + takeaway = next(( + node for node in nodes + if node.attrs.get("data-section") == "takeaway" + and _has_class(node, "section") + ), None) + if takeaway is not None: + return takeaway + + def siblings(node): + return [candidate for candidate in nodes if candidate.parent is node.parent] + + def last_child(node): + peer_nodes = siblings(node) + return bool(peer_nodes) and peer_nodes[-1] is node + + def last_of_type(node): + same_tag = [peer for peer in siblings(node) if peer.tag == node.tag] + return bool(same_tag) and same_tag[-1] is node + + def inside(node, ancestor): + parent = node.parent + while parent is not None: + if parent is ancestor: + return True + parent = parent.parent + return False + + # Equivalent to document.querySelector('.col:last-of-type .section:last-child'). + last_cols = [node for node in nodes if _has_class(node, "col") and last_of_type(node)] + anchor = next(( + node for node in nodes + if _has_class(node, "section") and last_child(node) + and any(inside(node, col) for col in last_cols) + ), None) + if anchor is not None: + return anchor + + # Equivalent to document.querySelector('.section:last-of-type'). + return next(( + node for node in nodes + if _has_class(node, "section") and last_of_type(node) + ), None) + + +def _merge_controlled_html(source: str, titlebars: list[str], scan_html: str | None, + sync_scan: bool) -> str: + """Splice only fit_logos-owned live nodes into the unexecuted source HTML. + + Loading the poster runs MathJax and the measured-fill scripts, which mutate + figures and sections in memory. Persisting ``page.content()`` therefore + bakes those transient measurements into the poster. This merger uses the + live page only for the header/logo/QR nodes owned by this script; every other + character comes directly from the input file. + """ + parser = _SourceSpanParser(source) + parser.feed(source) + parser.close() + + source_titlebars = [node for node in parser.nodes if _has_class(node, "titlebar")] + if len(source_titlebars) != len(titlebars): + raise ValueError( + "titlebar count changed while fitting logos " + f"(source={len(source_titlebars)}, live={len(titlebars)})" + ) + + edits: list[tuple[int, int, str]] = [] + for node, replacement in zip(source_titlebars, titlebars): + if node.end is None: + raise ValueError("cannot locate closing tag for source titlebar") + edits.append((node.start, node.end, replacement)) + + if sync_scan: + source_scans = [ + node for node in parser.nodes + if node.attrs.get("data-section") == "scan-to-read" + and _has_class(node, "section") + ] + if len(source_scans) > 1: + raise ValueError(f"expected at most one Scan-to-Read section, found {len(source_scans)}") + if source_scans: + scan = source_scans[0] + if scan.end is None: + raise ValueError("cannot locate closing tag for source Scan-to-Read section") + edits.append((scan.start, scan.end, scan_html or "")) + elif scan_html is not None: + anchor = _scan_insert_anchor(parser.nodes) + if anchor is None or anchor.end is None: + raise ValueError("cannot persist new Scan-to-Read section: insertion anchor not found") + edits.append((anchor.end, anchor.end, scan_html)) + + # Controlled nodes are siblings in all supported headers/templates. Reject + # overlap instead of risking an ambiguous rewrite if a future template nests + # one inside another. + ordered = sorted(edits) + for (_, previous_end, _), (next_start, _, _) in zip(ordered, ordered[1:]): + if next_start < previous_end: + raise ValueError("controlled HTML replacement spans overlap") + + merged = source + for start, end, replacement in sorted(edits, reverse=True): + merged = merged[:start] + replacement + merged[end:] + return merged + + def bake(poster_path, max_rows=3, pad_frac=0.06): """Measure each logo zone at true canvas scale, pack, and rewrite poster.html.""" from playwright.sync_api import sync_playwright poster = Path(poster_path).resolve() base = poster.parent + source_html = poster.read_bytes().decode("utf-8") resolved_canvas = _canvas.resolve_canvas(poster, None, label="[fit_logos]") if not resolved_canvas: raise ValueError(f"cannot resolve @page canvas from {poster}") @@ -556,8 +748,17 @@ def bake(poster_path, max_rows=3, pad_frac=0.06): # so a project-only paper reads "Project" not "Paper", and drop any empty tile. if qr_labels: pg.evaluate(_STAMP_QR_LABELS_JS, qr_labels) - html = pg.content() - poster.write_text(html, encoding="utf-8") + controlled = pg.evaluate("""()=>({ + titlebars:[...document.querySelectorAll('.titlebar')].map(node=>node.outerHTML), + scan:(document.querySelector('.section[data-section="scan-to-read"]')||{}).outerHTML||null + })""") + html = _merge_controlled_html( + source_html, + controlled["titlebars"], + controlled["scan"], + sync_scan=bool(relocate_qrs or qr_labels), + ) + poster.write_bytes(html.encode("utf-8")) br.close() return baked diff --git a/ResearchStudio-Reel/skills/paper2poster/requirements.txt b/ResearchStudio-Reel/skills/paper2poster/requirements.txt index f1e1779..5eb8df4 100644 --- a/ResearchStudio-Reel/skills/paper2poster/requirements.txt +++ b/ResearchStudio-Reel/skills/paper2poster/requirements.txt @@ -1,2 +1,7 @@ # Free, no-API-key TTS backend for narration audio (generate_audio.py default) edge-tts>=7.0 + +# Build compact, hinted subsets of the renderer's exact fallback face so a +# reopened poster keeps its original glyph metrics without shipping a 16 MB +# CJK collection. +fonttools>=4.59 diff --git a/ResearchStudio-Reel/skills/paper2poster/scripts/render_poster.py b/ResearchStudio-Reel/skills/paper2poster/scripts/render_poster.py index c73e785..504b167 100755 --- a/ResearchStudio-Reel/skills/paper2poster/scripts/render_poster.py +++ b/ResearchStudio-Reel/skills/paper2poster/scripts/render_poster.py @@ -18,10 +18,13 @@ from __future__ import annotations import argparse +import json import os import shutil +import struct import sys import tempfile +import zlib from pathlib import Path # Make `utils` importable when run directly. @@ -47,6 +50,16 @@ _EXPAND_FIG_MIN_RATIO = 0.90 _EXPAND_FIG_MAX_RATIO = 1.01 _LAYOUT_TIMER_GUARD_ID = "poster-layout-timer-guard" +_LEGACY_FIGURE_FLOOR_ENV = "POSTER_LEGACY_FIGURE_FLOOR_POLICY" +_LEGACY_FIGURE_FLOOR_TOKEN = "preserve-immutable-source-v1" +_GEOMETRY_SNAPSHOT_ID = "poster-geometry-snapshot" +_GEOMETRY_FROZEN_ATTR = "data-poster-geometry-frozen" +_GEOMETRY_FIT_GUARD = """ + /* paper2poster:frozen-geometry-fit-guard */ + if (document.documentElement.getAttribute('data-poster-geometry-frozen') === '1') return;""" +_GEOMETRY_FIT_ALL_GUARD = """ + /* paper2poster:frozen-geometry-fit-all-guard */ + if (document.documentElement.getAttribute('data-poster-geometry-frozen') === '1') return;""" def _pdf_content_scale( @@ -124,7 +137,9 @@ def _copy_asset_atomic( temporary.unlink(missing_ok=True) -def _renderer_font_asset_names() -> frozenset[str]: +def _renderer_font_asset_names( + fonts_dir: Path | None = None, +) -> frozenset[str]: """Return the closed set of font files a render may add or replace.""" skill_fonts = Path(__file__).resolve().parent.parent / "assets" / "fonts" bundled = ( @@ -132,7 +147,7 @@ def _renderer_font_asset_names() -> frozenset[str]: if skill_fonts.is_dir() else set() ) - return frozenset(bundled | set(managed_font_asset_names())) + return frozenset(bundled | set(managed_font_asset_names(fonts_dir))) class _FontAssetJournal: @@ -164,7 +179,7 @@ def __init__(self, html_path: Path): self.originals: dict[str, Path | None] = {} self.closed = False try: - for name in sorted(_renderer_font_asset_names()): + for name in sorted(_renderer_font_asset_names(self.fonts_dir)): target = self.fonts_dir / name if target.is_symlink() or ( target.exists() and not target.is_file() @@ -187,6 +202,12 @@ def rollback(self) -> None: return errors: list[str] = [] try: + # Content-addressed poster subsets are named only after the source + # face and glyph repertoire have been resolved inside the render. + # Discover them again here so a failed transaction removes every + # identity asset that did not exist when this journal was opened. + for name in managed_font_asset_names(self.fonts_dir): + self.originals.setdefault(name, None) for name, saved in self.originals.items(): target = self.fonts_dir / name try: @@ -306,6 +327,542 @@ def _append_style_at_end(text: str, block: str) -> str: return text[:at].rstrip() + "\n" + block + "\n" + text[at:] +def _strip_durable_geometry_snapshot(html_path: Path) -> bool: + """Remove the previous renderer-owned geometry freeze before rerendering. + + A completed render is intentionally frozen for cold opens. A later, + explicit rerender must first recover the authored/natural document so its + fitters can recompute against current content and assets. The snapshot is + one script, one root marker, and two sentinel-guard snippets, which makes + this removal idempotent and narrowly scoped. + """ + import re + + original = html_path.read_text(encoding="utf-8") + text = original + snapshot_pattern = re.compile( + rf'\s*]*\bid\s*=\s*["\']' + rf'{re.escape(_GEOMETRY_SNAPSHOT_ID)}["\'])[^>]*>.*?\s*', + flags=re.IGNORECASE | re.DOTALL, + ) + text = snapshot_pattern.sub("\n", text) + text = text.replace(_GEOMETRY_FIT_GUARD, "") + text = text.replace(_GEOMETRY_FIT_ALL_GUARD, "") + + root_pattern = re.compile(r"]*>", flags=re.IGNORECASE) + marker_pattern = re.compile( + rf"\s+{re.escape(_GEOMETRY_FROZEN_ATTR)}" + r"(?:\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+))?", + flags=re.IGNORECASE, + ) + match = root_pattern.search(text) + if match: + cleaned = marker_pattern.sub("", match.group(0)) + text = text[:match.start()] + cleaned + text[match.end():] + + if text == original: + return False + html_path.write_text(text, encoding="utf-8") + return True + + +_GEOMETRY_SNAPSHOT_JS = r""" +() => { + const specs = [ + { + name:'figures', + selector:'.section[data-section] figure, .section[data-section] .figure', + attributes:['style', 'class'], + }, + { + name:'images', + selector:'.section[data-section] img', + attributes:['style', 'class'], + }, + { + name:'methodBodies', + selector:'.method-body', + attributes:['style', 'class'], + }, + { + name:'sections', + selector:'.section[data-section]', + attributes:['style'], + }, + ]; + return { + version:1, + viewport:{width:window.innerWidth, height:window.innerHeight}, + groups:specs.map(spec => ({ + name:spec.name, + selector:spec.selector, + attributes:spec.attributes, + nodes:Array.from(document.querySelectorAll(spec.selector)).map(node => { + const attributes = {}; + spec.attributes.forEach(name => { + attributes[name] = node.hasAttribute(name) + ? node.getAttribute(name) : null; + }); + return attributes; + }), + })), + }; +} +""" + + +def _capture_durable_geometry_snapshot(page) -> dict: + """Serialize the final live geometry mutations that affect poster media.""" + snapshot = page.evaluate(_GEOMETRY_SNAPSHOT_JS) + if not isinstance(snapshot, dict) or snapshot.get("version") != 1: + raise RuntimeError("geometry snapshot returned malformed data") + groups = snapshot.get("groups") + if not isinstance(groups, list): + raise RuntimeError("geometry snapshot omitted its node groups") + for group in groups: + if (not isinstance(group, dict) + or not isinstance(group.get("selector"), str) + or not isinstance(group.get("attributes"), list) + or not isinstance(group.get("nodes"), list)): + raise RuntimeError("geometry snapshot contains a malformed group") + return snapshot + + +def _persist_durable_geometry_snapshot( + html_path: Path, + snapshot: dict, + *, + timeout_ms: int = 15_000, +) -> dict[str, int]: + """Bake the warm DOM's figure geometry and freeze its fitters on reload. + + The saved attributes are installed by a one-shot script at the end of + ``body``. It temporarily disarms the static frozen marker so the authored + load fitter reproduces the same warm-render glyph/paint sequence once, + reasserts the accepted attributes two animation frames later, and only then + re-arms the ``fit``/``fitAll`` guards and publishes its readiness marker. + """ + import re + + # Defensive idempotence for direct helper use; the normal render path also + # strips the prior snapshot before its first natural measurement. + _strip_durable_geometry_snapshot(html_path) + original = html_path.read_text(encoding="utf-8") + text = original + + fit_pattern = re.compile( + r"(function\s+fit\s*\(\s*img\s*\)\s*\{)", + flags=re.IGNORECASE, + ) + fit_all_pattern = re.compile( + r"(function\s+fitAll\s*\(\s*\)\s*\{)", + flags=re.IGNORECASE, + ) + text, fit_count = fit_pattern.subn( + lambda match: match.group(1) + _GEOMETRY_FIT_GUARD, + text, + ) + text, fit_all_count = fit_all_pattern.subn( + lambda match: match.group(1) + _GEOMETRY_FIT_ALL_GUARD, + text, + ) + + root_pattern = re.compile(r"]*>", flags=re.IGNORECASE) + root_match = root_pattern.search(text) + if not root_match: + raise RuntimeError("cannot freeze geometry without an root") + root_tag = root_match.group(0) + frozen_root = ( + root_tag[:-1].rstrip() + + f' {_GEOMETRY_FROZEN_ATTR}="1">' + ) + text = text[:root_match.start()] + frozen_root + text[root_match.end():] + + timeout_ms = max(1_000, int(timeout_ms)) + # Keep the DOMContentLoaded and FontFaceSet fallbacks comfortably inside + # the renderer's outer readiness wait. Normal pages still take the + # window.load path; these bounds matter only when a subresource never + # finishes and load therefore never fires. + phase_wait_ms = max(100, min(2_000, timeout_ms // 4)) + compatibility_wait_ms = min(500, phase_wait_ms) + + payload = json.dumps( + snapshot, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ) + # A style/class attribute can legally contain ````. Keep the + # embedded JSON from ever terminating its owner script early. + payload = payload.replace("<", "\\u003c").replace(">", "\\u003e") + install_script = ( + f'" + ) + text = _append_style_at_end(text, install_script) + html_path.write_text(text, encoding="utf-8") + return {"fit": fit_count, "fitAll": fit_all_count} + + +def _validate_installed_geometry_snapshot(page, snapshot: dict) -> list[str]: + """Verify the cold DOM applied every persisted attribute exactly.""" + result = page.evaluate( + """snapshot => { + const root = document.documentElement; + const failures = []; + if (root.getAttribute('data-poster-geometry-frozen') !== '1') + failures.push('frozen root marker is absent'); + if (root.dataset.posterGeometrySnapshotApplied !== '1') + failures.push('snapshot installer did not complete'); + if (root.dataset.posterGeometrySnapshotError) + failures.push('snapshot installer: ' + + root.dataset.posterGeometrySnapshotError); + for (const group of snapshot.groups || []) { + const nodes = Array.from(document.querySelectorAll(group.selector)); + if (nodes.length !== group.nodes.length) { + failures.push(group.name + ' count changed (' + + group.nodes.length + ' -> ' + nodes.length + ')'); + continue; + } + nodes.forEach((node, index) => { + for (const name of group.attributes) { + const expected = group.nodes[index][name]; + const actual = node.hasAttribute(name) + ? node.getAttribute(name) : null; + if (actual !== expected) + failures.push(group.name + '[' + index + '] ' + + name + ' changed'); + } + }); + } + return failures.slice(0, 80); + }""", + snapshot, + ) + if not isinstance(result, list): + return ["geometry snapshot validator returned malformed data"] + return [str(item) for item in result] + + +def _wait_for_installed_geometry_snapshot( + page, + *, + timeout_ms: int, +) -> bool: + """Wait for the cold page's warm-fit-then-restore one-shot to finish.""" + try: + page.wait_for_function( + """() => { + const root = document.documentElement; + return root.dataset.posterGeometrySnapshotApplied === '1' + || !!root.dataset.posterGeometrySnapshotError; + }""", + timeout=timeout_ms, + ) + state = page.evaluate( + """() => ({ + applied:document.documentElement.dataset + .posterGeometrySnapshotApplied || '', + error:document.documentElement.dataset + .posterGeometrySnapshotError || '', + frozen:document.documentElement.getAttribute( + 'data-poster-geometry-frozen') || '', + })""" + ) + except Exception as exc: + _eprint( + "[render_preview] WARN: geometry snapshot readiness wait failed: " + f"{ascii_safe(exc)}" + ) + return False + if (not isinstance(state, dict) + or state.get("applied") != "1" + or state.get("frozen") != "1" + or state.get("error")): + _eprint( + "[render_preview] WARN: geometry snapshot did not publish a clean " + f"ready state: {ascii_safe(state)}" + ) + return False + return True + + +def _decode_browser_png(payload: bytes) -> tuple[int, int, int, bytes]: + """Decode an 8-bit, non-interlaced browser screenshot without Pillow. + + Chromium's PNG screenshots use the ordinary non-interlaced RGB/RGBA PNG + subset. Keeping this tiny decoder in the renderer makes the mandatory + pixel gate self-contained instead of adding an optional image dependency. + The returned bytes are fully unfiltered native pixels; the comparison + helper below interprets them as RGBA. + """ + signature = b"\x89PNG\r\n\x1a\n" + if not isinstance(payload, (bytes, bytearray)) or not payload.startswith(signature): + raise RuntimeError("screenshot is not a PNG") + offset = len(signature) + width = height = bit_depth = color_type = None + compression = png_filter = interlace = None + idat: list[bytes] = [] + while offset + 12 <= len(payload): + length = struct.unpack(">I", payload[offset:offset + 4])[0] + chunk_type = bytes(payload[offset + 4:offset + 8]) + data_start = offset + 8 + data_end = data_start + length + crc_end = data_end + 4 + if crc_end > len(payload): + raise RuntimeError("PNG chunk extends beyond screenshot bytes") + data = bytes(payload[data_start:data_end]) + expected_crc = struct.unpack(">I", payload[data_end:crc_end])[0] + actual_crc = zlib.crc32(chunk_type) + actual_crc = zlib.crc32(data, actual_crc) & 0xFFFFFFFF + if actual_crc != expected_crc: + raise RuntimeError(f"PNG {chunk_type!r} checksum mismatch") + if chunk_type == b"IHDR": + if len(data) != 13: + raise RuntimeError("PNG IHDR has the wrong size") + (width, height, bit_depth, color_type, compression, + png_filter, interlace) = struct.unpack(">IIBBBBB", data) + elif chunk_type == b"IDAT": + idat.append(data) + elif chunk_type == b"IEND": + break + offset = crc_end + + channels = {0: 1, 2: 3, 4: 2, 6: 4}.get(color_type) + if (not width or not height or bit_depth != 8 or channels is None + or compression != 0 or png_filter != 0 or interlace != 0): + raise RuntimeError( + "unsupported browser PNG encoding " + f"({width}x{height}, depth={bit_depth}, color={color_type}, " + f"compression={compression}, filter={png_filter}, " + f"interlace={interlace})" + ) + compressed = b"".join(idat) + filtered = zlib.decompress(compressed) + stride = int(width) * channels + expected_size = int(height) * (stride + 1) + if len(filtered) != expected_size: + raise RuntimeError( + f"decoded PNG scanline size changed ({len(filtered)} != {expected_size})" + ) + + pixels = bytearray(int(height) * stride) + previous = bytearray(stride) + + def paeth(left: int, above: int, upper_left: int) -> int: + estimate = left + above - upper_left + d_left = abs(estimate - left) + d_above = abs(estimate - above) + d_upper_left = abs(estimate - upper_left) + if d_left <= d_above and d_left <= d_upper_left: + return left + if d_above <= d_upper_left: + return above + return upper_left + + for row_index in range(int(height)): + start = row_index * (stride + 1) + filter_type = filtered[start] + source = filtered[start + 1:start + 1 + stride] + current = bytearray(stride) + if filter_type == 0: + current[:] = source + elif filter_type in (1, 2, 3, 4): + for index, encoded in enumerate(source): + left = current[index - channels] if index >= channels else 0 + above = previous[index] + upper_left = previous[index - channels] if index >= channels else 0 + if filter_type == 1: + predictor = left + elif filter_type == 2: + predictor = above + elif filter_type == 3: + predictor = (left + above) // 2 + else: + predictor = paeth(left, above, upper_left) + current[index] = (encoded + predictor) & 0xFF + else: + raise RuntimeError(f"unsupported PNG row filter {filter_type}") + pixel_start = row_index * stride + pixels[pixel_start:pixel_start + stride] = current + previous = current + return int(width), int(height), int(color_type), bytes(pixels) + + +def _compare_decoded_rgba( + reference_png: bytes, + candidate_png: bytes, +) -> tuple[bool, str]: + """Require two PNG screenshots to have zero differing decoded RGBA pixels.""" + # Chromium's screenshot encoder is deterministic. Byte-identical valid + # PNGs necessarily decode to identical RGBA and are a strictly stronger + # equality proof; avoid two expensive 20-megapixel unfilter passes in this + # overwhelmingly common success case. Differently encoded PNGs still go + # through the complete decoder below (covered by the compression-level + # self-test used for this renderer). + if reference_png == candidate_png: + signature = b"\x89PNG\r\n\x1a\n" + if (len(reference_png) < 33 + or not reference_png.startswith(signature) + or reference_png[12:16] != b"IHDR"): + raise RuntimeError("byte-identical screenshots are not valid PNGs") + ref_w, ref_h = struct.unpack(">II", reference_png[16:24]) + return True, f"{ref_w}x{ref_h}, 0 differing decoded RGBA pixels" + + ref_w, ref_h, ref_type, ref_pixels = _decode_browser_png(reference_png) + cur_w, cur_h, cur_type, cur_pixels = _decode_browser_png(candidate_png) + if (ref_w, ref_h) != (cur_w, cur_h): + return False, ( + f"dimensions changed ({ref_w}x{ref_h} -> {cur_w}x{cur_h})" + ) + # The common path avoids materialising two additional 80 MB RGBA buffers: + # equal decoded RGB (or RGBA) samples imply equal RGBA samples exactly. + if ref_type == cur_type and ref_pixels == cur_pixels: + return True, f"{ref_w}x{ref_h}, 0 differing decoded RGBA pixels" + + channels = {0: 1, 2: 3, 4: 2, 6: 4} + + def rgba_at(pixels: bytes, color_type: int, index: int): + channel_count = channels[color_type] + at = index * channel_count + if color_type == 0: + value = pixels[at] + return value, value, value, 255 + if color_type == 2: + return pixels[at], pixels[at + 1], pixels[at + 2], 255 + if color_type == 4: + value = pixels[at] + return value, value, value, pixels[at + 1] + return tuple(pixels[at:at + 4]) + + for pixel_index in range(ref_w * ref_h): + if rgba_at(ref_pixels, ref_type, pixel_index) != rgba_at( + cur_pixels, cur_type, pixel_index): + x = pixel_index % ref_w + y = pixel_index // ref_w + return False, ( + f"at least 1 decoded RGBA pixel differs; first at ({x}, {y})" + ) + return True, f"{ref_w}x{ref_h}, 0 differing decoded RGBA pixels" + + +def _capture_full_viewport_png(page, viewport: tuple[int, int]) -> bytes: + """Capture the fixed 1x poster viewport used by the geometry pixel gate.""" + width, height = viewport + payload = page.screenshot( + type="png", + full_page=False, + clip={"x": 0, "y": 0, "width": width, "height": height}, + ) + if not isinstance(payload, bytes) or not payload: + raise RuntimeError("Playwright returned an empty reference screenshot") + return payload + + def _ensure_unscaled_layout_timer_guard(html_path: Path) -> bool: """Run recurring geometry fitters against the unscaled poster canvas. @@ -355,9 +912,16 @@ def _ensure_unscaled_layout_timer_guard(html_path: Path) -> bool: (() => {{ const nativeSetInterval = window.setInterval.bind(window); window.setInterval = function(callback, delay, ...args) {{ - if (typeof callback !== 'function') - return nativeSetInterval(callback, delay, ...args); + if (typeof callback !== 'function') {{ + const guardedSource = + "if (document.documentElement.getAttribute(" + + "'{_GEOMETRY_FROZEN_ATTR}') !== '1') {{\\n" + + String(callback) + "\\n}}"; + return nativeSetInterval(guardedSource, delay, ...args); + }} const guarded = function(...tickArgs) {{ + if (document.documentElement.getAttribute( + '{_GEOMETRY_FROZEN_ATTR}') === '1') return; const invoke = () => callback.apply(window, tickArgs); const poster = document.querySelector('[data-measure-role="poster"]') || document.querySelector('.poster') @@ -480,15 +1044,23 @@ def _bake_expand_into_html(html_path: Path, baked: list) -> bool: const eligibleImages = new Set(); Array.from(sec.querySelectorAll('img')).forEach((img, imageIndex) => { if (img.closest('.section[data-section]') !== sec) return; + const source = img.getAttribute('src') || ''; + const isResearchFigure = !!img.closest('figure, .figure') + || /(^|\/)figures?\//i.test(source); const r = img.getBoundingClientRect(); - if (r.width < 50 || r.height < 1) return; + // The size cutoff excludes incidental utility icons only. A collapsed + // research image is itself a figure-floor failure and must remain in the + // snapshot; otherwise its larger
wrapper is misreported below as + // a fully painted fallback and the absolute >=90% gate is bypassed. + if (!isResearchFigure && (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') || '', + isResearchFigure:isResearchFigure, + src:source, currentSrc:img.currentSrc || '', boxW:r.width, boxH:r.height, paintedW:p.w, paintedH:p.h, @@ -505,17 +1077,94 @@ def _bake_expand_into_html(html_path: Path, baked: list) -> bool: .some(img => eligibleImages.has(img))) return; const r = node.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return; + const visualRects = Array.from(node.querySelectorAll( + 'svg, canvas, video, object, embed' + )).filter(visual => visual.closest('figure, .figure') === node) + .map(visual => visual.getBoundingClientRect()) + .filter(box => box.width >= 1 && box.height >= 1); + let paintedW = 0; + let paintedH = 0; + let fallbackFit = 'unverified-fallback'; + if (visualRects.length) { + const left = Math.max(r.left, + Math.min(...visualRects.map(box => box.left))); + const top = Math.max(r.top, + Math.min(...visualRects.map(box => box.top))); + const right = Math.min(r.right, + Math.max(...visualRects.map(box => box.right))); + const bottom = Math.min(r.bottom, + Math.max(...visualRects.map(box => box.bottom))); + paintedW = Math.max(0, right - left); + paintedH = Math.max(0, bottom - top); + fallbackFit = 'visual-descendant'; + } else { + const style = getComputedStyle(node); + const backgroundSize = (style.backgroundSize || '') + .trim().toLowerCase(); + if (style.backgroundImage !== 'none' + && (backgroundSize === 'cover' + || backgroundSize === '100% 100%')) { + paintedW = r.width; + paintedH = r.height; + fallbackFit = 'background-cover'; + } + } media.push({ key:section.key + '|figure:' + figureIndex, sectionKey:section.key, sid:section.sid, parentKey:section.parentKey, kind:'figure', + isResearchFigure:true, src:'', currentSrc:'', boxW:r.width, boxH:r.height, - paintedW:r.width, paintedH:r.height, - nw:0, nh:0, fit:'fallback', + paintedW:paintedW, paintedH:paintedH, + nw:0, nh:0, fit:fallbackFit, }); }); }); - return {sections:sections, media:media}; + const text = Array.from(document.querySelectorAll( + 'h1,h2,h3,h4,h5,h6,p,li,td,th,figcaption,blockquote' + )).map((node, index) => { + const rect = node.getBoundingClientRect(); + const range = document.createRange(); + range.selectNodeContents(node); + const inkRects = Array.from(range.getClientRects()).filter( + r => r.width > 0.01 && r.height > 0.01 + ); + const lineTops = []; + inkRects.forEach(r => { + if (!lineTops.some(y => Math.abs(y - r.top) <= 1)) lineTops.push(r.top); + }); + const ink = inkRects.length ? { + x:Math.min(...inkRects.map(r => r.left)), + y:Math.min(...inkRects.map(r => r.top)), + w:Math.max(...inkRects.map(r => r.right)) + - Math.min(...inkRects.map(r => r.left)), + h:Math.max(...inkRects.map(r => r.bottom)) + - Math.min(...inkRects.map(r => r.top)), + } : {x:rect.x, y:rect.y, w:0, h:0}; + const section = node.closest('.section[data-section]'); + const sectionIndex = section ? sectionNodes.indexOf(section) : -1; + return { + key:'text:' + index, + tag:node.tagName.toLowerCase(), + value:(node.innerText || '').replace(/\s+/g, ' ').trim(), + sid:section ? (section.getAttribute('data-section') || '') : '', + parentKey:sectionIndex >= 0 ? sections[sectionIndex].parentKey : '', + controlled:!!node.closest('.section[data-section="scan-to-read"]'), + rect:rectOf(node), ink:ink, lineCount:lineTops.length, + }; + }); + const landmarkNodes = [ + ...document.querySelectorAll('.poster'), + ...document.querySelectorAll('.titlebar'), + ...document.querySelectorAll('.columns'), + ...document.querySelectorAll('.columns > .col'), + ]; + const landmarks = landmarkNodes.map((node, index) => ({ + key:'landmark:' + index, + classes:node.className || '', + rect:rectOf(node), + })); + return {sections:sections, media:media, text:text, landmarks:landmarks}; } """ @@ -528,6 +1177,170 @@ def _capture_expand_snapshot(page) -> dict: return result +def _validate_source_geometry( + page, + baseline: dict, + *, + allow_scan_reflow: bool = False, +) -> list[str]: + """Compare the accepted cold page with its pre-font-freeze source layout. + + Renderer-owned row-gap expansion may move content vertically inside a card, + so text *positions* are intentionally not compared. Text wrapping/ink + bounds, section/card geometry, landmarks, and media dimensions remain hard + invariants. The parent column containing Scan-to-Read is excluded only + after the explicit scan-suppression pass actually hides that section and + is therefore allowed to reflow that one scope. Callers must first run a + strict pre-suppression comparison so portable-font changes cannot hide in + the same column. + """ + after = _capture_expand_snapshot(page) + failures: list[str] = [] + + def close(left, right, tolerance: float = _EXPAND_GEOMETRY_TOLERANCE_PX): + try: + return abs(float(left) - float(right)) <= tolerance + except (TypeError, ValueError): + return False + + def rect_close(left, right, *, position: bool = True) -> bool: + if not isinstance(left, dict) or not isinstance(right, dict): + return False + keys = ("x", "y", "w", "h") if position else ("w", "h") + return all(close(left.get(key), right.get(key)) for key in keys) + + before_sections = [ + item for item in (baseline.get("sections") or []) + if isinstance(item, dict) + ] + after_sections = [ + item for item in (after.get("sections") or []) + if isinstance(item, dict) + ] + controlled_parents = ( + { + str(item.get("parentKey", "")) for item in before_sections + if str(item.get("sid", "")) == "scan-to-read" + } + if allow_scan_reflow + else set() + ) + if len(before_sections) != len(after_sections): + failures.append( + f"section count changed ({len(before_sections)} -> {len(after_sections)})" + ) + for index, (before, current) in enumerate(zip(before_sections, after_sections)): + identity = ("key", "sid", "parentKey") + if any(before.get(key) != current.get(key) for key in identity): + failures.append(f"section identity/order changed at index {index}") + continue + if str(before.get("parentKey", "")) in controlled_parents: + continue + if not rect_close(before.get("rect"), current.get("rect")): + failures.append(f"section geometry changed: {before.get('sid', index)}") + if not rect_close(before.get("parentRect"), current.get("parentRect")): + failures.append(f"section parent geometry changed: {before.get('sid', index)}") + + before_landmarks = [ + item for item in (baseline.get("landmarks") or []) + if isinstance(item, dict) + ] + after_landmarks = [ + item for item in (after.get("landmarks") or []) + if isinstance(item, dict) + ] + if len(before_landmarks) != len(after_landmarks): + failures.append( + "poster/header/column landmark count changed " + f"({len(before_landmarks)} -> {len(after_landmarks)})" + ) + for index, (before, current) in enumerate(zip(before_landmarks, after_landmarks)): + if (before.get("key") != current.get("key") + or before.get("classes") != current.get("classes")): + failures.append(f"landmark identity/order changed at index {index}") + elif not rect_close(before.get("rect"), current.get("rect")): + failures.append(f"landmark geometry changed at index {index}") + + before_media = [ + item for item in (baseline.get("media") or []) + if isinstance(item, dict) + ] + after_media = [ + item for item in (after.get("media") or []) + if isinstance(item, dict) + ] + if allow_scan_reflow: + # A suppressed legacy Scan-to-Read tile can collapse its QR to + # 0x0. The snapshot intentionally omits such non-research media, so + # remove the complete controlled parent scope before comparing key + # sets. Additions/removals everywhere else remain strict. + before_media = [ + item for item in before_media + if str(item.get("parentKey", "")) not in controlled_parents + ] + after_media = [ + item for item in after_media + if str(item.get("parentKey", "")) not in controlled_parents + ] + before_media_by_key = {str(item.get("key", "")): item for item in before_media} + after_media_by_key = {str(item.get("key", "")): item for item in after_media} + if set(before_media_by_key) != set(after_media_by_key): + failures.append("media set changed") + for key, before in before_media_by_key.items(): + if str(before.get("parentKey", "")) in controlled_parents: + continue + current = after_media_by_key.get(key) + if current is None: + continue + identity = ("sectionKey", "sid", "kind", "src", "fit", "isResearchFigure") + if any(before.get(field) != current.get(field) for field in identity): + failures.append(f"media identity/source changed: {key}") + continue + for field in ("boxW", "boxH", "paintedW", "paintedH"): + old_value = float(before.get(field) or 0) + new_value = float(current.get(field) or 0) + tolerance = min( + _EXPAND_GEOMETRY_TOLERANCE_PX, + _EXPAND_MEDIA_REL_TOLERANCE + * max(abs(old_value), abs(new_value)), + ) + if not close(old_value, new_value, tolerance): + failures.append(f"media {field} changed: {key}") + if (before.get("nw"), before.get("nh")) != ( + current.get("nw"), current.get("nh")): + failures.append(f"media intrinsic dimensions changed: {key}") + + before_text = [ + item for item in (baseline.get("text") or []) + if isinstance(item, dict) + ] + after_text = [ + item for item in (after.get("text") or []) + if isinstance(item, dict) + ] + if len(before_text) != len(after_text): + failures.append(f"text-block count changed ({len(before_text)} -> {len(after_text)})") + for index, (before, current) in enumerate(zip(before_text, after_text)): + if (before.get("key") != current.get("key") + or before.get("tag") != current.get("tag") + or before.get("value") != current.get("value")): + failures.append(f"text identity/content changed at index {index}") + continue + if allow_scan_reflow and ( + before.get("controlled") + or str(before.get("parentKey", "")) in controlled_parents): + continue + if before.get("lineCount") != current.get("lineCount"): + failures.append(f"text wrapping changed: {before.get('key', index)}") + if not rect_close(before.get("rect"), current.get("rect"), position=False): + failures.append(f"text block size changed: {before.get('key', index)}") + if not rect_close(before.get("ink"), current.get("ink"), position=False): + failures.append(f"text ink bounds changed: {before.get('key', index)}") + + # Keep diagnostics bounded even on a badly diverged legacy page. + return failures[:80] + + def _wait_for_images_decoded(page, *, timeout_ms: int, label: str) -> bool: """Wait until every document image has loaded and decoded. @@ -535,31 +1348,55 @@ def _wait_for_images_decoded(page, *, timeout_ms: int, label: str) -> bool: standalone reopen cannot. Durable geometry must therefore be measured only after a bounded decode wait in the fresh context used for capture. """ + # Playwright interprets timeout=0 as "wait forever". The CLI accepts + # zero for historical compatibility, so clamp every call to a genuinely + # bounded host-side timeout before entering the page. + timeout_ms = max(1_000, int(timeout_ms)) + state_name = "__paper2posterImageDecodeState" 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, + # Start and poll the asynchronous decode inside wait_for_function. + # Unlike page.evaluate, Playwright enforces this call's timeout from + # the host even when the page main thread is busy or the poster has + # replaced/throttled window.setTimeout. This keeps a nominally bounded + # image gate from hanging the complete render transaction. + status_handle = page.wait_for_function( + """stateName => { + let state = window[stateName]; + if (!state) { + state = {status:'pending'}; + window[stateName] = state; + 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 => { state.status = results.every(Boolean) + ? 'ok' : 'failed'; }, + () => { state.status = 'failed'; }, + ); + } + return state.status === 'pending' ? false : state.status; + }""", + arg=state_name, + timeout=timeout_ms, + polling=100, ) + try: + status = status_handle.json_value() + finally: + status_handle.dispose() except Exception as exc: _eprint( - f"[render_preview] WARN: {label} image decode check failed: " - f"{ascii_safe(exc)}." + f"[render_preview] WARN: {label} images did not decode cleanly " + f"within {timeout_ms} ms: {ascii_safe(exc)}." ) return False if status != "ok": @@ -575,6 +1412,8 @@ def _validate_durable_expand( page, records: list[dict], baseline: dict, + *, + enforce_figure_floor: bool = True, ) -> dict: """Validate provisional rules against the whole natural poster snapshot. @@ -710,16 +1549,15 @@ def _validate_durable_expand( ? 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) + // Research figures normally have an absolute minimum. The only + // exception is the explicit historical-backfill token, represented + // here by figMin=0; the <=1.01 overflow ceiling stays unconditional. + // QR codes and utility images are excluded by isResearchFigure. + if (current.isResearchFigure && fill + 1e-6 < figMin) addFailure(before.parentKey, 'media fill dropped below floor: ' + before.key); - if ((beforeWr <= figMax && wr > figMax) - || (beforeHr <= figMax && hr > figMax)) + if (current.isResearchFigure && (wr > figMax || hr > figMax)) addFailure(before.parentKey, 'media overflowed its section: ' + before.key); } @@ -761,7 +1599,9 @@ def _validate_durable_expand( "gapTol": _EXPAND_GAP_TOLERANCE_PX, "geomTol": _EXPAND_GEOMETRY_TOLERANCE_PX, "mediaRelTol": _EXPAND_MEDIA_REL_TOLERANCE, - "figMin": _EXPAND_FIG_MIN_RATIO, + "figMin": ( + _EXPAND_FIG_MIN_RATIO if enforce_figure_floor else 0.0 + ), "figMax": _EXPAND_FIG_MAX_RATIO, }, ) @@ -778,6 +1618,58 @@ def _validate_durable_expand( return result +def _validate_final_figure_fill( + page, + *, + enforce_minimum: bool = True, +) -> list[str]: + """Return absolute research-figure fill/overflow failures for ``page``. + + Expand validation is conditional: a poster with no provisional expand rule + can reach capture without calling ``_validate_durable_expand`` at all. The + >=90% figure contract is normally unconditional, so enforce it once + more on the accepted cold page immediately before PDF/PNG capture. The + sole exception is the explicit historical-backfill compatibility token: + those immutable source pixels may already violate the modern minimum. + Overflow remains strict in both modes. + """ + snapshot = _capture_expand_snapshot(page) + sections = { + str(item.get("key", "")): item + for item in (snapshot.get("sections") or []) + if isinstance(item, dict) + } + failures: list[str] = [] + for media in snapshot.get("media") or []: + if not isinstance(media, dict) or not media.get("isResearchFigure"): + continue + section = sections.get(str(media.get("sectionKey", ""))) or {} + rect = section.get("rect") or {} + section_w = float(rect.get("w") or 0) + section_h = float(rect.get("h") or 0) + if section_w <= 0 or section_h <= 0: + failures.append( + f"research figure has no measurable section: {media.get('key', '')}" + ) + continue + width_ratio = float(media.get("paintedW") or 0) / section_w + height_ratio = float(media.get("paintedH") or 0) / section_h + fill = max(width_ratio, height_ratio) + key = str(media.get("key", "")) + if enforce_minimum and fill + 1e-6 < _EXPAND_FIG_MIN_RATIO: + failures.append( + f"research figure fill {fill:.4f} is below " + f"{_EXPAND_FIG_MIN_RATIO:.2f}: {key}" + ) + if (width_ratio > _EXPAND_FIG_MAX_RATIO + 1e-6 + or height_ratio > _EXPAND_FIG_MAX_RATIO + 1e-6): + failures.append( + "research figure overflows its section " + f"({width_ratio:.4f}x{height_ratio:.4f}): {key}" + ) + return failures + + def _settle_loaded_durable_page( page, *, @@ -846,13 +1738,15 @@ def _reload_and_settle_after_bake( timeout_ms: int, playwright_timeout_error, label: str, -) -> bool: - """Reload renderer-owned CSS and report whether geometry fully settled. +) -> tuple[bool, bool]: + """Reload renderer-owned CSS and report navigation and settle separately. 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. + validator fails closed and removes it. A failed navigation is different: + there is no persisted page to validate or capture, so callers keep that as + a hard transaction failure. """ navigated = True try: @@ -878,13 +1772,14 @@ def _reload_and_settle_after_bake( f"[render_preview] WARN: {label} failed: {ascii_safe(exc)}; " "provisional expand rules will fail closed." ) - return False - return _settle_loaded_durable_page( + return False, False + settled = _settle_loaded_durable_page( page, timeout_ms=timeout_ms, playwright_timeout_error=playwright_timeout_error, label=label, - ) and navigated + ) + return navigated, settled def _new_print_context_page(browser, viewport: tuple[int, int]): @@ -910,7 +1805,7 @@ def _open_fresh_durable_page( playwright_timeout_error, label: str, ): - """Navigate once in a brand-new BrowserContext, then fully settle it.""" + """Open a fresh context and return navigation and settle independently.""" context, page = _new_print_context_page(browser, viewport) navigated = True try: @@ -931,14 +1826,14 @@ def _open_fresh_durable_page( f"[render_preview] WARN: {label} failed: {ascii_safe(exc)}; " "provisional expand rules will fail closed." ) - return context, page, False + return context, page, False, 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 + return context, page, navigated, stable def _capture_style_targets_and_freeze(context, page): @@ -1050,6 +1945,18 @@ def _bake_scan_suppress_into_html(html_path: Path) -> bool: return True +def _remove_scan_suppress_from_html(html_path: Path) -> bool: + """Remove the optional scan mutation after any durability uncertainty.""" + original = html_path.read_text(encoding="utf-8") + text, count = _strip_derived_style_block( + original, "poster-scan-suppress" + ) + if not count: + return False + html_path.write_text(text, encoding="utf-8") + return True + + def _autopack_header_logos(html_path: Path) -> None: """Step 5.9 auto-run: pack the header institution logos so they FILL their zone (multi-row, grown to fit) instead of one tiny row. This is a manual @@ -1091,13 +1998,34 @@ def _render_staged( 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. + legacy_figure_floor_compat = ( + os.environ.get(_LEGACY_FIGURE_FLOOR_ENV, "").strip() + == _LEGACY_FIGURE_FLOOR_TOKEN + ) + enforce_figure_floor = not legacy_figure_floor_compat + figure_policy_label = ( + "the >=90% minimum and <=1.01 overflow research-figure contracts" + if enforce_figure_floor + else ( + "the <=1.01 research-figure overflow contract under the explicit " + "legacy minimum-only exception" + ) + ) + if legacy_figure_floor_compat: + _eprint( + "[render_preview] LEGACY COMPAT: preserving an immutable " + "historical source that predates the >=90% research-figure " + "minimum; the minimum-only gate is disabled for this render, " + "while intrinsic geometry and <=1.01 overflow remain strict." + ) + + # Geometry/expand/scan state is renderer-derived final output. Recover the + # authored natural document on every explicit rerender; otherwise the prior + # frozen snapshot (or an old row-gap bake) would feed back into itself. + _strip_durable_geometry_snapshot(html_path) _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 resolved = _canvas.resolve_canvas( @@ -1120,9 +2048,52 @@ def _render_staged( sync_playwright, PWTimeoutError = pw with sync_playwright() as p_: - browser, ctx, page = _render.open_print_emulated_page( - p_, viewport + browser = p_.chromium.launch() + ( + _source_ctx, + _source_page, + _source_navigated, + _source_settled, + ) = _open_fresh_durable_page( + browser, + viewport, + html_path, + timeout_ms=args.mathjax_timeout_ms, + playwright_timeout_error=PWTimeoutError, + label="pre-font-freeze source baseline", ) + if not _source_navigated: + _eprint( + "[render_preview] ERROR: source layout did not complete " + "navigation; refusing to mutate a render transaction without " + "a geometry baseline." + ) + _source_ctx.close() + browser.close() + return 2 + if not _source_settled: + _eprint( + "[render_preview] WARN: source layout did not fully settle; " + "continuing on the renderer's soft path with all optional " + "expand/scan mutations disabled." + ) + try: + _source_baseline = _capture_expand_snapshot(_source_page) + except Exception as exc: + _eprint( + "[render_preview] ERROR: could not capture the source layout " + f"baseline: {ascii_safe(exc)}" + ) + _source_ctx.close() + browser.close() + return 2 + _source_ctx.close() + + # Font freezing is the portability mutation under test. It runs only + # after a settled source snapshot exists, and the final cold page must + # prove that section/media geometry and text wrapping survived it. + freeze_system_font_webfont(html_path) + ctx, page = _new_print_context_page(browser, viewport) # Soft path: a hung CDN (blocked MathJax fetch, unreachable # web font) must not hard-crash render. Playwright's default # `page.goto` waits for `load` (all subresources), which can @@ -1155,29 +2126,76 @@ def _render_staged( label="initial load", ) - settle = _render.settle_page( - page, - mathjax_timeout_ms=args.mathjax_timeout_ms, - settle_ms=1500, + _initial_settle_complete = bool( + _source_settled and _initial_images_ready ) - # Render is soft path: warn but continue, even on MathJax - # problems — the user can SEE raw $...$ on the resulting PDF. - if settle.mathjax_status == "timeout": - _eprint( - f"[render_preview] WARN: MathJax typeset timed out " - f"after {args.mathjax_timeout_ms} ms." - ) - elif settle.mathjax_status == "error": - _eprint( - f"[render_preview] WARN: MathJax error: " - f"{ascii_safe(settle.mathjax_error)}" + try: + settle = _render.settle_page( + page, + mathjax_timeout_ms=args.mathjax_timeout_ms, + settle_ms=1500, ) - if settle.mathjax_intended and settle.tex_without_mathjax: + except Exception as exc: + # Rendering is a soft path. Navigation succeeded, so retain the + # natural page and reject only renderer-owned optional mutations. + _initial_settle_complete = False _eprint( - "[render_preview] WARN: page intended to load MathJax " - "but no rendered -- MathJax may have " - "failed to load. PDF will show raw $...$ text." + "[render_preview] WARN: initial settle failed: " + f"{ascii_safe(exc)}; continuing without optional expand/scan " + "mutations." ) + else: + # Render is a soft path: warn but continue, even on MathJax + # problems — the user can SEE raw $...$ on the resulting PDF. + if settle.mathjax_status == "timeout": + _initial_settle_complete = False + _eprint( + f"[render_preview] WARN: MathJax typeset timed out " + f"after {args.mathjax_timeout_ms} ms." + ) + elif settle.mathjax_status == "error": + _initial_settle_complete = False + _eprint( + f"[render_preview] WARN: MathJax error: " + f"{ascii_safe(settle.mathjax_error)}" + ) + if settle.mathjax_intended and settle.tex_without_mathjax: + _initial_settle_complete = False + _eprint( + "[render_preview] WARN: page intended to load MathJax " + "but no rendered -- MathJax may have " + "failed to load. PDF will show raw $...$ text." + ) + + # Prove the portable-font mutation itself preserves the complete + # authored layout before Scan-to-Read suppression is allowed to reflow + # one column. Without this strict pre-scan gate, the later intentional + # reflow exception could also conceal unrelated font wrapping changes + # in Headline Numbers, Takeaway, or another sibling card. + if _initial_settle_complete: + try: + _pre_scan_source_geometry_failures = ( + _validate_source_geometry(page, _source_baseline) + ) + except Exception as exc: + _eprint( + "[render_preview] ERROR: could not validate portable-font " + "geometry before optional Scan-to-Read suppression: " + f"{ascii_safe(exc)}" + ) + browser.close() + return 2 + if _pre_scan_source_geometry_failures: + _eprint( + "[render_preview] ERROR: portable-font mutation changed " + "source geometry or text wrapping before optional " + "Scan-to-Read suppression: " + + ascii_safe( + "; ".join(_pre_scan_source_geometry_failures) + ) + ) + browser.close() + return 2 # Scan-to-Read aspect guard (runs BEFORE the expand pass). A scan section # that came out wide and flat -- its own width many times its own height @@ -1200,7 +2218,7 @@ def _render_staged( _scan_wh = float(os.environ.get("POSTER_SCAN_SUPPRESS_WH", "3.8")) except Exception: _scan_wh = 3.8 - if _scan_wh > 0: + if _scan_wh > 0 and _initial_settle_complete: try: _sv = page.evaluate( """(T) => { @@ -1236,8 +2254,9 @@ def _render_staged( # 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 + # removes any rule that changes figure dimensions, violates the >=90% + # minimum when enforced, or exceeds the unconditional <=1.01 overflow + # ceiling. 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 @@ -1258,7 +2277,7 @@ def _render_staged( _expand_baseline = {} _expand_failed = False _baked = [] - if _expand_t > 0 and _initial_images_ready: + if _expand_t > 0 and _initial_settle_complete: try: # This is the one natural-layout capture. It completes before # the proposal evaluator is allowed to mutate any row-gap. @@ -1380,7 +2399,7 @@ def _render_staged( elif _expand_t > 0: _eprint( "[render_preview] WARN: render-time expand skipped because " - "the natural image baseline was not fully decoded." + "the natural page did not fully settle." ) # Persist renderer-derived state in cascade order: scan first, expand @@ -1399,19 +2418,43 @@ def _render_staged( # catches load-count-dependent fitters and late cascade overrides. _survivors = list(_expand_records) _had_provisional_expand = bool(_survivors) - _durable_ready = True + _durable_navigated = True + _durable_settled = 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", + _durable_navigated, _durable_settled = ( + _reload_and_settle_after_bake( + page, + timeout_ms=args.mathjax_timeout_ms, + playwright_timeout_error=PWTimeoutError, + label="post-bake reload", + ) + ) + if not _durable_navigated: + _eprint( + "[render_preview] ERROR: persisted HTML could not complete " + "post-bake navigation; refusing to capture a live-only DOM." + ) + browser.close() + return 2 + if not _durable_settled and _scan_suppressed: + _eprint( + "[render_preview] scan suppression rollback: durable settle " + "incomplete." + ) + _remove_scan_suppress_from_html(html_path) + _scan_suppressed = False + _durable_navigated, _durable_settled = ( + _reload_and_settle_after_bake( + page, + timeout_ms=args.mathjax_timeout_ms, + playwright_timeout_error=PWTimeoutError, + label="post-scan rollback reload", + ) ) - if _expand_failed and not _durable_ready: + if not _durable_navigated: _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." + "[render_preview] ERROR: natural persisted HTML could not " + "complete navigation after optional scan rollback." ) browser.close() return 2 @@ -1419,22 +2462,30 @@ def _render_staged( if _had_provisional_expand: _clean_validations = 0 while True: - if not _durable_ready and _survivors: + if not _durable_navigated or not _durable_settled: + _state_reason = ( + "durable navigation incomplete" + if not _durable_navigated + else "durable settle incomplete" + ) _validation = { "failures": [ { "sid": str(record.get("sid", "")), - "reasons": ["durable settle incomplete"], + "reasons": [_state_reason], } for record in _survivors ], "snapshotOk": False, - "snapshotReasons": ["durable settle incomplete"], + "snapshotReasons": [_state_reason], } else: try: _validation = _validate_durable_expand( - page, _survivors, _expand_baseline, + page, + _survivors, + _expand_baseline, + enforce_figure_floor=enforce_figure_floor, ) except Exception as exc: _reason = f"durable validation failed: {ascii_safe(exc)}" @@ -1456,11 +2507,13 @@ def _render_staged( 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", + _durable_navigated, _durable_settled = ( + _reload_and_settle_after_bake( + page, + timeout_ms=args.mathjax_timeout_ms, + playwright_timeout_error=PWTimeoutError, + label="post-expand confirmation reload", + ) ) continue @@ -1514,19 +2567,37 @@ def _render_staged( 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", + _durable_navigated, _durable_settled = ( + _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. + # navigated but a soft image/MathJax settle did not complete, + # the natural page remains renderable; the later independent + # warm/cold RGBA equality gate still protects durability. A + # navigation failure or a settled geometry mismatch remains + # fatal. + if not _durable_navigated: + _eprint( + "[render_preview] ERROR: natural HTML did not complete " + "navigation after expand rollback." + ) + browser.close() + return 2 + if not _durable_settled: + _eprint( + "[render_preview] WARN: natural HTML settle remained " + "incomplete after expand rollback; continuing on the " + "soft render path." + ) + break _reasons = "; ".join( str(reason) for reason in (_validation.get("snapshotReasons") or []) @@ -1553,32 +2624,68 @@ def _render_staged( _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}" - ), - ) + ( + _fresh_ctx, + _fresh_page, + _fresh_navigated, + _fresh_settled, + ) = _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: + if (not _fresh_settled and _fresh_navigated + and _scan_suppressed): + _eprint( + "[render_preview] scan suppression rollback: " + "fresh-context settle incomplete." + ) + try: + _fresh_ctx.close() + except Exception: + pass + _remove_scan_suppress_from_html(html_path) + _scan_suppressed = False + continue + if (not _fresh_settled and _fresh_navigated + and not _survivors and not _scan_suppressed): + # All optional output is gone. Keep the navigated natural + # page on the documented soft path; the independent final + # warm/cold pixel gate remains mandatory. + _eprint( + "[render_preview] WARN: natural fresh-context settle " + "incomplete after optional rollback; continuing on " + "the soft render path." + ) + _old_ctx = ctx + ctx, page = _fresh_ctx, _fresh_page + try: + _old_ctx.close() + except Exception: + pass + break + if not _fresh_navigated or not _fresh_settled: + _fresh_state_reason = ( + "fresh-context navigation incomplete" + if not _fresh_navigated + else "fresh-context settle incomplete" + ) _fresh_validation = { "failures": [ { "sid": str(record.get("sid", "")), - "reasons": ["fresh-context settle incomplete"], + "reasons": [_fresh_state_reason], } for record in _survivors ], "snapshotOk": False, - "snapshotReasons": [ - "fresh-context settle incomplete" - ], + "snapshotReasons": [_fresh_state_reason], } elif _expand_baseline: try: @@ -1586,6 +2693,7 @@ def _render_staged( _fresh_page, _survivors, _expand_baseline, + enforce_figure_floor=enforce_figure_floor, ) except Exception as exc: _reason = ( @@ -1622,7 +2730,7 @@ def _render_staged( _fresh_validation.get("snapshotOk") ) if (not _fresh_failures and _fresh_snapshot_ok - and _fresh_ready): + and _fresh_navigated and _fresh_settled): _old_ctx = ctx ctx, page = _fresh_ctx, _fresh_page try: @@ -1686,6 +2794,16 @@ def _render_staged( ) continue + if _scan_suppressed: + _eprint( + "[render_preview] scan suppression rollback: " + "fresh-context validation did not reach a durable " + "fixed point." + ) + _remove_scan_suppress_from_html(html_path) + _scan_suppressed = False + continue + _reasons = "; ".join( str(reason) for reason in (_fresh_validation.get("snapshotReasons") or []) @@ -1727,18 +2845,225 @@ def _render_staged( return 2 try: - _capture_cdp, _capture_style_targets = ( + _source_geometry_failures = _validate_source_geometry( + page, + _source_baseline, + allow_scan_reflow=_scan_suppressed, + ) + except Exception as exc: + _eprint( + "[render_preview] ERROR: could not validate final cold page " + f"against its source geometry: {ascii_safe(exc)}" + ) + browser.close() + return 2 + if _source_geometry_failures: + _eprint( + "[render_preview] ERROR: portable-font/render mutations " + "changed source geometry or text wrapping: " + + ascii_safe("; ".join(_source_geometry_failures)) + ) + browser.close() + return 2 + + try: + _figure_fill_failures = _validate_final_figure_fill( + page, + enforce_minimum=enforce_figure_floor, + ) + except Exception as exc: + _eprint( + "[render_preview] ERROR: could not validate final research " + f"figure fill: {ascii_safe(exc)}" + ) + browser.close() + return 2 + if _figure_fill_failures: + _eprint( + "[render_preview] ERROR: final poster violates " + f"{figure_policy_label}: " + + ascii_safe("; ".join(_figure_fill_failures)) + ) + browser.close() + return 2 + + # Persist the *actual* final figure geometry, not only the expand CSS. + # The current page is the accepted warm reference: all image fitters, + # MathJax, font freezing, logo packing, and expand rollback have already + # reached their fixed point and passed the active minimum/overflow + # figure policy. + # Capture its authored-node attributes, freeze scripts immediately, and + # take a full-canvas reference screenshot before touching the HTML. + try: + _geometry_snapshot = _capture_durable_geometry_snapshot(page) + _warm_cdp, _warm_style_targets = ( _capture_style_targets_and_freeze(ctx, page) ) + _warm_reference_png = _capture_full_viewport_png(page, viewport) except Exception as exc: _eprint( - "[render_preview] ERROR: could not freeze the accepted " - "durable page before capture: " + "[render_preview] ERROR: could not capture/freeze the final " + "warm geometry reference: " f"{ascii_safe(exc)}" ) browser.close() return 2 + try: + _guard_counts = _persist_durable_geometry_snapshot( + html_path, + _geometry_snapshot, + timeout_ms=args.mathjax_timeout_ms, + ) + except Exception as exc: + _eprint( + "[render_preview] ERROR: could not persist the final figure " + f"geometry snapshot: {ascii_safe(exc)}" + ) + browser.close() + return 2 + + # A brand-new BrowserContext is the equality oracle. The final + # HTML must replay the authored warm fitter once, reassert the saved + # figure/img styles, publish its frozen/ready markers, preserve source + # geometry and the active figure policy, then paint the exact same + # decoded RGBA pixels as the warm reference. No tolerance or + # approximate geometry gate can substitute for this comparison. Only + # this accepted cold page is subsequently used for PDF and PNG capture. + ( + _cold_ctx, + _cold_page, + _cold_navigated, + _cold_settled, + ) = _open_fresh_durable_page( + browser, + viewport, + html_path, + timeout_ms=args.mathjax_timeout_ms, + playwright_timeout_error=PWTimeoutError, + label="frozen-geometry cold confirmation", + ) + if not _cold_navigated: + _eprint( + "[render_preview] ERROR: frozen-geometry HTML did not " + "complete navigation in a brand-new BrowserContext; refusing " + "promotion." + ) + _cold_ctx.close() + browser.close() + return 2 + if not _cold_settled: + _eprint( + "[render_preview] WARN: frozen-geometry cold page did not " + "fully settle; continuing on the soft render path because " + "the durable snapshot and exact warm/cold RGBA gates remain " + "mandatory." + ) + if not _wait_for_installed_geometry_snapshot( + _cold_page, + timeout_ms=args.mathjax_timeout_ms, + ): + _eprint( + "[render_preview] ERROR: frozen-geometry cold page did not " + "complete its warm-fit-then-restore handshake; refusing " + "promotion." + ) + _cold_ctx.close() + browser.close() + return 2 + try: + _snapshot_failures = _validate_installed_geometry_snapshot( + _cold_page, + _geometry_snapshot, + ) + _cold_source_geometry_failures = _validate_source_geometry( + _cold_page, + _source_baseline, + allow_scan_reflow=_scan_suppressed, + ) + _cold_figure_fill_failures = _validate_final_figure_fill( + _cold_page, + enforce_minimum=enforce_figure_floor, + ) + except Exception as exc: + _eprint( + "[render_preview] ERROR: frozen-geometry cold validation " + f"failed: {ascii_safe(exc)}" + ) + _cold_ctx.close() + browser.close() + return 2 + if _snapshot_failures: + _eprint( + "[render_preview] ERROR: frozen-geometry snapshot was not " + "installed exactly: " + + ascii_safe("; ".join(_snapshot_failures)) + ) + _cold_ctx.close() + browser.close() + return 2 + if _cold_source_geometry_failures: + _eprint( + "[render_preview] ERROR: frozen-geometry cold page changed " + "source geometry or text wrapping: " + + ascii_safe("; ".join(_cold_source_geometry_failures)) + ) + _cold_ctx.close() + browser.close() + return 2 + if _cold_figure_fill_failures: + _eprint( + "[render_preview] ERROR: frozen-geometry cold page violates " + f"{figure_policy_label}: " + + ascii_safe("; ".join(_cold_figure_fill_failures)) + ) + _cold_ctx.close() + browser.close() + return 2 + + try: + _capture_cdp, _capture_style_targets = ( + _capture_style_targets_and_freeze(_cold_ctx, _cold_page) + ) + _cold_candidate_png = _capture_full_viewport_png( + _cold_page, + viewport, + ) + _pixels_equal, _pixel_detail = _compare_decoded_rgba( + _warm_reference_png, + _cold_candidate_png, + ) + except Exception as exc: + _eprint( + "[render_preview] ERROR: could not run the warm/cold decoded " + f"RGBA pixel gate: {ascii_safe(exc)}" + ) + _cold_ctx.close() + browser.close() + return 2 + if not _pixels_equal: + _eprint( + "[render_preview] ERROR: frozen-geometry cold page is not " + "pixel-identical to the final warm DOM: " + f"{ascii_safe(_pixel_detail)}" + ) + _cold_ctx.close() + browser.close() + return 2 + + _eprint( + "[render_preview] frozen geometry persisted " + f"(fit guards={_guard_counts.get('fit', 0)}, " + f"fitAll guards={_guard_counts.get('fitAll', 0)}); " + f"warm/cold pixel gate PASS: {ascii_safe(_pixel_detail)}." + ) + _warm_ctx = ctx + ctx, page = _cold_ctx, _cold_page + try: + _warm_ctx.close() + except Exception: + pass + # ---- PDF: exact poster size, print-emulated ---- pdf_scale = _pdf_content_scale(canvas, viewport) if pdf_scale < 0.999999: diff --git a/ResearchStudio-Reel/skills/paper2poster/scripts/utils/font_fidelity.py b/ResearchStudio-Reel/skills/paper2poster/scripts/utils/font_fidelity.py index 259db3c..5da4126 100644 --- a/ResearchStudio-Reel/skills/paper2poster/scripts/utils/font_fidelity.py +++ b/ResearchStudio-Reel/skills/paper2poster/scripts/utils/font_fidelity.py @@ -3,24 +3,93 @@ 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. +to different glyph metrics and wrap differently. This module preserves the +renderer platform's *actual* open-licensed fallback, subsets that exact face +into the bundle, and records the requested family for the native PPTX handoff. """ from __future__ import annotations +import hashlib +import html import os import re import shutil import subprocess import tempfile +from dataclasses import dataclass from pathlib import Path from .cli_common import eprint -_FIDELITY_VERSION = "4" -_LICENSE_NAME = "RS-DejaVu-LICENSE.txt" +_FIDELITY_VERSION = "7" +_SUBSET_FORMAT_VERSION = "2" +_SUBSET_GENERATOR_SCHEMA = "paper2poster-font-subset-p2" + + +@dataclass(frozen=True) +class _SourceSpec: + family: str + regular_style: str + bold_style: str + asset_slug: str + suffix: str + css_format: str + license_name: str + license_candidates: tuple[Path, ...] + + +@dataclass(frozen=True) +class _FontFace: + path: Path + index: int + + +_SOURCE_SPECS = { + "noto sans cjk sc": _SourceSpec( + family="Noto Sans CJK SC", + regular_style="Regular", + bold_style="Bold", + asset_slug="NotoSansCJKSC", + suffix=".otf", + css_format="opentype", + license_name="RS-NotoSansCJKSC-LICENSE.txt", + license_candidates=( + Path("/usr/share/doc/fonts-noto-cjk/copyright"), + Path("/usr/share/licenses/noto-fonts/LICENSE"), + Path("/usr/share/licenses/google-noto-cjk-fonts/LICENSE"), + Path("/usr/share/licenses/noto-cjk-fonts/LICENSE"), + ), + ), + "dejavu sans": _SourceSpec( + family="DejaVu Sans", + regular_style="Book", + bold_style="Bold", + asset_slug="DejaVuSans", + suffix=".ttf", + css_format="truetype", + license_name="RS-DejaVu-LICENSE.txt", + license_candidates=( + Path("/usr/share/doc/fonts-dejavu-core/copyright"), + Path("/usr/share/licenses/ttf-dejavu/LICENSE"), + Path("/usr/share/licenses/dejavu-fonts/LICENSE"), + ), + ), + "dejavu serif": _SourceSpec( + family="DejaVu Serif", + regular_style="Book", + bold_style="Bold", + asset_slug="DejaVuSerif", + suffix=".ttf", + css_format="truetype", + license_name="RS-DejaVu-LICENSE.txt", + license_candidates=( + Path("/usr/share/doc/fonts-dejavu-core/copyright"), + Path("/usr/share/licenses/ttf-dejavu/LICENSE"), + Path("/usr/share/licenses/dejavu-fonts/LICENSE"), + ), + ), +} _PORTABLE_FAMILIES = { "calibri": ("Calibri", "DejaVu Sans"), @@ -41,18 +110,30 @@ ) -def managed_font_asset_names() -> frozenset[str]: - """Return every filename this module may create under ``assets/fonts``. +def managed_font_asset_names(fonts_dir: Path | None = None) -> frozenset[str]: + """Return managed mutable and content-addressed font asset names. ``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. + fixed names source-derived; when ``fonts_dir`` is supplied, also discover + existing immutable subset names. The journal calls this once before a + render and again during rollback, so identity assets created by a failed + render are removed without putting unrelated files in scope. """ - 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") + names: set[str] = set() + for spec in _SOURCE_SPECS.values(): + names.add(spec.license_name) + names.add(f"RS-{spec.asset_slug}-Regular{spec.suffix}") + names.add(f"RS-{spec.asset_slug}-Bold{spec.suffix}") + if fonts_dir is not None and fonts_dir.is_dir(): + identity_pattern = re.compile( + rf"^RS-{re.escape(spec.asset_slug)}-(?:Regular|Bold)" + rf"-s[0-9a-f]{{64}}-i\d+-g[0-9a-f]{{64}}" + rf"(?:-p1|-p\d+-t[0-9a-f]{{64}})" + rf"{re.escape(spec.suffix)}$" + ) + for path in fonts_dir.iterdir(): + if identity_pattern.fullmatch(path.name): + names.add(path.name) return frozenset(names) @@ -76,12 +157,14 @@ def _copy_public_asset_atomic(source: Path, target: Path) -> None: temporary.unlink(missing_ok=True) -def _resolve_dejavu(family_name: str, style: str) -> Path | None: - """Resolve only the explicitly requested DejaVu family via fontconfig.""" +def _resolve_exact_font(family_name: str, style: str) -> _FontFace | None: + """Resolve an exact installed family/style without accepting an alias.""" try: match = subprocess.run( [ - "fc-match", "-f", "%{family}\t%{style}\t%{file}\n", + "fc-match", + "-f", + "%{family}\t%{style}\t%{file}\t%{index}\n", f"{family_name}:style={style}", ], capture_output=True, @@ -93,27 +176,366 @@ def _resolve_dejavu(family_name: str, style: str) -> Path | None: return None if match.returncode != 0 or not match.stdout.strip(): return None - fields = match.stdout.strip().split("\t", 2) - if len(fields) != 3: + fields = match.stdout.strip().split("\t", 3) + if len(fields) != 4: 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(): + family, _actual_style, filename, raw_index = fields + # fc-match always returns *some* fallback. Only accept a genuine member of + # the requested family; an alias such as Calibri -> DejaVu is not exact. + actual_families = { + name.strip().casefold() for name in family.split(",") if name.strip() + } + if family_name.casefold() not in actual_families: return None path = Path(filename) - return path if path.is_file() else None + try: + index = int(raw_index or "0") + except ValueError: + return None + return _FontFace(path=path, index=index) if path.is_file() else None + + +def _split_css_families(value: str) -> list[str]: + """Split a CSS family list while respecting quotes and parentheses.""" + families: list[str] = [] + start = 0 + quote = "" + depth = 0 + for index, char in enumerate(value): + if quote: + if char == quote and (index == 0 or value[index - 1] != "\\"): + quote = "" + continue + if char in {"\"", "'"}: + quote = char + elif char == "(": + depth += 1 + elif char == ")" and depth: + depth -= 1 + elif char == "," and depth == 0: + families.append(value[start:index].strip().strip("\"'")) + start = index + 1 + families.append(value[start:].strip().strip("\"'")) + return [family for family in families if family] + + +def _sha256_file(path: Path) -> str: + """Hash file content without trusting path, size, or modification time.""" + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _subset_asset_names( + spec: _SourceSpec, + regular: _FontFace, + bold: _FontFace, + regular_sha256: str, + bold_sha256: str, + glyph_signature: str, + *, + subset_format_version: str, + generator_signature: str | None, +) -> tuple[str, str]: + """Return immutable, content-addressed names for one poster subset pair.""" + if subset_format_version == "1" and generator_signature is None: + identity_suffix = "-p1" + elif ( + re.fullmatch(r"[1-9]\d*", subset_format_version) + and subset_format_version != "1" + and generator_signature is not None + and re.fullmatch(r"[0-9a-f]{64}", generator_signature) + ): + identity_suffix = ( + f"-p{subset_format_version}-t{generator_signature}" + ) + else: + raise ValueError( + "font subset identity must be legacy untagged p1 or a tagged " + "numeric pN with a 64-hex generator signature" + ) + return ( + f"RS-{spec.asset_slug}-Regular-s{regular_sha256}-i{regular.index}" + f"-g{glyph_signature}{identity_suffix}{spec.suffix}", + f"RS-{spec.asset_slug}-Bold-s{bold_sha256}-i{bold.index}" + f"-g{glyph_signature}{identity_suffix}{spec.suffix}", + ) + + +def _subset_generator_signature() -> str: + """Fingerprint every loaded generator input that can change font bytes.""" + try: + import fontTools + except ImportError as exc: + raise RuntimeError( + "fonttools is required to identify compact portable poster fonts" + ) from exc + version = str(getattr(fontTools, "__version__", "")).strip() + if not version: + raise RuntimeError("loaded fonttools does not expose __version__") + identity = ( + f"{_SUBSET_GENERATOR_SCHEMA}\0fontTools={version}".encode("utf-8") + ) + return hashlib.sha256(identity).hexdigest() + + +def _data_attr(block: str, name: str) -> str | None: + match = re.search( + rf'data-{re.escape(name)}=["\']([^"\']+)["\']', + block, + flags=re.IGNORECASE, + ) + return match.group(1) if match else None + + +def _safe_bundled_face(out_fonts: Path, asset_name: str | None) -> _FontFace | None: + """Resolve a metadata asset name without allowing path traversal.""" + if not asset_name or Path(asset_name).name != asset_name: + return None + path = out_fonts / asset_name + if not path.is_file() or path.is_symlink() or path.stat().st_size <= 4_096: + return None + return _FontFace(path=path, index=0) + + +def _prior_face_assets( + block: str, + out_fonts: Path, +) -> tuple[_FontFace, _FontFace] | None: + """Read the 400/700 font URLs from an older fidelity block. + + Fidelity v4 and v7 did not record subset-source paths as metadata. Their + two ``@font-face`` URLs are therefore the authoritative custom faces whose + geometry must survive migration. Accept only direct ``assets/fonts`` + basenames; remote URLs and traversal never become local subset inputs. + """ + by_weight: dict[int, _FontFace] = {} + for face_block in re.findall( + r"@font-face\s*\{.*?\}", block, flags=re.IGNORECASE | re.DOTALL + ): + weight_match = re.search( + r"font-weight\s*:\s*(400|700)\b", face_block, flags=re.IGNORECASE + ) + url_match = re.search( + r"url\(\s*[\"']?([^\"')?#]+)(?:[?#][^\"')]*)?[\"']?\s*\)", + face_block, + flags=re.IGNORECASE, + ) + if not weight_match or not url_match: + continue + raw_url = url_match.group(1).replace("\\", "/") + parts = tuple(part for part in raw_url.split("/") if part not in {"", "."}) + if len(parts) != 3 or parts[:2] != ("assets", "fonts"): + continue + resolved = _safe_bundled_face(out_fonts, parts[2]) + if resolved is not None: + by_weight[int(weight_match.group(1))] = resolved + if 400 not in by_weight or 700 not in by_weight: + return None + return by_weight[400], by_weight[700] + + +def _font_face_is_readable(face: _FontFace) -> bool: + """Validate that a legacy asset is an actual readable font face.""" + try: + from fontTools.ttLib import TTFont + + collection = face.path.suffix.casefold() in {".ttc", ".otc"} + font = TTFont( + str(face.path), + fontNumber=face.index if collection else -1, + lazy=True, + ) + font.close() + return True + except Exception: + return False + + +def _install_immutable_subset(temporary: Path, target: Path) -> str: + """Install a content-addressed subset without replacing different bytes.""" + subset_sha256 = _sha256_file(temporary) + if target.is_file(): + if target.is_symlink() or _sha256_file(target) != subset_sha256: + raise RuntimeError( + f"content-addressed font collision at {target}; refusing overwrite" + ) + temporary.unlink() + return subset_sha256 + if target.exists(): + raise RuntimeError(f"font subset target is not a regular file: {target}") + os.replace(temporary, target) + return subset_sha256 + + +def _resolve_source( + spec: _SourceSpec, +) -> tuple[_FontFace, _FontFace, Path] | None: + regular = _resolve_exact_font(spec.family, spec.regular_style) + bold = _resolve_exact_font(spec.family, spec.bold_style) + license_path = next( + (path for path in spec.license_candidates if path.is_file()), None + ) + if regular is None or bold is None or license_path is None: + return None + return regular, bold, license_path + + +def _source_candidates(text: str, default_family: str) -> list[_SourceSpec]: + """Return the renderer's likely painted Latin faces, in priority order. + + The Chromium matrix on the production renderer has two stable classes: + the PowerPoint-safe sans stacks paint Noto Sans CJK SC, while serif stacks + paint DejaVu Serif. In particular, an explicit ``--font-cjk`` Noto entry + must never outrank the serif category for Cambria/Georgia/Times: it is a + fallback for missing CJK glyphs, not the face painting the Latin poster. + + Keep the serif category exclusive. For sans, retain declared-stack order + so the installed Noto face that Chromium actually paints remains + authoritative, with DejaVu Sans as the last licensed fallback. + """ + candidates: list[_SourceSpec] = [] + seen: set[str] = set() + + def append(spec: _SourceSpec) -> None: + key = spec.family.casefold() + if key not in seen: + candidates.append(spec) + seen.add(key) + + default = _SOURCE_SPECS[default_family.casefold()] + if default.family.casefold() == "dejavu serif": + # Do not fall through to a CJK sans merely because it is declared in + # the shared body stack. If the renderer lacks its serif source face, + # failing soft is safer than silently changing the poster category. + return [default] + + for variable in ("font-latin", "font-cjk"): + declaration = re.search( + rf"--{variable}\s*:\s*([^;]+);", + text, + flags=re.IGNORECASE, + ) + if not declaration: + continue + for family in _split_css_families(declaration.group(1)): + spec = _SOURCE_SPECS.get(family.casefold()) + if spec is not None: + append(spec) + append(default) + return candidates + + +def _source_category(spec: _SourceSpec) -> str: + """Return the browser typography category used for migration safety.""" + return "serif" if spec.family.casefold() == "dejavu serif" else "sans" + + +_CSS_HEX_ESCAPE = re.compile( + r"\\([0-9a-fA-F]{1,6})(?:\r\n|[\t\n\f\r ])?" +) + + +def _glyph_repertoire(text: str) -> str: + """Return every codepoint the delivered DOM/CSS can visibly produce.""" + expanded = html.unescape(text) + + def decode_css_hex(match: re.Match[str]) -> str: + value = int(match.group(1), 16) + if value == 0 or value > 0x10FFFF or 0xD800 <= value <= 0xDFFF: + return "\N{REPLACEMENT CHARACTER}" + return chr(value) + + expanded = _CSS_HEX_ESCAPE.sub(decode_css_hex, expanded) + repertoire = set(expanded) + # CSS text-transform may request glyphs absent from the literal source. + # Apply transforms to the whole string because Unicode mappings such as + # sharp-s -> SS can expand to more than one codepoint. + repertoire.update(expanded.lower()) + repertoire.update(expanded.upper()) + return "".join(sorted(repertoire)) + + +def _glyph_signature(repertoire: str) -> str: + """Fingerprint the exact expanded codepoint set baked into the fonts.""" + return hashlib.sha256(repertoire.encode("utf-8")).hexdigest() + + +def _build_subset_temp( + face: _FontFace, + target: Path, + text: str, +) -> Path: + """Build one hinted font subset beside its destination for atomic install.""" + try: + from fontTools import subset + from fontTools.ttLib import TTFont + except ImportError as exc: + raise RuntimeError( + "fonttools is required to create compact portable poster fonts" + ) from exc + + descriptor, raw = tempfile.mkstemp( + dir=target.parent, + prefix=".font-subset.", + suffix=f".font-next{target.suffix}", + ) + os.close(descriptor) + temporary = Path(raw) + font = None + try: + collection = face.path.suffix.casefold() in {".ttc", ".otc"} + font = TTFont( + str(face.path), + fontNumber=face.index if collection else -1, + lazy=False, + recalcTimestamp=False, + ) + options = subset.Options() + # Preserve all shaping features used by the source face. Do not use + # --no-hinting: removing hints changes glyph advances/rasterization. + # FontTools drops the legacy `kern` table by default even when its + # OpenType `kern` feature is retained. Chromium's installed-font and + # custom-webfont paths do not always choose the same one; keeping both + # removes one avoidable source of installed/custom path divergence. + options.layout_features = ["*"] + options.legacy_kern = True + options.drop_tables.append("FFTM") + subsetter = subset.Subsetter(options=options) + subsetter.populate(text=text) + subsetter.subset(font) + font.save(str(temporary)) + temporary.chmod(0o644) + if temporary.stat().st_size < 4_096: + raise RuntimeError(f"implausibly small font subset: {temporary}") + with temporary.open("rb") as stream: + os.fsync(stream.fileno()) + return temporary + except Exception: + temporary.unlink(missing_ok=True) + raise + finally: + if font is not None: + font.close() 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. + Preserve the first exact, open-licensed face that the declared browser + stack can actually use (normally Noto Sans CJK SC on the Linux renderer), + then subset that face to the poster's codepoint repertoire. The local + source family remains first in the browser-only override: this preserves + the source platform's rasterization exactly. A separately named bundled + face follows it for clients lacking that font, retaining the same source + metrics as closely as the browser's custom-font path permits without + falsely registering a webfont as Calibri/Arial/etc. + + The fidelity block records the originally requested family so html2pptx + can retain that native family in PowerPoint even though browser rendering + deliberately uses the measured source/fallback face. If the HTML already supplies an independent custom ``@font-face`` for the selected family, it is treated as intentionally licensed and left alone. @@ -138,7 +560,10 @@ def freeze_system_font_webfont(html_path: Path) -> bool: "the poster no longer declares --font-latin." ) return True - first_family = declaration.group(1).split(",", 1)[0].strip().strip("\"'") + declared_families = _split_css_families(declaration.group(1)) + if not declared_families: + return False + first_family = declared_families[0] selected = _PORTABLE_FAMILIES.get(first_family.casefold()) if selected is None: if text_without_fidelity == text: @@ -149,13 +574,7 @@ def freeze_system_font_webfont(html_path: Path) -> bool: 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" + requested_family, default_source_family = selected for face in re.findall( r"@font-face\s*\{.*?\}", @@ -180,98 +599,331 @@ def freeze_system_font_webfont(html_path: Path) -> bool: return True out_fonts = html_path.parent / "assets" / "fonts" + glyph_repertoire = _glyph_repertoire(text_without_fidelity) + glyph_signature = _glyph_signature(glyph_repertoire) prior = _FIDELITY_PATTERN.search(text) + prior_spec: _SourceSpec | None = None + prior_is_compatible = False + prior_version: str | None = None + prior_source_order: str | None = None + refit_version: str | None = None 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, - ) + prior_block = prior.group(0) + prior_requested = _data_attr(prior_block, "requested-family") + prior_source_family = _data_attr(prior_block, "source-family") + prior_version = _data_attr(prior_block, "fidelity-version") + prior_source_order = _data_attr(prior_block, "source-order") refit_tag = re.search( r']*>', - prior.group(0), + prior_block, flags=re.IGNORECASE, ) - refit_version_attr = ( - re.search( - r'data-fidelity-version=["\']([^"\']+)["\']', - refit_tag.group(0), - flags=re.IGNORECASE, - ) - if refit_tag + refit_version = ( + _data_attr(refit_tag.group(0), "fidelity-version") + if refit_tag else None + ) + prior_spec = ( + _SOURCE_SPECS.get(prior_source_family.casefold()) + if prior_source_family 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) + prior_is_compatible = bool( + prior_spec + and prior_requested + and prior_requested.casefold() == requested_family.casefold() + and _source_category(prior_spec) + == _source_category(_SOURCE_SPECS[default_source_family.casefold()]) + ) + + # Fidelity v4 made its bundled DejaVu files the actual painted faces. A v7 + # block can also explicitly retain that bundled-first contract. In both + # cases those exact bundle bytes—not whatever font happens to be installed + # on today's host—are the source for migration. A v7 local-first block is + # deliberately *not* treated as legacy custom geometry. + legacy_custom_source = bool( + prior_is_compatible + and prior_source_order is None + and prior_version not in {"7", _FIDELITY_VERSION} + ) + persisted_custom_source = bool( + prior_is_compatible + and prior_source_order + and prior_source_order.casefold() == "bundled-first" + ) + use_prior_bundle = legacy_custom_source or persisted_custom_source + + resolved_source: tuple[ + _SourceSpec, _FontFace, _FontFace, Path, str, str | None, str | None + ] | None = None + if use_prior_bundle and prior and prior_spec: + source_regular_asset = _data_attr( + prior.group(0), "source-regular-asset" + ) + source_bold_asset = _data_attr(prior.group(0), "source-bold-asset") + regular = _safe_bundled_face(out_fonts, source_regular_asset) + bold = _safe_bundled_face(out_fonts, source_bold_asset) + if regular is None or bold is None: + legacy_faces = _prior_face_assets(prior.group(0), out_fonts) + if legacy_faces is not None: + regular, bold = legacy_faces + source_regular_asset = regular.path.name + source_bold_asset = bold.path.name + existing_license = out_fonts / prior_spec.license_name + license_path = ( + existing_license + if existing_license.is_file() + and not existing_license.is_symlink() + and existing_license.stat().st_size > 100 + else next( + ( + path for path in prior_spec.license_candidates + if path.is_file() + ), + None, + ) ) 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 + regular is not None + and bold is not None + and license_path is not None + and _font_face_is_readable(regular) + and _font_face_is_readable(bold) ): + resolved_source = ( + prior_spec, + regular, + bold, + license_path, + "bundled", + source_regular_asset, + source_bold_asset, + ) + else: + # Keeping the valid prior block is safer than silently swapping its + # geometry for a host font when a legacy bundle is incomplete. + eprint( + "[paper2poster] WARN: could not validate the bundled source " + f"faces for legacy {requested_family}; preserving the prior " + "font-fidelity block unchanged." + ) 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: + if resolved_source is None: + # If the requested proprietary family is genuinely installed, it is + # the source platform face. We cannot redistribute it under this open- + # font contract, so do not replace it with a different metric face. + if _resolve_exact_font(requested_family, "Regular") is not None: + if text_without_fidelity != text: + html_path.write_text(text_without_fidelity, encoding="utf-8") + eprint( + "[paper2poster] removed stale portable-font fidelity block: " + f"the renderer has native {requested_family}." + ) + return True + eprint( + f"[paper2poster] WARN: {requested_family} is not portable on " + "this host: its native face is installed but cannot be copied by " + "this open-font bundler; continuing with the platform font stack." + ) + return False + + for candidate in _source_candidates( + text_without_fidelity, default_source_family + ): + resolved = _resolve_source(candidate) + if resolved is not None: + regular, bold, license_path = resolved + resolved_source = ( + candidate, + regular, + bold, + license_path, + "local", + None, + None, + ) + break + if resolved_source 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." + "this host: no licensed face from its rendered fallback stack " + "could be located; continuing with the platform font stack." ) return False + ( + source_spec, + regular, + bold, + license_path, + source_origin, + source_regular_asset, + source_bold_asset, + ) = resolved_source + source_family = source_spec.family + fallback_family = f"RS {source_family}" + bundled_first = source_origin == "bundled" + source_order = "bundled-first" if bundled_first else "local-first" + browser_families = ( + f'"{fallback_family}", "{source_family}"' + if bundled_first + else f'"{source_family}", "{fallback_family}"' + ) + generic_family = ( + "serif" if default_source_family.casefold() == "dejavu serif" + else "sans-serif" + ) + regular_source_sha256 = _sha256_file(regular.path) + bold_source_sha256 = _sha256_file(bold.path) 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'''