diff --git a/ResearchStudio-Reel/skills/paper2poster/SKILL.md b/ResearchStudio-Reel/skills/paper2poster/SKILL.md index 159d4e9..22b7378 100644 --- a/ResearchStudio-Reel/skills/paper2poster/SKILL.md +++ b/ResearchStudio-Reel/skills/paper2poster/SKILL.md @@ -322,7 +322,7 @@ The first three are **full themes** (they retheme the header + page background); v1–v4 each render the conference **logo** when `assets/logos/_venue.png` exists (Step 6 `fetch_conf_logo.py`), else a text venue/year fallback in the same chip; v5 uses a text venue badge by design. All work for **2–6 institutions** (empty `LOGO_n` slots auto-hide). Logos are sized to **fill** their zone (single venue logo + a 2-row institution grid), and the logo chips **theme to the chosen style** via `--tb-chip-bg` / `--tb-chip-shadow`: solid → flat white chip (no shadow) on the accent band; framed → flat white chip on the white card; **simple → transparent chip (no frame), logos sit directly on the white header**. Default: pass **`--header random`** — `compose_poster.py` picks one DETERMINISTICALLY from the configured seed from **all five (v1–v5)**; v5 fills its own titlebar QR via `{{HDR_QR_*}}` (see the QR contract). Override via `POSTER_HEADER={v1|v2|v3|v4|v5}`. -**Default font policy:** the poster body defaults to **Arial**, and `--font-latin` may select any of the 8 Mac/Windows PowerPoint-safe family names: `Calibri | Aptos | Cambria | Arial | "Times New Roman" | Verdana | Georgia | "Trebuchet MS"`. These proprietary OS fonts are not reliably installed on the Linux renderer or every HTML client. Before browser geometry is measured, `check_poster.py` / `render_poster.py` therefore freeze their **browser** rendering to a self-contained, open-licensed DejaVu face of the same category (Sans or Serif), stored under `assets/fonts/` with its license but exposed under the requested CSS family name. This makes fill measurements, standalone HTML, PDF, and PNG deterministic while html2pptx continues emitting the requested native family name into `.pptx`; the deck can still have small metric differences because PowerPoint uses the real OS font. Literal Georgia/Arial/etc. in every artifact requires separately licensed webfont files. The optional `POSTER_FONT` env var, when set, carries the same choice — but the default lives in the templates. To use **Inter** (the bundled webfont — more editorial), flip `--font-latin` back to `Inter, …` *and* run the html2pptx Inter embed step so the `.pptx` ships the font; the 4 Inter `@font-face` blocks stay defined in every template for exactly this override. +**Default font policy:** the poster body defaults to **Arial**, and `--font-latin` may select any of the 8 Mac/Windows PowerPoint-safe family names: `Calibri | Aptos | Cambria | Arial | "Times New Roman" | Verdana | Georgia | "Trebuchet MS"`. These proprietary OS fonts are not reliably installed on the Linux renderer or every HTML client. Before browser geometry is measured, `check_poster.py` / `render_poster.py` freeze browser rendering to a compact, self-contained open-licensed subset under `assets/fonts/`, while html2pptx continues emitting the requested native family into `.pptx`. Arial uses bundled Arimo Regular/Bold because Arimo is metrically compatible with Arial; the other selections preserve an open DejaVu or Noto face from the renderer's actual fallback category. A fresh Arial poster, an old Arial/DejaVu fidelity block, or a local-first Arimo block is migrated to packaged-first Arimo through a directional media gate: media identity must stay fixed, every research figure must satisfy the absolute 90% minimum after migration, and no axis may newly exceed 101%. After that one repair, the Arimo layout becomes the strict source baseline for durable HTML, PDF, PNG, and Reel checks. The optional `POSTER_FONT` env var carries the same choice, but the default lives in the templates. To use **Inter**, flip `--font-latin` back to `Inter, …` and run the html2pptx Inter embed step so the `.pptx` ships the font; the 4 Inter `@font-face` blocks stay defined in every template for exactly this override. **Composition catalog (landscape and portrait):** diff --git a/ResearchStudio-Reel/skills/paper2poster/assets/fonts/Arimo-Bold.ttf b/ResearchStudio-Reel/skills/paper2poster/assets/fonts/Arimo-Bold.ttf new file mode 100644 index 0000000..d901b99 Binary files /dev/null and b/ResearchStudio-Reel/skills/paper2poster/assets/fonts/Arimo-Bold.ttf differ diff --git a/ResearchStudio-Reel/skills/paper2poster/assets/fonts/Arimo-LICENSE.txt b/ResearchStudio-Reel/skills/paper2poster/assets/fonts/Arimo-LICENSE.txt new file mode 100644 index 0000000..e9dbd01 --- /dev/null +++ b/ResearchStudio-Reel/skills/paper2poster/assets/fonts/Arimo-LICENSE.txt @@ -0,0 +1,155 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: Noto +Upstream-Contact: https://github.com/googlei18n/noto-fonts/issues + http://groups.google.com/group/noto-font +Source: https://github.com/googlei18n/noto-fonts + +Files: * +Copyright: + 2010,2012-2020, Google Inc. + 2015-2020, Google LLC. +License-Grant: + This Font Software is licensed under the SIL Open Font License, + Version 1.1. +License: OFL-1.1 + +Files: + debian/* +Copyright: + 2013-2020, Jonas Smedegaard + 2020, Purism, SPC + 2013, Vasudev Kamath +License-Grant: + This program is free software; + you can redistribute it and/or modify it + under the terms of the GNU General Public License + as published by the Free Software Foundation; + either version 3, or (at your option) any later version. +License: GPL-3+ + +Files: + debian/NotoMono/NotoMono-Regular.ttf +Copyright: + 2007, Google Inc. +License-Grant: + This Font Software is licensed under the SIL Open Font License, + Version 1.1. +License: OFL-1.1 + +License: OFL-1.1 + PREAMBLE + . + The goals of the Open Font License (OFL) are to stimulate + worldwide development of collaborative font projects, + to support the font creation efforts + of academic and linguistic communities, + and to provide a free and open framework + in which fonts may be shared and improved + in partnership with others. + . + The OFL allows the licensed fonts to be used, studied, + modified and redistributed freely + as long as they are not sold by themselves. + The fonts, including any derivative works, + can be bundled, embedded, + redistributed and/or sold with any software + provided that any reserved names are not used + by derivative works. + The fonts and derivatives, however, + cannot be released under any other type of license. + The requirement for fonts to remain under this license + does not apply to any document + created using the fonts or their derivatives. + . + DEFINITIONS + . + "Font Software" refers to the set of files + released by the Copyright Holder(s) under this license + and clearly marked as such. + This may include source files, build scripts and documentation. + . + "Reserved Font Name" refers to any names specified as such + after the copyright statement(s). + . + "Original Version" refers to the collection + of Font Software components + as distributed by the Copyright Holder(s). + . + "Modified Version" refers to any derivative + made by adding to, deleting, or substituting -- + in part or in whole -- + any of the components of the Original Version, + by changing formats + or by porting the Font Software to a new environment. + . + "Author" refers to any designer, engineer, programmer, + technical writer or other person + who contributed to the Font Software. + . + PERMISSION & CONDITIONS + . + Permission is hereby granted, free of charge, + to any person obtaining a copy of the Font Software, + to use, study, copy, merge, embed, modify, redistribute, + and sell modified and unmodified copies of the Font Software, + subject to the following conditions: + . + 1) Neither the Font Software nor any of its individual components, + in Original or Modified Versions, + may be sold by itself. + . + 2) Original or Modified Versions of the Font Software + may be bundled, redistributed and/or sold with any software, + provided that each copy contains the above copyright notice + and this license. + These can be included either as stand-alone text files, + human-readable headers or in the appropriate + machine-readable metadata fields within text or binary files + as long as those fields can be easily viewed by the user. + . + 3) No Modified Version of the Font Software may use + the Reserved Font Name(s) + unless explicit written permission is granted + by the corresponding Copyright Holder. + This restriction only applies to the primary font name + as presented to the users. + . + 4) The name(s) of the Copyright Holder(s) + or the Author(s) of the Font Software + shall not be used to promote, + endorse or advertise any Modified Version, + except to acknowledge the contribution(s) + of the Copyright Holder(s) and the Author(s) + or with their explicit written permission. + . + 5) The Font Software, + modified or unmodified, in part or in whole, + must be distributed entirely under this license, + and must not be distributed under any other license. + The requirement for fonts to remain under this license + does not apply to any document + created using the Font Software. + . + TERMINATION + . + This license becomes null and void + if any of the above conditions are not met. + . + DISCLAIMER + . + THE FONT SOFTWARE IS PROVIDED "AS IS", + WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, + INCLUDING BUT NOT LIMITED TO + ANY WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT + OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. + IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE + FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + INCLUDING ANY GENERAL, SPECIAL, INDIRECT, + INCIDENTAL, OR CONSEQUENTIAL DAMAGES, + WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, + ARISING FROM, OUT OF THE USE OR INABILITY TO USE + THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. + +License: GPL-3+ +License-Reference: /usr/share/common-licenses/GPL-3 diff --git a/ResearchStudio-Reel/skills/paper2poster/assets/fonts/Arimo-Regular.ttf b/ResearchStudio-Reel/skills/paper2poster/assets/fonts/Arimo-Regular.ttf new file mode 100644 index 0000000..42ad964 Binary files /dev/null and b/ResearchStudio-Reel/skills/paper2poster/assets/fonts/Arimo-Regular.ttf differ 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..5d497f3 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 @@ -193,17 +194,23 @@ def best_arrangement(logos, qrs, zone_w, zone_h, pad=0.0, max_rows=3, gap_frac=0 const cs=getComputedStyle(z); const px=n=>parseFloat(n)||0; const insetX=px(cs.paddingLeft)+px(cs.paddingRight)+px(cs.borderLeftWidth)+px(cs.borderRightWidth); const insetY=px(cs.paddingTop)+px(cs.paddingBottom)+px(cs.borderTopWidth)+px(cs.borderBottomWidth); - // IDEMPOTENCY: a prior bake collapses the zone to its packed-content height, so a - // re-bake would then only ever fit ONE row. Remember the ORIGINAL available height on - // the first bake and reuse it, so re-running fit_logos re-packs against the real header - // space (e.g. 8 logos -> 2 rows) instead of the shrunken strip. - // The zone's TRUE available height = the largest we've ever seen. On a re-bake - // the zone collapses to packed-content height (so trust the cached pre-bake - // height); when the TEMPLATE grew the zone (e.g. v4 stacked layout), the fresh - // box is taller than a stale cache -> trust the fresh box. max() is correct both - // ways and self-heals a stale data-lf-h0 from an older layout. + // IDEMPOTENCY: a prior bake can collapse a content-sized zone on either axis. + // In particular v4's auto-width grid track follows the packed logo width, so + // measuring only the new border box makes every re-bake shrink the track and + // logos again. Remember the ORIGINAL available width and height on the first + // bake and reuse them, so re-running fit_logos searches the same geometry. + // The zone's TRUE available size is the largest we've ever seen. When a + // TEMPLATE genuinely grows the zone, the fresh box exceeds a stale cache and + // max() self-heals it. + const w0=z.getAttribute('data-lf-w0'); const h0=z.getAttribute('data-lf-h0'); + // A legacy completed bake recorded only h0. Its auto-sized grid track has + // already collapsed around the packed rows, so that live width is not the + // original search budget. Repacking against it would change rows again. + if(!w0 && h0 && z.hasAttribute('data-lf-fill')) return; + let W = w0 ? Math.max(r.width, +w0) : r.width; let H = h0 ? Math.max(r.height, +h0) : r.height; + z.setAttribute('data-lf-w0', String(W)); z.setAttribute('data-lf-h0', String(H)); const isQR=(im)=>!!im.closest('.qr-tile, .qr-block, .qr') || /assets\/qr\//.test(im.getAttribute('src')||''); const imgs=[...z.querySelectorAll('img')].filter(im=>im.getAttribute('src')&&!im.src.includes('{{')); @@ -213,8 +220,8 @@ def best_arrangement(logos, qrs, zone_w, zone_h, pad=0.0, max_rows=3, gap_frac=0 // If placeholder substitution removed every , bake() still needs a // target into which it can auto-complete those manifest-approved marks. if(!logos.length && !qrImgs.length && !includeEmpty) return; - out.push({sel, idx:[...document.querySelectorAll(sel)].indexOf(z), W:r.width, H:H, - innerW:Math.max(1,r.width-insetX), innerH:Math.max(1,H-insetY), + out.push({sel, idx:[...document.querySelectorAll(sel)].indexOf(z), W:W, H:H, + innerW:Math.max(1,W-insetX), innerH:Math.max(1,H-insetY), logos:logos.map(im=>({src:im.getAttribute('src'), natW:im.naturalWidth, natH:im.naturalHeight})), qrs:qrImgs.map(im=>{const fig=im.closest('figure, .qr-tile, .qr'); const lb=fig?(fig.querySelector('figcaption, .qr-lbl, .qr-label, span')||{}).textContent:''; @@ -422,11 +429,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 +754,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/check_poster.py b/ResearchStudio-Reel/skills/paper2poster/scripts/check_poster.py index 14278ba..0f607f8 100755 --- a/ResearchStudio-Reel/skills/paper2poster/scripts/check_poster.py +++ b/ResearchStudio-Reel/skills/paper2poster/scripts/check_poster.py @@ -319,8 +319,9 @@ def main(argv: list[str] | None = None) -> int: # Geometry/fill measurements must use the same deterministic browser face # that final rendering will bake into the standalone poster bundle. In # particular, Linux naturally resolves Arial/Times New Roman to Nimbus, - # while the portable final mapping uses DejaVu; injecting only at final - # render would change wrapping *after* the strict fill gate passed. + # while the portable final mapping uses Arimo for Arial and an open DejaVu + # or Noto source for the remaining OS-family choices; injecting only at + # final render would change wrapping *after* the strict fill gate passed. if args.cmd in {"pack", "slack", "polish", "autofit"}: try: freeze_system_font_webfont(Path(args.html).resolve()) diff --git a/ResearchStudio-Reel/skills/paper2poster/scripts/render_poster.py b/ResearchStudio-Reel/skills/paper2poster/scripts/render_poster.py index c73e785..e004d3d 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. @@ -33,6 +36,7 @@ from utils import render as _render # noqa: E402 from utils.cli_common import eprint as _eprint, import_playwright # noqa: E402 from utils.font_fidelity import ( # noqa: E402 + font_metric_migration_kind, freeze_system_font_webfont, managed_font_asset_names, ) @@ -47,6 +51,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 +138,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 +148,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 +180,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 +203,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 +328,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 +913,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 +1045,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 +1078,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 +1178,270 @@ def _capture_expand_snapshot(page) -> dict: return result +def _card_media_fills(snapshot: dict) -> list[dict]: + """Measure every visible card media item against its owning section.""" + sections = { + str(section.get("key", "")): section + for section in snapshot.get("sections", []) + if isinstance(section, dict) + } + rows = [] + for media in snapshot.get("media", []): + if not isinstance(media, dict): + continue + section = sections.get(str(media.get("sectionKey", ""))) + rect = section.get("rect") if section else None + section_w = float((rect or {}).get("w", 0.0)) + section_h = float((rect or {}).get("h", 0.0)) + if section_w <= 0 or section_h <= 0: + continue + painted_w = float(media.get("paintedW", 0.0)) + painted_h = float(media.get("paintedH", 0.0)) + rows.append({ + "key": str(media.get("key", "")), + "sid": str(media.get("sid", "")), + "sectionKey": str(media.get("sectionKey", "")), + "kind": str(media.get("kind", "")), + "src": str(media.get("src", "")), + "currentSrc": str(media.get("currentSrc", "")), + "isResearchFigure": bool(media.get("isResearchFigure")), + "fit": str(media.get("fit", "")), + "nw": int(media.get("nw", 0) or 0), + "nh": int(media.get("nh", 0) or 0), + "fill": max(painted_w / section_w, painted_h / section_h), + "widthFill": painted_w / section_w, + "heightFill": painted_h / section_h, + }) + return rows + + +def _font_migration_failures( + before_snapshot: dict, + after_snapshot: dict, + *, + floor: float = _EXPAND_FIG_MIN_RATIO, + ceiling: float = _EXPAND_FIG_MAX_RATIO, +) -> list[dict]: + """Return media regressions caused by an intentional font migration. + + Unlike the normal font-freeze gate, an old Arial/DejaVu bundle is expected + to reflow when it is corrected to metric-compatible Arimo. Permit that + migration only when media identity is stable, valid figures do not cross + below 90%, and no axis newly overflows 101%. The caller separately applies + the absolute post-migration research-figure floor before resetting baseline. + """ + before = _card_media_fills(before_snapshot) + after = _card_media_fills(after_snapshot) + after_by_key = {str(item.get("key", "")): item for item in after} + failures = [] + for original in before: + current = after_by_key.get(str(original.get("key", ""))) + reasons = [] + if current is None: + reasons.append("media disappeared") + else: + for identity in ( + "sectionKey", "sid", "kind", "src", "currentSrc", + "isResearchFigure", "fit", "nw", "nh", + ): + if original.get(identity) != current.get(identity): + reasons.append(f"{identity} changed") + before_fill = float(original.get("fill", 0.0)) + current_fill = float(current.get("fill", 0.0)) + if before_fill + 1e-6 >= floor and current_fill + 1e-6 < floor: + reasons.append( + f"fill dropped below floor ({before_fill:.2%} -> " + f"{current_fill:.2%})" + ) + for axis in ("widthFill", "heightFill"): + before_axis = float(original.get(axis, 0.0)) + current_axis = float(current.get(axis, 0.0)) + if before_axis <= ceiling and current_axis > ceiling: + reasons.append( + f"{axis} overflowed ({before_axis:.2%} -> " + f"{current_axis:.2%})" + ) + if reasons: + failures.append({ + "before": original, + "after": current, + "reasons": reasons, + }) + before_keys = {str(item.get("key", "")) for item in before} + for current in after: + if str(current.get("key", "")) not in before_keys: + failures.append({ + "before": None, + "after": current, + "reasons": ["media appeared"], + }) + return failures + + +def _validate_source_geometry( + page, + baseline: dict, + *, + allow_scan_reflow: bool = False, +) -> list[str]: + """Compare the accepted cold page with its authoritative 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 +1449,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 +1513,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 +1650,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 +1700,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 +1719,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 +1839,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 +1873,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 +1906,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 +1927,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 +2046,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 +2099,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 +2149,63 @@ 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 candidate", ) + 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. Routine freezes must preserve + # full geometry. Explicit legacy Arial fallback -> packaged Arimo + # corrections are allowed to reflow only through their directional + # media gate, after which the accepted Arimo page becomes the + # authoritative durable baseline. + _font_migration_kind = font_metric_migration_kind(html_path) + _font_changed = freeze_system_font_webfont(html_path) + if _font_migration_kind and not _font_changed: + _eprint( + "[render_preview] ERROR: required font migration was not " + f"applied: {_font_migration_kind}." + ) + browser.close() + return 2 + 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 +2238,138 @@ 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." + try: + settle = _render.settle_page( + page, + mathjax_timeout_ms=args.mathjax_timeout_ms, + settle_ms=1500, ) - elif settle.mathjax_status == "error": + 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( - f"[render_preview] WARN: MathJax error: " - f"{ascii_safe(settle.mathjax_error)}" + "[render_preview] WARN: initial settle failed: " + f"{ascii_safe(exc)}; continuing without optional expand/scan " + "mutations." ) - if settle.mathjax_intended and settle.tex_without_mathjax: + 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. The one + # exception is an explicit Arial fallback -> packaged Arimo repair: + # validate that directionally, then establish the corrected page as + # the baseline. + if _font_migration_kind and not _initial_settle_complete: _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] ERROR: could not settle both sides of " + f"required font migration {_font_migration_kind}; refusing " + "to promote an unvalidated HTML/PDF/PNG transaction." ) + browser.close() + return 2 + if _initial_settle_complete: + try: + if _font_migration_kind: + _post_migration_snapshot = _capture_expand_snapshot(page) + _font_migration_gate_failures = _font_migration_failures( + _source_baseline, _post_migration_snapshot, + ) + _font_migration_floor_failures = ( + _validate_final_figure_fill( + page, + enforce_minimum=enforce_figure_floor, + ) + ) + _pre_scan_source_geometry_failures = [] + else: + _post_migration_snapshot = None + _font_migration_gate_failures = [] + _font_migration_floor_failures = [] + _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 _font_migration_gate_failures: + _details = [] + for _failure in _font_migration_gate_failures: + _before = _failure.get("before") or {} + _after = _failure.get("after") or {} + _label = ( + _before.get("sid") or _after.get("sid") + or _before.get("key") or _after.get("key") + ) + _details.append( + f"{_label}: " + + ", ".join(_failure.get("reasons") or []) + ) + _eprint( + "[render_preview] ERROR: font migration changed card media " + "outside its accepted identity/fill band: " + + ascii_safe("; ".join(_details)) + ) + browser.close() + return 2 + if _font_migration_floor_failures: + _eprint( + "[render_preview] ERROR: font migration did not produce a " + "valid research-figure layout: " + + ascii_safe("; ".join(_font_migration_floor_failures)) + ) + browser.close() + return 2 + if _font_migration_kind and _post_migration_snapshot is not None: + _source_baseline = _post_migration_snapshot + print( + "[render_preview] accepted directional font migration " + f"{_font_migration_kind}; the settled Arimo layout is now " + "the authoritative durable baseline." + ) + 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 +2392,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 +2428,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 +2451,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 +2573,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 +2592,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." ) - if _expand_failed and not _durable_ready: + _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 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 +2636,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 +2681,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 +2741,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 +2798,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 +2867,7 @@ def _render_staged( _fresh_page, _survivors, _expand_baseline, + enforce_figure_floor=enforce_figure_floor, ) except Exception as exc: _reason = ( @@ -1622,7 +2904,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 +2968,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 +3019,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..a1148ef 100644 --- a/ResearchStudio-Reel/skills/paper2poster/scripts/utils/font_fidelity.py +++ b/ResearchStudio-Reel/skills/paper2poster/scripts/utils/font_fidelity.py @@ -3,29 +3,121 @@ 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 = "8" +_SUBSET_FORMAT_VERSION = "2" +_SUBSET_GENERATOR_SCHEMA = "paper2poster-font-subset-p2" +_BUNDLED_FONT_DIR = Path(__file__).resolve().parents[2] / "assets" / "fonts" + + +@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, ...] + bundled_regular: Path | None = None + bundled_bold: Path | None = None + bundled_license: Path | None = None + + +@dataclass(frozen=True) +class _FontFace: + path: Path + index: int + + +_SOURCE_SPECS = { + "arimo": _SourceSpec( + family="Arimo", + regular_style="Regular", + bold_style="Bold", + asset_slug="Arimo", + suffix=".ttf", + css_format="truetype", + license_name="RS-Arimo-LICENSE.txt", + license_candidates=( + Path("/usr/share/doc/fonts-croscore/copyright"), + Path("/usr/share/licenses/ttf-croscore/LICENSE"), + Path("/usr/share/licenses/croscore-fonts/LICENSE"), + ), + bundled_regular=_BUNDLED_FONT_DIR / "Arimo-Regular.ttf", + bundled_bold=_BUNDLED_FONT_DIR / "Arimo-Bold.ttf", + bundled_license=_BUNDLED_FONT_DIR / "Arimo-LICENSE.txt", + ), + "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"), "aptos": ("Aptos", "DejaVu Sans"), - "arial": ("Arial", "DejaVu Sans"), + # Arimo is metrically compatible with Arial. DejaVu Sans is not: on the + # MSRA 117 poster it refit a 95.20%-wide figure to 79.16%. + "arial": ("Arial", "Arimo"), "verdana": ("Verdana", "DejaVu Sans"), "trebuchet ms": ("Trebuchet MS", "DejaVu Sans"), "cambria": ("Cambria", "DejaVu Serif"), @@ -41,18 +133,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 +180,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 +199,452 @@ 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 = ( + _FontFace(spec.bundled_regular, 0) + if spec.bundled_regular is not None + and spec.bundled_regular.is_file() + else _resolve_exact_font(spec.family, spec.regular_style) + ) + bold = ( + _FontFace(spec.bundled_bold, 0) + if spec.bundled_bold is not None and spec.bundled_bold.is_file() + else _resolve_exact_font(spec.family, spec.bold_style) + ) + license_path = ( + spec.bundled_license + if spec.bundled_license is not None and spec.bundled_license.is_file() + else 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. + + Arial deliberately starts with bundled Arimo because it is metrically + compatible. Other PowerPoint-safe sans stacks may 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] + + # A metric-compatible default is an explicit contract, not merely the last + # fallback. Do not let a declared CJK face outrank Arimo for Arial. + if default.family.casefold() == "arimo": + 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" + + +def font_metric_migration_kind(html_path: Path) -> str | None: + """Return the intentional initial/legacy font migration for ``html_path``. + + Fidelity v4 and early v7 bundles could persist DejaVu Sans under an Arial + request. Later local-first bundles could record Arimo while still letting + a client-installed face outrank the portable subset. Replacing either + source with packaged-first, metric-compatible Arimo can intentionally + reflow the DOM, so the renderer validates it with a directional media gate + before establishing its strict durable baseline. + """ + text = Path(html_path).read_text(encoding="utf-8", errors="ignore") + declaration = re.search( + r"--font-latin\s*:\s*([^;]+);", text, flags=re.IGNORECASE, + ) + if not declaration: + return None + families = _split_css_families(declaration.group(1)) + if not families: + return None + selected = _PORTABLE_FAMILIES.get(families[0].casefold()) + prior = _FIDELITY_PATTERN.search(text) + if selected is None: + return None + requested_family, target_source_family = selected + if ( + requested_family.casefold() != "arial" + or target_source_family.casefold() != "arimo" + ): + return None + text_without_fidelity = _FIDELITY_PATTERN.sub("", text) + for face in re.findall( + r"@font-face\s*\{.*?\}", + text_without_fidelity, + flags=re.IGNORECASE | re.DOTALL, + ): + family = re.search( + r"font-family\s*:\s*([^;}]+)", face, flags=re.IGNORECASE, + ) + if ( + family + and family.group(1).strip().strip("\"'").casefold() == "arial" + ): + return None + if prior is None: + return "arial-to-arimo-v1" + prior_requested = _data_attr(prior.group(0), "requested-family") + prior_source_family = _data_attr(prior.group(0), "source-family") + prior_source_order = _data_attr(prior.group(0), "source-order") + if ( + prior_requested + and prior_requested.casefold() == "arial" + and prior_source_family + and prior_source_family.casefold() != "arimo" + ): + return "arial-to-arimo-v1" + if ( + prior_requested + and prior_requested.casefold() == "arial" + and prior_source_family + and prior_source_family.casefold() == "arimo" + and prior_source_order + and prior_source_order.casefold() == "local-first" + ): + return "arimo-local-to-packaged-v1" + return None + + +_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. + Arial uses bundled, metrically compatible Arimo. Other selections preserve + the first exact, open-licensed face that their declared browser stack can + actually use, then subset that face to the poster's codepoint repertoire. + The local source family remains first in the browser-only override when it + is authoritative. 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 +669,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 +683,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 +708,352 @@ 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" + ) + migration_kind = font_metric_migration_kind(html_path) + use_prior_bundle = ( + legacy_custom_source or persisted_custom_source + ) and migration_kind is None + + 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 ( + default_source_family.casefold() != "arimo" + and _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 + packaged_source = bool( + candidate.bundled_regular is not None + and candidate.bundled_bold is not None + and candidate.bundled_license is not None + and regular.path == candidate.bundled_regular + and bold.path == candidate.bundled_bold + and license_path == candidate.bundled_license + ) + resolved_source = ( + candidate, + regular, + bold, + license_path, + "packaged" if packaged_source else "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" + packaged_first = source_origin == "packaged" + portable_first = bundled_first or packaged_first + source_order = ( + "bundled-first" if bundled_first + else "packaged-first" if packaged_first + else "local-first" + ) + browser_families = ( + f'"{fallback_family}", "{source_family}"' + if portable_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'''