diff --git a/ResearchStudio-Reel/skills/paper2reel/scripts/build_poster_slides_view.py b/ResearchStudio-Reel/skills/paper2reel/scripts/build_poster_slides_view.py index 2e9881b..5669a0c 100644 --- a/ResearchStudio-Reel/skills/paper2reel/scripts/build_poster_slides_view.py +++ b/ResearchStudio-Reel/skills/paper2reel/scripts/build_poster_slides_view.py @@ -9,14 +9,19 @@ from __future__ import annotations import argparse +import hashlib import html import json import re import shutil +import struct +import subprocess import tarfile +import tempfile import urllib.request import zipfile from datetime import datetime, timezone +from html.parser import HTMLParser from pathlib import Path from typing import Any @@ -79,6 +84,123 @@ ), ) +HISTORY_DENSITY_MAX_SIDE = 12_000 +HISTORY_DENSITY_MAX_PIXELS = 64_000_000 +HISTORY_DENSITY_MAX_MAE = 6.0 +HISTORY_DENSITY_MAX_RMS = 14.0 +HISTORY_DENSITY_TILE_SIZE = 32 +HISTORY_DENSITY_MAX_TILE_RMS = 30.0 +HISTORY_DENSITY_DETAIL_TILE_SIZE = 128 +HISTORY_DENSITY_DETAIL_TILE_COUNT = 12 +HISTORY_DENSITY_DETAIL_MARGIN = 4 +HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_MAE = 2.0 +HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_RMS = 8.0 + +HTML_VOID_ELEMENTS = { + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +} + + +class HistoryPixelLayerContractParser(HTMLParser): + """Locate the historical layer and verify that its own ancestor is a host.""" + + def __init__(self, source_text: str) -> None: + super().__init__(convert_charrefs=True) + self.stack: list[tuple[str, bool]] = [] + self.host_depth = 0 + self.layer_contracts: list[tuple[bool, bool, bool, int, int, str]] = [] + self.line_offsets = [0] + self.line_offsets.extend( + match.end() for match in re.finditer(r"\n", source_text) + ) + + @staticmethod + def attribute_values( + attrs: list[tuple[str, str | None]], + name: str, + ) -> list[str]: + return [ + value or "" + for key, value in attrs + if key.lower() == name + ] + + def record_start_tag( + self, + tag: str, + attrs: list[tuple[str, str | None]], + *, + push: bool, + ) -> None: + normalized_tag = tag.lower() + ids = self.attribute_values(attrs, "id") + if "poster-history-pixel-layer" in ids: + line, column = self.getpos() + raw_tag = self.get_starttag_text() or "" + start = self.line_offsets[line - 1] + column + self.layer_contracts.append( + ( + normalized_tag == "img", + self.host_depth > 0, + ids == ["poster-history-pixel-layer"], + start, + start + len(raw_tag), + raw_tag, + ) + ) + host_values = self.attribute_values( + attrs, + "data-poster-history-pixel-host", + ) + is_host = host_values == ["1"] + if push and normalized_tag not in HTML_VOID_ELEMENTS: + self.stack.append((normalized_tag, is_host)) + if is_host: + self.host_depth += 1 + + def handle_starttag( + self, + tag: str, + attrs: list[tuple[str, str | None]], + ) -> None: + self.record_start_tag(tag, attrs, push=True) + + def handle_startendtag( + self, + tag: str, + attrs: list[tuple[str, str | None]], + ) -> None: + self.record_start_tag(tag, attrs, push=False) + + def handle_endtag(self, tag: str) -> None: + normalized_tag = tag.lower() + matching_index = next( + ( + index + for index in range(len(self.stack) - 1, -1, -1) + if self.stack[index][0] == normalized_tag + ), + None, + ) + if matching_index is None: + return + removed = self.stack[matching_index:] + del self.stack[matching_index:] + self.host_depth -= sum(1 for _, is_host in removed if is_host) + CANONICAL_DEFAULT_MAP: dict[str, list[int]] = { "title": [1], @@ -565,6 +687,10 @@ if (posterHookState && posterHookState.observer) { try { posterHookState.observer.disconnect(); } catch(e) {} } + if (posterHookState && posterHookState.doc && posterHookState.rasterSyncHandler) { + try { posterHookState.doc.defaultView.removeEventListener('resize', posterHookState.rasterSyncHandler); } catch(e) {} + try { posterHookState.doc.removeEventListener('scroll', posterHookState.rasterSyncHandler, true); } catch(e) {} + } posterHookState = null; } function schedulePosterToolsRetry(delayMs) { @@ -608,6 +734,101 @@ const id = el.getAttribute('data-section') || ''; return sections.has(id) ? id : ''; } +function paperReelProxyId(kind) { + return kind === 'flash' ? 'paperReelFlashProxy' : 'paperReelHoverProxy'; +} +function ensurePaperReelRasterProxy(state, kind) { + const id = paperReelProxyId(kind); + let proxy = state.doc.getElementById(id); + if (!proxy) { + proxy = state.doc.createElement('div'); + proxy.id = id; + proxy.setAttribute('aria-hidden', 'true'); + state.body.appendChild(proxy); + } + return proxy; +} +function hidePaperReelRasterProxy(state, kind) { + const proxy = state.doc.getElementById(paperReelProxyId(kind)); + if (proxy) proxy.style.display = 'none'; +} +function syncPaperReelRasterMode(state) { + const layers = state.doc.querySelectorAll('[id="poster-history-pixel-layer"]'); + const layer = layers.length === 1 && layers[0].tagName === 'IMG' ? layers[0] : null; + const host = layer && layer.parentElement && layer.parentElement.closest('[data-poster-history-pixel-host="1"]'); + const active = Boolean(layer && host); + state.rasterLayer = active ? layer : null; + state.rasterHost = active ? host : null; + state.body.classList.toggle('paper-reel-raster-fallback', active); + if (active) { + state.root.setAttribute('data-paper-reel-raster-fallback', '1'); + ensurePaperReelRasterProxy(state, 'hover'); + ensurePaperReelRasterProxy(state, 'flash'); + } else { + state.root.removeAttribute('data-paper-reel-raster-fallback'); + hidePaperReelRasterProxy(state, 'hover'); + hidePaperReelRasterProxy(state, 'flash'); + } + return active; +} +function placePaperReelRasterProxy(state, kind, el) { + if (!syncPaperReelRasterMode(state) || !el || !el.isConnected) { + hidePaperReelRasterProxy(state, kind); + return false; + } + const rect = el.getBoundingClientRect(); + const layerRect = state.rasterLayer.getBoundingClientRect(); + const view = state.doc.defaultView; + const left = Math.max(0, layerRect.left, rect.left); + const top = Math.max(0, layerRect.top, rect.top); + const right = Math.min(view.innerWidth, layerRect.right, rect.right); + const bottom = Math.min(view.innerHeight, layerRect.bottom, rect.bottom); + if (right - left < 2 || bottom - top < 2) { + hidePaperReelRasterProxy(state, kind); + return false; + } + const proxy = ensurePaperReelRasterProxy(state, kind); + proxy.style.left = `${left}px`; + proxy.style.top = `${top}px`; + proxy.style.width = `${right - left}px`; + proxy.style.height = `${bottom - top}px`; + proxy.style.borderRadius = view.getComputedStyle(el).borderRadius || '5px'; + proxy.style.display = 'block'; + proxy.dataset.paperReelSection = posterTargetId(el); + return true; +} +function showPaperReelRasterHover(state, el) { + if (!syncPaperReelRasterMode(state)) { + state.activeRasterHover = null; + hidePaperReelRasterProxy(state, 'hover'); + return; + } + state.activeRasterHover = el; + placePaperReelRasterProxy(state, 'hover', el); +} +function hidePaperReelRasterHover(state, el) { + if (state.activeRasterHover === el) state.activeRasterHover = null; + if (!state.activeRasterHover) hidePaperReelRasterProxy(state, 'hover'); +} +function flashPaperReelRasterTarget(state, el, durationMs) { + if (!syncPaperReelRasterMode(state)) { + state.activeRasterFlash = null; + hidePaperReelRasterProxy(state, 'flash'); + return; + } + state.activeRasterFlash = el; + const proxy = ensurePaperReelRasterProxy(state, 'flash'); + clearTimeout(proxy._paperReelTimer); + placePaperReelRasterProxy(state, 'flash', el); + proxy._paperReelTimer = setTimeout(() => { + if (state.activeRasterFlash === el) state.activeRasterFlash = null; + hidePaperReelRasterProxy(state, 'flash'); + }, durationMs); +} +function syncPaperReelRasterProxies(state) { + if (state.activeRasterHover) placePaperReelRasterProxy(state, 'hover', state.activeRasterHover); + if (state.activeRasterFlash) placePaperReelRasterProxy(state, 'flash', state.activeRasterFlash); +} function bindPosterTarget(state, el) { const view = state.doc.defaultView; if (!view || !(el instanceof view.Element)) return; @@ -622,11 +843,16 @@ el.addEventListener('mouseenter', e => { state.body.classList.add('paper-reel-has-hover'); el.classList.add('paper-reel-hover'); + showPaperReelRasterHover(state, el); + showTooltip(state.doc, 'Double Click to Open', e.clientX, e.clientY); + }); + el.addEventListener('mousemove', e => { + showPaperReelRasterHover(state, el); showTooltip(state.doc, 'Double Click to Open', e.clientX, e.clientY); }); - el.addEventListener('mousemove', e => showTooltip(state.doc, 'Double Click to Open', e.clientX, e.clientY)); el.addEventListener('mouseleave', () => { el.classList.remove('paper-reel-hover'); + hidePaperReelRasterHover(state, el); if (!state.doc.querySelector('.paper-reel-hover')) state.body.classList.remove('paper-reel-has-hover'); }); el.addEventListener('dblclick', ev => { @@ -655,6 +881,11 @@ body: doc.body, bound: new WeakSet(), observer: null, + rasterLayer: null, + rasterHost: null, + activeRasterHover: null, + activeRasterFlash: null, + rasterSyncHandler: null, }; let style = doc.getElementById('paperReelToolsStyle'); if (!style) { @@ -667,6 +898,10 @@ .titlebar.paper-reel-hover { filter:brightness(1.04); } [data-section].paper-reel-flash { border-color:rgba(214,74,54,.72) !important; box-shadow:inset 0 0 0 7px rgba(214,74,54,.46), 0 0 20px rgba(214,74,54,.16) !important; } .titlebar.paper-reel-flash { filter:brightness(1.08); } + #paperReelHoverProxy, #paperReelFlashProxy { position:fixed !important; display:none; box-sizing:border-box; background:transparent !important; pointer-events:none !important; user-select:none !important; z-index:2147483647 !important; } + #paperReelHoverProxy { border:2px solid rgba(255,255,255,.96); box-shadow:0 0 18px rgba(15,23,42,.16), 0 0 0 100vmax rgb(255 255 255 / calc(1 - var(--paper-reel-dim-opacity,.48))); } + #paperReelFlashProxy { border:4px solid rgba(214,74,54,.92); box-shadow:inset 0 0 0 4px rgba(214,74,54,.36), 0 0 22px rgba(214,74,54,.3); } + @media (min-resolution:1.5dppx) { :root[data-paper-reel-raster-fallback="1"] [data-poster-history-pixel-host="1"] #poster-history-pixel-layer[srcset] { image-rendering:auto !important; } } #paperReelDebug { position:fixed; right:14px; bottom:14px; z-index:2147483646; display:none; background:rgba(255,255,255,.96); border:1px solid #cbd5dc; border-radius:8px; padding:10px; font:12px Arial; box-shadow:0 12px 30px rgba(0,0,0,.2); } body.paper-reel-debug #paperReelDebug { display:block; } `; @@ -730,6 +965,10 @@ doc.defaultView.onkeydown = iframeReelKeydown; doc.onkeydown = iframeReelKeydown; if (doc.body) doc.body.onkeydown = iframeReelKeydown; + syncPaperReelRasterMode(state); + state.rasterSyncHandler = () => syncPaperReelRasterProxies(state); + doc.defaultView.addEventListener('resize', state.rasterSyncHandler); + doc.addEventListener('scroll', state.rasterSyncHandler, true); bindPosterTargets(state); state.observer = new doc.defaultView.MutationObserver(records => { if (!posterHookIdentityMatches(state, posterDoc())) { @@ -765,6 +1004,7 @@ return false; } } else { + syncPaperReelRasterMode(posterHookState); bindPosterTargets(posterHookState); } const ready = Boolean(doc.querySelector('[data-section].paper-reel-clickable, .titlebar.paper-reel-clickable')); @@ -778,7 +1018,13 @@ const el = doc.querySelector(selector); if (!el) return; el.classList.add('paper-reel-flash'); + if (posterHookIdentityMatches(posterHookState, doc)) { + flashPaperReelRasterTarget(posterHookState, el, 1600); + } el.scrollIntoView({block:'center', inline:'center', behavior:'smooth'}); + setTimeout(() => { + if (posterHookIdentityMatches(posterHookState, doc)) syncPaperReelRasterProxies(posterHookState); + }, 100); setTimeout(() => el.classList.remove('paper-reel-flash'), 1600); } function postShortcutToPoster(key) { @@ -926,7 +1172,15 @@ def copy_if_exists(src: Path, dst: Path) -> None: def is_backup_artifact_name(name: str) -> bool: lowered = name.lower() - return lowered.endswith((".bak", ".backup")) or ".bak." in lowered + return ( + lowered.endswith((".bak", ".backup")) + or ".bak." in lowered + or ".backup." in lowered + or ( + lowered.startswith(".") + and any(marker in lowered for marker in (".density.", ".render.")) + ) + ) def ignore_backup_artifacts(_dir: str, names: list[str]) -> set[str]: @@ -964,6 +1218,524 @@ def ignore(dir_name: str, names: list[str]) -> set[str]: shutil.copytree(src, dst, ignore=ignore) +def html_tag_attribute(tag: str, name: str) -> str | None: + match = re.search( + rf"(? str: + escaped = html.escape(value, quote=True) + pattern = re.compile( + rf"(?$", tag)) + body = re.sub(r"/\s*>$" if self_closing else r">$", "", tag).rstrip() + closing = " />" if self_closing else ">" + return body + f' {name}="{escaped}"' + closing + + +def unique_hosted_history_pixel_layer_tag( + text: str, +) -> tuple[str, int, int] | None: + """Return the one eligible layer tag, rejecting duplicate or unhosted IDs.""" + parser = HistoryPixelLayerContractParser(text) + try: + parser.feed(text) + parser.close() + except Exception as exc: + print( + "[paper2reel] WARNING: could not parse the historical pixel layer " + f"contract: {exc}" + ) + return None + + if len(parser.layer_contracts) != 1: + if parser.layer_contracts: + print( + "[paper2reel] WARNING: Retina sources require exactly one " + "#poster-history-pixel-layer; preserving the poster unchanged" + ) + return None + is_image, inside_host, unambiguous_id, start, end, raw_tag = ( + parser.layer_contracts[0] + ) + if not unambiguous_id: + print( + "[paper2reel] WARNING: Retina sources require exactly one id " + "attribute on #poster-history-pixel-layer; preserving the poster " + "unchanged" + ) + return None + if not is_image: + print( + "[paper2reel] WARNING: Retina sources require " + "#poster-history-pixel-layer to be an img; preserving the poster " + "unchanged" + ) + return None + if not inside_host: + print( + "[paper2reel] WARNING: Retina sources require the historical pixel " + "layer to be inside [data-poster-history-pixel-host=\"1\"]; " + "preserving the poster unchanged" + ) + return None + if not raw_tag or text[start:end] != raw_tag: + print( + "[paper2reel] WARNING: could not isolate the historical pixel layer " + "tag; preserving the poster unchanged" + ) + return None + return raw_tag, start, end + + +def png_dimensions(path: Path) -> tuple[int, int] | None: + try: + with path.open("rb") as stream: + header = stream.read(24) + except OSError: + return None + if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": + return None + width, height = struct.unpack(">II", header[16:24]) + if width < 1 or height < 1: + return None + return width, height + + +def local_history_pixel_source(poster_out: Path, tag: str) -> Path | None: + src = html_tag_attribute(tag, "src") + if not src or re.match(r"^[a-z][a-z0-9+.-]*:", src, flags=re.IGNORECASE): + return None + clean_src = src.split("#", 1)[0].split("?", 1)[0] + candidate = (poster_out / clean_src).resolve() + try: + candidate.relative_to(poster_out.resolve()) + except ValueError: + return None + return candidate if candidate.is_file() and candidate.suffix.lower() == ".png" else None + + +def density_source_resample_metrics( + source: Any, + candidate: Any, + image_module: Any, + image_chops: Any, + image_stat: Any, +) -> dict[str, Any]: + """Detect a density candidate that is only a conventional 1x resize.""" + if ( + candidate.width % source.width + or candidate.height % source.height + or candidate.width // source.width != candidate.height // source.height + ): + return {"trivial_upscale": True, "error": "non-integral density scale"} + scale = candidate.width // source.width + if scale not in (2, 3): + return {"trivial_upscale": True, "error": f"unsupported density scale {scale}"} + + ranked_tiles: list[tuple[float, tuple[int, int, int, int]]] = [] + tile_size = HISTORY_DENSITY_DETAIL_TILE_SIZE + for top in range(0, source.height, tile_size): + for left in range(0, source.width, tile_size): + right = min(left + tile_size, source.width) + bottom = min(top + tile_size, source.height) + grayscale = source.crop((left, top, right, bottom)).convert("L") + stats = image_stat.Stat(grayscale) + variance = stats.var[0] if stats.var else 0.0 + ranked_tiles.append( + (variance * (right - left) * (bottom - top), (left, top, right, bottom)) + ) + boxes = [ + box + for _, box in sorted(ranked_tiles, key=lambda item: item[0], reverse=True)[ + :HISTORY_DENSITY_DETAIL_TILE_COUNT + ] + ] + + resampling = getattr(image_module, "Resampling", image_module) + methods: list[tuple[str, Any]] = [] + for name in ("NEAREST", "BOX", "BILINEAR", "HAMMING", "BICUBIC", "LANCZOS"): + method = getattr(resampling, name, None) + if method is not None and all(method != existing for _, existing in methods): + methods.append((name.lower(), method)) + + comparisons: list[dict[str, Any]] = [] + margin = HISTORY_DENSITY_DETAIL_MARGIN + for name, method in methods: + absolute_total = 0.0 + square_total = 0.0 + channel_samples = 0 + for left, top, right, bottom in boxes: + expanded_left = max(0, left - margin) + expanded_top = max(0, top - margin) + expanded_right = min(source.width, right + margin) + expanded_bottom = min(source.height, bottom + margin) + source_crop = source.crop( + (expanded_left, expanded_top, expanded_right, expanded_bottom) + ) + reference = source_crop.resize( + (source_crop.width * scale, source_crop.height * scale), + method, + ).crop( + ( + (left - expanded_left) * scale, + (top - expanded_top) * scale, + (right - expanded_left) * scale, + (bottom - expanded_top) * scale, + ) + ) + actual = candidate.crop( + (left * scale, top * scale, right * scale, bottom * scale) + ) + stats = image_stat.Stat(image_chops.difference(actual, reference)) + pixels = actual.width * actual.height + absolute_total += sum(stats.mean) * pixels + square_total += sum(value * value for value in stats.rms) * pixels + channel_samples += len(stats.mean) * pixels + comparisons.append( + { + "method": name, + "mae": absolute_total / channel_samples, + "rms": (square_total / channel_samples) ** 0.5, + } + ) + + closest = min( + comparisons, + key=lambda item: max( + item["mae"] / HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_MAE, + item["rms"] / HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_RMS, + ), + ) + return { + "trivial_upscale": any( + item["mae"] <= HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_MAE + and item["rms"] <= HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_RMS + for item in comparisons + ), + "closest_resample": closest["method"], + "closest_resample_mae": closest["mae"], + "closest_resample_rms": closest["rms"], + } + + +def render_pdf_density_variant( + pdf: Path, + source_png: Path, + scale: int, + pdftoppm: str, +) -> Path | None: + dimensions = png_dimensions(source_png) + if dimensions is None: + return None + width, height = dimensions + target = source_png.with_name(f"{source_png.stem}@{scale}x.png") + target_width, target_height = width * scale, height * scale + if ( + max(target_width, target_height) > HISTORY_DENSITY_MAX_SIDE + or target_width * target_height > HISTORY_DENSITY_MAX_PIXELS + ): + print( + "[paper2reel] WARNING: refusing oversized historical poster density " + f"source {target_width}x{target_height}" + ) + return None + with tempfile.NamedTemporaryFile( + prefix=f".{target.stem}.render.", + suffix=".png", + dir=target.parent, + delete=False, + ) as stream: + temporary = Path(stream.name) + temporary.unlink(missing_ok=True) + prefix = temporary.with_suffix("") + cmd = [ + pdftoppm, + "-f", "1", + "-l", "1", + "-singlefile", + "-png", + "-scale-to-x", str(target_width), + "-scale-to-y", str(target_height), + str(pdf), + str(prefix), + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + except (OSError, subprocess.TimeoutExpired) as exc: + temporary.unlink(missing_ok=True) + print(f"[paper2reel] WARNING: could not render {scale}x historical poster raster: {exc}") + return None + if result.returncode != 0 or png_dimensions(temporary) != (target_width, target_height): + temporary.unlink(missing_ok=True) + detail = (result.stderr or result.stdout or "pdftoppm returned invalid output").strip() + print(f"[paper2reel] WARNING: could not render {scale}x historical poster raster: {detail}") + return None + try: + shutil.copymode(source_png, temporary) + except OSError as exc: + temporary.unlink(missing_ok=True) + print(f"[paper2reel] WARNING: could not prepare {scale}x historical poster raster: {exc}") + return None + return temporary + + +def density_variant_matches_canonical(source_png: Path, variant: Path) -> bool: + """Reject a same-sized but unrelated PDF render before enabling srcset.""" + try: + from PIL import Image, ImageChops, ImageStat + + with Image.open(source_png) as source_image, Image.open(variant) as variant_image: + source_full = source_image.convert("RGB") + candidate_full = variant_image.convert("RGB") + source_resample = density_source_resample_metrics( + source_full, + candidate_full, + Image, + ImageChops, + ImageStat, + ) + sample_width = min(512, source_full.width) + sample_size = ( + sample_width, + max(1, round(source_full.height * sample_width / source_full.width)), + ) + resampling = getattr(Image, "Resampling", Image).LANCZOS + source = source_full.resize(sample_size, resampling) + candidate = candidate_full.resize(sample_size, resampling) + difference = ImageChops.difference(source, candidate) + stats = ImageStat.Stat(difference) + mean_absolute_delta = sum(stats.mean) / len(stats.mean) + rms_delta = (sum(value * value for value in stats.rms) / len(stats.rms)) ** 0.5 + tile_rms_values: list[float] = [] + for top in range(0, difference.height, HISTORY_DENSITY_TILE_SIZE): + for left in range(0, difference.width, HISTORY_DENSITY_TILE_SIZE): + tile = difference.crop( + ( + left, + top, + min(left + HISTORY_DENSITY_TILE_SIZE, difference.width), + min(top + HISTORY_DENSITY_TILE_SIZE, difference.height), + ) + ) + tile_stats = ImageStat.Stat(tile) + tile_rms_values.append( + ( + sum(value * value for value in tile_stats.rms) + / len(tile_stats.rms) + ) ** 0.5 + ) + max_tile_rms = max(tile_rms_values, default=0.0) + except Exception as exc: + print( + "[paper2reel] WARNING: could not verify the PDF-derived historical " + f"poster raster against its canonical PNG: {exc}" + ) + return False + + matches = ( + mean_absolute_delta <= HISTORY_DENSITY_MAX_MAE + and rms_delta <= HISTORY_DENSITY_MAX_RMS + and max_tile_rms <= HISTORY_DENSITY_MAX_TILE_RMS + and not source_resample.get("trivial_upscale", True) + ) + if not matches: + print( + "[paper2reel] WARNING: PDF-derived poster raster does not match the " + "canonical historical PNG " + f"(mean delta {mean_absolute_delta:.2f}, RMS {rms_delta:.2f}, " + f"max {HISTORY_DENSITY_TILE_SIZE}x{HISTORY_DENSITY_TILE_SIZE} " + f"tile RMS {max_tile_rms:.2f}, closest canonical resize " + f"{source_resample.get('closest_resample', 'unknown')} " + f"MAE {float(source_resample.get('closest_resample_mae') or 0):.2f}, " + f"RMS {float(source_resample.get('closest_resample_rms') or 0):.2f})" + ) + return matches + + +def reserve_neighbor_temp_path(target: Path, purpose: str) -> Path: + with tempfile.NamedTemporaryFile( + prefix=f".{target.stem}.{purpose}.", + suffix=target.suffix, + dir=target.parent, + delete=False, + ) as stream: + return Path(stream.name) + + +def install_history_density_transaction( + poster_html: Path, + updated_text: str, + variants: list[tuple[Path, Path, int]], +) -> bool: + """Commit HTML and a 2x/3x pair together, restoring any old pair on error.""" + html_temporary = reserve_neighbor_temp_path(poster_html, "density") + backups: dict[Path, Path | None] = {} + install_started = False + preserve_backups = False + try: + html_temporary.write_text(updated_text, encoding="utf-8") + shutil.copymode(poster_html, html_temporary) + for temporary, target, _ in variants: + if target.exists() or target.is_symlink(): + if not target.is_file(): + raise OSError(f"Retina target is not a regular file: {target}") + backup = reserve_neighbor_temp_path(target, "backup") + try: + shutil.copy2(target, backup) + shutil.copymode(target, temporary) + except OSError: + backup.unlink(missing_ok=True) + raise + backups[target] = backup + else: + backups[target] = None + + install_started = True + for temporary, target, _ in variants: + temporary.replace(target) + html_temporary.replace(poster_html) + except OSError as exc: + rollback_errors: list[str] = [] + if install_started: + for _, target, _ in variants: + backup = backups.get(target) + try: + if backup is not None and backup.exists(): + backup.replace(target) + elif target.exists() or target.is_symlink(): + if target.is_file() or target.is_symlink(): + target.unlink() + else: + raise OSError( + f"cannot remove non-file Retina target {target}" + ) + except OSError as rollback_exc: + rollback_errors.append(str(rollback_exc)) + detail = f"{exc}" + if rollback_errors: + preserve_backups = True + detail += "; rollback errors: " + "; ".join(rollback_errors) + print( + "[paper2reel] preserving the previous historical poster density " + f"sources; transaction failed: {detail}" + ) + return False + finally: + html_temporary.unlink(missing_ok=True) + for temporary, _, _ in variants: + temporary.unlink(missing_ok=True) + for backup in backups.values(): + if backup is not None and not preserve_backups: + backup.unlink(missing_ok=True) + return True + + +def install_history_pixel_density_sources(poster_dir: Path, poster_out: Path) -> None: + """Add optional Retina sources without changing the canonical DPR1 image. + + The out-of-band production backfill may cover native poster DOM with a + historical PNG to preserve exact old pixels. Keep that PNG as the 1x src, + and derive only higher-density fallbacks from the canonical PDF. + """ + poster_html = poster_out / "poster.html" + text = poster_html.read_text(encoding="utf-8") + layer_contract = unique_hosted_history_pixel_layer_tag(text) + if not layer_contract: + return + layer_tag, layer_start, layer_end = layer_contract + source_png = local_history_pixel_source(poster_out, layer_tag) + expected_sha = html_tag_attribute( + layer_tag, + "data-historical-png-sha256", + ) + if source_png is not None: + if not expected_sha or not re.fullmatch(r"[0-9a-fA-F]{64}", expected_sha): + print( + "[paper2reel] historical pixel layer is missing its canonical " + "PNG SHA-256; preserving the poster without density variants" + ) + return + try: + actual_sha = hashlib.sha256(source_png.read_bytes()).hexdigest() + except OSError as exc: + print( + "[paper2reel] could not verify the canonical historical PNG; " + f"preserving the poster without density variants: {exc}" + ) + return + if actual_sha != expected_sha.lower(): + print( + "[paper2reel] canonical historical PNG SHA-256 mismatch; " + "preserving the poster without density variants" + ) + return + pdf = poster_dir / "poster.pdf" + pdftoppm = shutil.which("pdftoppm") + if source_png is None or not pdf.is_file() or not pdftoppm: + print( + "[paper2reel] historical pixel layer detected; preserving its canonical 1x PNG " + "without density variants" + ) + return + + variants: list[tuple[Path, Path, int]] = [] + for scale in (2, 3): + rendered = render_pdf_density_variant(pdf, source_png, scale, pdftoppm) + if rendered is not None: + target = source_png.with_name(f"{source_png.stem}@{scale}x.png") + variants.append((rendered, target, scale)) + if len(variants) != 2: + for path, _, _ in variants: + path.unlink(missing_ok=True) + print( + "[paper2reel] preserving the canonical 1x historical poster raster; " + "both 2x and 3x variants are required before enabling Retina sources" + ) + return + variant_matches = [ + density_variant_matches_canonical(source_png, temporary) + for temporary, _, _ in variants + ] + if not all(variant_matches): + for path, _, _ in variants: + path.unlink(missing_ok=True) + print( + "[paper2reel] preserving the canonical 1x historical poster raster " + "without unverified Retina sources" + ) + return + installed_variants = [(target, scale) for _, target, scale in variants] + sources = [(source_png, 1), *installed_variants] + + srcset = ", ".join( + f"{path.relative_to(poster_out).as_posix()} {scale}x" + for path, scale in sources + ) + updated_tag = set_html_tag_attribute(layer_tag, "srcset", srcset) + updated_tag = set_html_tag_attribute( + updated_tag, + "data-paper-reel-density-sources", + ",".join(str(scale) for _, scale in sources), + ) + updated_text = text[:layer_start] + updated_tag + text[layer_end:] + if not install_history_density_transaction( + poster_html, + updated_text, + variants, + ): + return + print(f"[paper2reel] installed historical poster density sources: {srcset}") + + def js_string_for_html(text: str) -> str: """Encode HTML as a JS string without closing the surrounding script tag.""" return js_json_for_script(text) @@ -1222,8 +1994,9 @@ def copy_poster_bundle(poster_dir: Path, outdir: Path) -> None: copy_if_exists(required, poster_out / "poster.html") patch_poster_shortcut_bridge(poster_out / "poster.html") copy_poster_assets(poster_dir / "assets", poster_out / "assets") - for name in ("figures", "fonts", "logos", "qr", "audio"): + for name in ("figures", "fonts", "logos", "qr", "audio", "mathjax"): copy_if_exists(poster_dir / name, poster_out / name) + install_history_pixel_density_sources(poster_dir, poster_out) def copy_ui_assets(outdir: Path) -> None: diff --git a/ResearchStudio-Reel/skills/paper2reel/scripts/check_reel_package.py b/ResearchStudio-Reel/skills/paper2reel/scripts/check_reel_package.py index 10c8e45..78623c5 100644 --- a/ResearchStudio-Reel/skills/paper2reel/scripts/check_reel_package.py +++ b/ResearchStudio-Reel/skills/paper2reel/scripts/check_reel_package.py @@ -12,13 +12,16 @@ import functools import hashlib import json +import math import re +import struct import sys import threading import urllib.error import urllib.request import zipfile from datetime import datetime, timezone +from html.parser import HTMLParser from pathlib import Path, PurePosixPath from typing import Any @@ -108,6 +111,81 @@ "min_download_buttons": 4, } +HISTORY_DENSITY_MAX_SIDE = 12_000 +HISTORY_DENSITY_MAX_PIXELS = 64_000_000 +HISTORY_DENSITY_MAX_MAE = 6.0 +HISTORY_DENSITY_MAX_RMS = 14.0 +HISTORY_DENSITY_TILE_SIZE = 32 +HISTORY_DENSITY_MAX_TILE_RMS = 30.0 +HISTORY_DENSITY_DETAIL_TILE_SIZE = 128 +HISTORY_DENSITY_DETAIL_TILE_COUNT = 12 +HISTORY_DENSITY_DETAIL_MARGIN = 4 +HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_MAE = 2.0 +HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_RMS = 8.0 + + +class HistoricalRasterMarkupAudit(HTMLParser): + """Track whether the historical pixel layer is nested in its host.""" + + VOID_TAGS = { + "area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "param", "source", "track", "wbr", + } + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.stack: list[tuple[str, bool]] = [] + self.has_host = False + self.layer_count = 0 + self.layer_host_descendant_count = 0 + self.layer_tags: list[tuple[str, str]] = [] + self.ambiguous_layer_id_count = 0 + self.ambiguous_host_count = 0 + + def _handle_start( + self, + tag: str, + attrs: list[tuple[str, str | None]], + *, + self_closing: bool, + ) -> None: + values: dict[str, list[str]] = {} + for name, value in attrs: + values.setdefault(name.lower(), []).append(value or "") + lowered_tag = tag.lower() + parent_has_host = self.stack[-1][1] if self.stack else False + host_values = values.get("data-poster-history-pixel-host", []) + is_host = host_values == ["1"] + if len(host_values) > 1 and "1" in host_values: + self.ambiguous_host_count += 1 + inside_host = parent_has_host or is_host + self.has_host = self.has_host or is_host + ids = values.get("id", []) + if "poster-history-pixel-layer" in ids: + self.layer_count += 1 + if ids != ["poster-history-pixel-layer"]: + self.ambiguous_layer_id_count += 1 + self.layer_tags.append( + (lowered_tag, self.get_starttag_text() or "") + ) + if parent_has_host: + self.layer_host_descendant_count += 1 + if not self_closing and lowered_tag not in self.VOID_TAGS: + self.stack.append((lowered_tag, inside_host)) + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + self._handle_start(tag, attrs, self_closing=False) + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + self._handle_start(tag, attrs, self_closing=True) + + def handle_endtag(self, tag: str) -> None: + lowered_tag = tag.lower() + for index in range(len(self.stack) - 1, -1, -1): + if self.stack[index][0] == lowered_tag: + del self.stack[index:] + break + def utc_now() -> str: return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") @@ -196,6 +274,19 @@ def validate_no_local_paths(findings: list[dict[str, Any]], text: str, *, path: return +def is_internal_artifact_name(name: str) -> bool: + lowered = name.lower() + return ( + lowered.endswith((".bak", ".backup")) + or ".bak." in lowered + or ".backup." in lowered + or ( + lowered.startswith(".") + and any(marker in lowered for marker in (".density.", ".render.")) + ) + ) + + def validate_no_backup_files(findings: list[dict[str, Any]], viewer_dir: Path) -> None: scan_roots = [ viewer_dir / "reel.html", @@ -215,8 +306,7 @@ def validate_no_backup_files(findings: list[dict[str, Any]], viewer_dir: Path) - for path in paths: if not path.is_file(): continue - lowered = path.name.lower() - if lowered.endswith((".bak", ".backup")) or ".bak." in lowered: + if is_internal_artifact_name(path.name): add_finding( findings, "ERROR", @@ -227,8 +317,7 @@ def validate_no_backup_files(findings: list[dict[str, Any]], viewer_dir: Path) - def is_backup_archive_name(name: str) -> bool: - lowered = name.lower() - return lowered.endswith((".bak", ".backup")) or ".bak." in lowered + return is_internal_artifact_name(name) def archive_internal_names(names: list[str]) -> list[str]: @@ -471,6 +560,411 @@ def validate_local_open_resources(findings: list[dict[str, Any]], poster_html: s ) +def tag_attribute(tag: str, name: str) -> str | None: + match = re.search( + rf"(? tuple[int, int] | None: + try: + with path.open("rb") as stream: + header = stream.read(24) + except OSError: + return None + if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n" or header[12:16] != b"IHDR": + return None + width, height = struct.unpack(">II", header[16:24]) + return (width, height) if width > 0 and height > 0 else None + + +def density_source_resample_metrics( + source: Any, + candidate: Any, + image_module: Any, + image_chops: Any, + image_stat: Any, +) -> dict[str, Any]: + """Detect a density candidate that is only a conventional 1x resize.""" + if ( + candidate.width % source.width + or candidate.height % source.height + or candidate.width // source.width != candidate.height // source.height + ): + return {"trivial_upscale": True, "error": "non-integral density scale"} + scale = candidate.width // source.width + if scale not in (2, 3): + return {"trivial_upscale": True, "error": f"unsupported density scale {scale}"} + + ranked_tiles: list[tuple[float, tuple[int, int, int, int]]] = [] + tile_size = HISTORY_DENSITY_DETAIL_TILE_SIZE + for top in range(0, source.height, tile_size): + for left in range(0, source.width, tile_size): + right = min(left + tile_size, source.width) + bottom = min(top + tile_size, source.height) + grayscale = source.crop((left, top, right, bottom)).convert("L") + stats = image_stat.Stat(grayscale) + variance = stats.var[0] if stats.var else 0.0 + ranked_tiles.append( + (variance * (right - left) * (bottom - top), (left, top, right, bottom)) + ) + boxes = [ + box + for _, box in sorted(ranked_tiles, key=lambda item: item[0], reverse=True)[ + :HISTORY_DENSITY_DETAIL_TILE_COUNT + ] + ] + + resampling = getattr(image_module, "Resampling", image_module) + methods: list[tuple[str, Any]] = [] + for name in ("NEAREST", "BOX", "BILINEAR", "HAMMING", "BICUBIC", "LANCZOS"): + method = getattr(resampling, name, None) + if method is not None and all(method != existing for _, existing in methods): + methods.append((name.lower(), method)) + + comparisons: list[dict[str, Any]] = [] + margin = HISTORY_DENSITY_DETAIL_MARGIN + for name, method in methods: + absolute_total = 0.0 + square_total = 0.0 + channel_samples = 0 + for left, top, right, bottom in boxes: + expanded_left = max(0, left - margin) + expanded_top = max(0, top - margin) + expanded_right = min(source.width, right + margin) + expanded_bottom = min(source.height, bottom + margin) + source_crop = source.crop( + (expanded_left, expanded_top, expanded_right, expanded_bottom) + ) + reference = source_crop.resize( + (source_crop.width * scale, source_crop.height * scale), + method, + ).crop( + ( + (left - expanded_left) * scale, + (top - expanded_top) * scale, + (right - expanded_left) * scale, + (bottom - expanded_top) * scale, + ) + ) + actual = candidate.crop( + (left * scale, top * scale, right * scale, bottom * scale) + ) + stats = image_stat.Stat(image_chops.difference(actual, reference)) + pixels = actual.width * actual.height + absolute_total += sum(stats.mean) * pixels + square_total += sum(value * value for value in stats.rms) * pixels + channel_samples += len(stats.mean) * pixels + comparisons.append( + { + "method": name, + "mae": absolute_total / channel_samples, + "rms": (square_total / channel_samples) ** 0.5, + } + ) + + closest = min( + comparisons, + key=lambda item: max( + item["mae"] / HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_MAE, + item["rms"] / HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_RMS, + ), + ) + return { + "trivial_upscale": any( + item["mae"] <= HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_MAE + and item["rms"] <= HISTORY_DENSITY_TRIVIAL_UPSCALE_MAX_RMS + for item in comparisons + ), + "closest_resample": closest["method"], + "closest_resample_mae": closest["mae"], + "closest_resample_rms": closest["rms"], + } + + +def historical_density_similarity(source_png: Path, variant: Path) -> dict[str, Any]: + try: + from PIL import Image, ImageChops, ImageStat + + with Image.open(source_png) as source_image, Image.open(variant) as variant_image: + source_full = source_image.convert("RGB") + candidate_full = variant_image.convert("RGB") + source_resample = density_source_resample_metrics( + source_full, + candidate_full, + Image, + ImageChops, + ImageStat, + ) + sample_width = min(512, source_full.width) + sample_size = ( + sample_width, + max(1, round(source_full.height * sample_width / source_full.width)), + ) + resampling = getattr(Image, "Resampling", Image).LANCZOS + source = source_full.resize(sample_size, resampling) + candidate = candidate_full.resize(sample_size, resampling) + diff = ImageChops.difference(source, candidate) + stats = ImageStat.Stat(diff) + mean_absolute_delta = sum(stats.mean) / len(stats.mean) + rms_delta = (sum(value * value for value in stats.rms) / len(stats.rms)) ** 0.5 + max_tile_rms = 0.0 + for top in range(0, diff.height, HISTORY_DENSITY_TILE_SIZE): + for left in range(0, diff.width, HISTORY_DENSITY_TILE_SIZE): + tile = diff.crop(( + left, + top, + min(left + HISTORY_DENSITY_TILE_SIZE, diff.width), + min(top + HISTORY_DENSITY_TILE_SIZE, diff.height), + )) + tile_stats = ImageStat.Stat(tile) + tile_rms = ( + sum(value * value for value in tile_stats.rms) + / len(tile_stats.rms) + ) ** 0.5 + max_tile_rms = max(max_tile_rms, tile_rms) + return { + "matches": ( + mean_absolute_delta <= HISTORY_DENSITY_MAX_MAE + and rms_delta <= HISTORY_DENSITY_MAX_RMS + and max_tile_rms <= HISTORY_DENSITY_MAX_TILE_RMS + and not source_resample.get("trivial_upscale", True) + ), + "mean_absolute_delta": mean_absolute_delta, + "rms_delta": rms_delta, + "max_tile_rms": max_tile_rms, + **source_resample, + } + except Exception as exc: + return {"matches": False, "error": str(exc)} + + +def validate_historical_raster_assets( + findings: list[dict[str, Any]], + poster_html: str, + poster_path: Path, + root: Path, +) -> None: + markup_audit = HistoricalRasterMarkupAudit() + try: + markup_audit.feed(poster_html) + markup_audit.close() + except Exception: + pass + has_host = markup_audit.has_host + layer_count = markup_audit.layer_count + if markup_audit.ambiguous_host_count: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_HOST_ATTRIBUTE_DUPLICATE", + "Historical raster hosts must use exactly one unambiguous contract attribute.", + path=rel(poster_path, root), + data={"count": markup_audit.ambiguous_host_count}, + ) + if layer_count > 1: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_LAYER_DUPLICATE", + "Historical raster pixel layer ID must be unique.", + path=rel(poster_path, root), + data={"count": layer_count}, + ) + return + if markup_audit.ambiguous_layer_id_count: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_LAYER_ID_AMBIGUOUS", + "Historical raster pixel layer must use exactly one unambiguous id attribute.", + path=rel(poster_path, root), + data={"count": markup_audit.ambiguous_layer_id_count}, + ) + return + if layer_count == 0: + if has_host: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_LAYER_MISSING", + "Historical raster host exists without its canonical pixel layer.", + path=rel(poster_path, root), + ) + return + layer_tag_name, tag = markup_audit.layer_tags[0] + if layer_tag_name != "img": + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_LAYER_NOT_IMAGE", + "Historical raster pixel layer must be an img element.", + path=rel(poster_path, root), + ) + return + if not has_host: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_HOST_MISSING", + "Historical pixel layer exists without its matching pixel host.", + path=rel(poster_path, root), + ) + elif layer_count == 1 and markup_audit.layer_host_descendant_count != 1: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_HOST_MISMATCH", + "Historical pixel layer must be a descendant of its matching pixel host.", + path=rel(poster_path, root), + ) + + src = tag_attribute(tag, "src") or "" + srcset = tag_attribute(tag, "srcset") or "" + density_sources = tag_attribute(tag, "data-paper-reel-density-sources") or "" + expected_sha = tag_attribute(tag, "data-historical-png-sha256") or "" + if not src or re.match(r"^[a-z][a-z0-9+.-]*:", src, flags=re.IGNORECASE): + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_CANONICAL_SOURCE_INVALID", + "Historical pixel layer must use a local canonical PNG source.", + path=rel(poster_path, root), + data={"src": src}, + ) + return + canonical = (poster_path.parent / src.split("#", 1)[0].split("?", 1)[0]).resolve() + try: + canonical.relative_to(poster_path.parent.resolve()) + except ValueError: + canonical = Path("/") + dimensions = read_png_dimensions(canonical) + if dimensions is None: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_CANONICAL_SOURCE_MISSING", + "Historical pixel layer canonical PNG is missing or invalid.", + path=src, + ) + return + if not re.fullmatch(r"[0-9a-fA-F]{64}", expected_sha): + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_CANONICAL_HASH_MISSING", + "Historical pixel layer must record its canonical PNG SHA-256.", + path=rel(poster_path, root), + data={"value": expected_sha}, + ) + else: + actual_sha = hashlib.sha256(canonical.read_bytes()).hexdigest() + if actual_sha != expected_sha.lower(): + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_CANONICAL_HASH_MISMATCH", + "Historical pixel layer canonical PNG no longer matches its recorded hash.", + path=rel(canonical, root), + data={"expected": expected_sha, "actual": actual_sha}, + ) + + if not srcset and not density_sources: + orphaned = [ + canonical.with_name(f"{canonical.stem}@{scale}x.png") + for scale in (2, 3) + if canonical.with_name(f"{canonical.stem}@{scale}x.png").exists() + ] + if orphaned: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_DENSITY_ORPHANED", + "Historical raster density files exist without the transactional HTML srcset update.", + path=rel(poster_path, root), + data={"files": [rel(path, root) for path in orphaned]}, + ) + add_finding( + findings, + "WARNING", + "HISTORICAL_RASTER_RETINA_UNAVAILABLE", + "Historical raster keeps its canonical 1x pixels, but no optional 2x/3x PDF-derived sources are available.", + path=rel(poster_path, root), + ) + return + + density_paths: dict[int, Path] = {} + density_path_escaped = False + for candidate in srcset.split(","): + parts = candidate.strip().rsplit(None, 1) + if len(parts) != 2 or not re.fullmatch(r"[123]x", parts[1]): + continue + scale = int(parts[1][0]) + density_path = (poster_path.parent / parts[0]).resolve() + try: + density_path.relative_to(poster_path.parent.resolve()) + except ValueError: + density_path_escaped = True + continue + density_paths[scale] = density_path + if ( + density_path_escaped + or density_paths.get(1) != canonical + or set(density_paths) != {1, 2, 3} + or density_sources != "1,2,3" + ): + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_SRCSET_INVALID", + "Historical raster srcset must map the unchanged canonical PNG to 1x and include 2x/3x sources.", + path=rel(poster_path, root), + data={"src": src, "srcset": srcset}, + ) + width, height = dimensions + for scale in (2, 3): + candidate = density_paths.get(scale) + actual = read_png_dimensions(candidate) if candidate else None + expected = (width * scale, height * scale) + oversized = ( + max(expected) > HISTORY_DENSITY_MAX_SIDE + or expected[0] * expected[1] > HISTORY_DENSITY_MAX_PIXELS + ) + if oversized: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_DENSITY_OVERSIZED", + "Historical raster density source exceeds the safe decode limit.", + path=rel(candidate, root) if candidate else rel(poster_path, root), + data={"scale": scale, "dimensions": list(expected)}, + ) + continue + if actual != expected: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_DENSITY_DIMENSIONS_INVALID", + f"Historical raster {scale}x source must be the exact {scale}x dimensions of the canonical PNG.", + path=rel(candidate, root) if candidate else rel(poster_path, root), + data={"scale": scale, "expected": list(expected), "actual": list(actual) if actual else None}, + ) + continue + similarity = historical_density_similarity(canonical, candidate) + if not similarity.get("matches"): + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_DENSITY_CONTENT_MISMATCH", + "Historical raster density source does not match the canonical PNG content.", + path=rel(candidate, root), + data={"scale": scale, **similarity}, + ) + + def blocks_for_language(section: dict[str, Any], lang: str) -> list[Any]: blog = section.get("blog") if isinstance(section.get("blog"), dict) else {} blocks = blog.get("blocks") if isinstance(blog.get("blocks"), dict) else {} @@ -513,6 +1007,7 @@ def validate_static( download_manifest = validate_download_contract(findings, viewer_dir) validate_no_local_paths(findings, html, path=rel(html_path, viewer_dir)) validate_local_open_resources(findings, poster_html, poster_path, viewer_dir) + validate_historical_raster_assets(findings, poster_html, poster_path, viewer_dir) required_html_markers = contract.get("required_html_markers") if isinstance(contract.get("required_html_markers"), dict) else {} for label, marker in required_html_markers.items(): if marker not in html: @@ -1105,6 +1600,802 @@ def validate_browser_seek_interactions(page: Any, findings: list[dict[str, Any]] ) +def screenshot_pixel_delta(before: bytes, after: bytes) -> dict[str, Any]: + if before == after: + return {"different_pixels": 0, "max_channel_delta": 0} + try: + from io import BytesIO + from PIL import Image, ImageChops + + with Image.open(BytesIO(before)) as before_image, Image.open(BytesIO(after)) as after_image: + left = before_image.convert("RGBA") + right = after_image.convert("RGBA") + if left.size != right.size: + return { + "different_pixels": -1, + "before_size": list(left.size), + "after_size": list(right.size), + } + diff = ImageChops.difference(left, right) + changed = 0 + max_delta = 0 + pixels = ( + diff.get_flattened_data() + if hasattr(diff, "get_flattened_data") + else diff.getdata() + ) + for pixel in pixels: + pixel_max = max(pixel) + if pixel_max: + changed += 1 + max_delta = max(max_delta, pixel_max) + return { + "different_pixels": changed, + "max_channel_delta": max_delta, + "dimensions": list(left.size), + } + except Exception as exc: + return { + "different_pixels": -1, + "max_channel_delta": None, + "error": f"Could not decode browser screenshots with Pillow: {exc}", + "before_sha256": hashlib.sha256(before).hexdigest(), + "after_sha256": hashlib.sha256(after).hexdigest(), + } + + +def screenshot_pixel_delta_valid(delta: Any) -> bool: + """Return whether a screenshot comparison produced usable numeric metrics.""" + if not isinstance(delta, dict) or delta.get("error"): + return False + different_pixels = delta.get("different_pixels") + max_channel_delta = delta.get("max_channel_delta") + return bool( + isinstance(different_pixels, (int, float)) + and not isinstance(different_pixels, bool) + and different_pixels >= 0 + and isinstance(max_channel_delta, (int, float)) + and not isinstance(max_channel_delta, bool) + and max_channel_delta >= 0 + ) + + +def screenshot_spotlight_delta( + before: bytes, + after: bytes, + geometry: Any, +) -> dict[str, Any]: + """Measure raster spotlight pixels inside and outside its focus cutout.""" + try: + from io import BytesIO + from PIL import Image + + with ( + Image.open(BytesIO(before)) as before_image, + Image.open(BytesIO(after)) as after_image, + ): + left_image = before_image.convert("RGBA") + right_image = after_image.convert("RGBA") + if left_image.size != right_image.size: + return { + "error": "Spotlight screenshots have different dimensions.", + "before_size": list(left_image.size), + "after_size": list(right_image.size), + } + if not isinstance(geometry, dict): + return {"error": "Spotlight geometry is missing."} + layer_width = float(geometry.get("layerWidth") or 0) + layer_height = float(geometry.get("layerHeight") or 0) + focus_left = float(geometry.get("relativeLeft") or 0) + focus_top = float(geometry.get("relativeTop") or 0) + focus_width = float(geometry.get("width") or 0) + focus_height = float(geometry.get("height") or 0) + if min(layer_width, layer_height, focus_width, focus_height) <= 0: + return {"error": "Spotlight geometry has non-positive dimensions."} + + image_width, image_height = left_image.size + scale_x = image_width / layer_width + scale_y = image_height / layer_height + focus_box = ( + max(0, min(image_width, round(focus_left * scale_x))), + max(0, min(image_height, round(focus_top * scale_y))), + max(0, min(image_width, round((focus_left + focus_width) * scale_x))), + max(0, min(image_height, round((focus_top + focus_height) * scale_y))), + ) + if focus_box[2] <= focus_box[0] or focus_box[3] <= focus_box[1]: + return { + "error": "Spotlight focus box is empty.", + "focus_box": list(focus_box), + } + + border_width = max(0.0, float(geometry.get("borderWidth") or 0)) + focus_pixel_width = focus_box[2] - focus_box[0] + focus_pixel_height = focus_box[3] - focus_box[1] + core_inset_x = max( + math.ceil((border_width + 2) * scale_x), + round(focus_pixel_width * 0.2), + ) + core_inset_y = max( + math.ceil((border_width + 2) * scale_y), + round(focus_pixel_height * 0.2), + ) + focus_core_box = ( + focus_box[0] + core_inset_x, + focus_box[1] + core_inset_y, + focus_box[2] - core_inset_x, + focus_box[3] - core_inset_y, + ) + focus_core_pixels = max(0, focus_core_box[2] - focus_core_box[0]) * max( + 0, focus_core_box[3] - focus_core_box[1] + ) + + changed_pixels = 0 + focus_changed_pixels = 0 + focus_core_changed_pixels = 0 + focus_core_max_channel_delta = 0 + outside_changed_pixels = 0 + outside_toward_white_pixels = 0 + outside_away_from_white_pixels = 0 + max_channel_delta = 0 + left_pixels = ( + left_image.get_flattened_data() + if hasattr(left_image, "get_flattened_data") + else left_image.getdata() + ) + right_pixels = ( + right_image.get_flattened_data() + if hasattr(right_image, "get_flattened_data") + else right_image.getdata() + ) + focus_x0, focus_y0, focus_x1, focus_y1 = focus_box + for index, (left_pixel, right_pixel) in enumerate( + zip(left_pixels, right_pixels) + ): + channel_delta = max( + abs(int(left_pixel[channel]) - int(right_pixel[channel])) + for channel in range(4) + ) + if not channel_delta: + continue + changed_pixels += 1 + max_channel_delta = max(max_channel_delta, channel_delta) + y, x = divmod(index, image_width) + if focus_x0 <= x < focus_x1 and focus_y0 <= y < focus_y1: + focus_changed_pixels += 1 + if ( + focus_core_box[0] <= x < focus_core_box[2] + and focus_core_box[1] <= y < focus_core_box[3] + ): + focus_core_changed_pixels += 1 + focus_core_max_channel_delta = max( + focus_core_max_channel_delta, channel_delta + ) + continue + outside_changed_pixels += 1 + before_white_distance = sum( + 255 - int(value) for value in left_pixel[:3] + ) + after_white_distance = sum( + 255 - int(value) for value in right_pixel[:3] + ) + if after_white_distance + 2 < before_white_distance: + outside_toward_white_pixels += 1 + elif after_white_distance > before_white_distance + 2: + outside_away_from_white_pixels += 1 + return { + "different_pixels": changed_pixels, + "focus_changed_pixels": focus_changed_pixels, + "focus_core_pixels": focus_core_pixels, + "focus_core_changed_pixels": focus_core_changed_pixels, + "focus_core_max_channel_delta": focus_core_max_channel_delta, + "outside_changed_pixels": outside_changed_pixels, + "outside_toward_white_pixels": outside_toward_white_pixels, + "outside_away_from_white_pixels": outside_away_from_white_pixels, + "max_channel_delta": max_channel_delta, + "dimensions": [image_width, image_height], + "focus_box": list(focus_box), + "focus_core_box": list(focus_core_box), + } + except Exception as exc: + return {"error": f"Could not measure raster spotlight pixels: {exc}"} + + +def raster_hover_spotlight_valid(proxy: Any, delta: Any) -> bool: + if not isinstance(proxy, dict) or not screenshot_pixel_delta_valid(delta): + return False + border_color = str(proxy.get("borderColor") or "") + color_match = re.fullmatch( + r"rgba?\(\s*([0-9.]+)[, ]+\s*([0-9.]+)[, ]+\s*([0-9.]+)(?:\s*[,/]\s*([0-9.]+))?\s*\)", + border_color, + ) + if not color_match: + return False + red, green, blue = (float(color_match.group(index)) for index in range(1, 4)) + alpha = float(color_match.group(4) or 1) + outside_changed = int(delta.get("outside_changed_pixels") or 0) + toward_white = int(delta.get("outside_toward_white_pixels") or 0) + away_from_white = int(delta.get("outside_away_from_white_pixels") or 0) + focus_core_pixels = int(delta.get("focus_core_pixels") or 0) + focus_core_changed = int(delta.get("focus_core_changed_pixels") or 0) + focus_core_max_delta = int(delta.get("focus_core_max_channel_delta") or 0) + return bool( + min(red, green, blue) >= 240 + and max(red, green, blue) - min(red, green, blue) <= 4 + and alpha >= 0.8 + and str(proxy.get("borderStyle") or "") == "solid" + and 1 <= float(proxy.get("borderWidth") or 0) <= 3 + and str(proxy.get("boxShadow") or "") != "none" + and focus_core_pixels >= 64 + and focus_core_changed <= 4 + and focus_core_max_delta <= 1 + and outside_changed >= 256 + and toward_white >= max(128, int(outside_changed * 0.75)) + and away_from_white <= max(128, int(outside_changed * 0.2)) + ) + + +def raster_proxy_state_valid(proxy: Any, *, section: str) -> bool: + if not isinstance(proxy, dict): + return False + dimensions = ( + proxy.get("width"), + proxy.get("height"), + proxy.get("clippedWidth"), + proxy.get("clippedHeight"), + ) + if not all( + isinstance(value, (int, float)) + and not isinstance(value, bool) + and value >= 2 + for value in dimensions + ): + return False + width, height, clipped_width, clipped_height = dimensions + return bool( + proxy.get("display") == "block" + and proxy.get("opacity") == "1" + and not proxy.get("clickable") + and proxy.get("section") == section + and proxy.get("targetSection") == section + and abs(width - clipped_width) <= 1 + and abs(height - clipped_height) <= 1 + ) + + +def png_bytes_dimensions(data: bytes) -> tuple[int, int] | None: + if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n" or data[12:16] != b"IHDR": + return None + width, height = struct.unpack(">II", data[16:24]) + return (width, height) if width > 0 and height > 0 else None + + +def stable_locator_screenshot(page: Any, locator: Any) -> bytes: + """Wait out tiny compositor changes before using pixels as an idle baseline.""" + previous = locator.screenshot(type="png", animations="disabled") + for _ in range(4): + page.wait_for_timeout(100) + current = locator.screenshot(type="png", animations="disabled") + delta = screenshot_pixel_delta(previous, current) + if not screenshot_pixel_delta_valid(delta): + previous = current + continue + changed = int(delta.get("different_pixels") or 0) + max_delta = int(delta.get("max_channel_delta") or 0) + if changed == 0 or (0 < changed <= 4 and max_delta <= 1): + return current + previous = current + return previous + + +def validate_visible_poster_highlights( + page: Any, + frame: Any, + findings: list[dict[str, Any]], + *, + label: str, +) -> None: + target_info = frame.evaluate( + """() => { + document.querySelectorAll('[data-paper-reel-qa-target]').forEach( + el => el.removeAttribute('data-paper-reel-qa-target') + ); + const candidates = Array.from(document.querySelectorAll('[data-section].paper-reel-clickable')) + .filter(el => !el.matches('button, a, .listen-btn, .listen-title, .listen-all')) + .filter(el => !el.closest('.titlebar')) + .filter(el => { + const r = el.getBoundingClientRect(); + return r.width > 40 && r.height > 30; + }); + const el = candidates[0]; + if (!el) return null; + el.setAttribute('data-paper-reel-qa-target', '1'); + return { + section:el.getAttribute('data-section') || '', + rasterFallback:document.documentElement.getAttribute('data-paper-reel-raster-fallback') === '1' + }; + }""" + ) + if not isinstance(target_info, dict) or not target_info.get("section"): + add_finding( + findings, + "ERROR", + "NO_POSTER_HIGHLIGHT_TARGET", + f"No poster section was available for the {label} visible-pixel highlight gate.", + ) + return + + target = frame.locator('[data-paper-reel-qa-target="1"]') + target.evaluate( + "el => el.scrollIntoView({behavior:'instant', block:'center', inline:'center'})" + ) + page.wait_for_timeout(500) + before = stable_locator_screenshot(page, target) + raster_surface = ( + frame.locator("#poster-history-pixel-layer") + if target_info.get("rasterFallback") + else None + ) + raster_before = ( + stable_locator_screenshot(page, raster_surface) + if raster_surface is not None + else None + ) + hover_state = target.evaluate( + """el => { + const r = el.getBoundingClientRect(); + el.dispatchEvent(new MouseEvent('mouseenter', { + bubbles:true, clientX:r.left + 8, clientY:r.top + 8, view:window + })); + const tip = document.getElementById('paperReelTip'); + if (tip) tip.style.opacity = '0'; + return { + section:el.getAttribute('data-section') || '', + classApplied:el.classList.contains('paper-reel-hover'), + bodyClass:document.body.classList.contains('paper-reel-has-hover') + }; + }""" + ) + page.wait_for_timeout(250) + after_hover = target.screenshot(type="png", animations="disabled") + raster_after_hover = ( + raster_surface.screenshot(type="png", animations="disabled") + if raster_surface is not None + else None + ) + hover_delta = screenshot_pixel_delta(before, after_hover) + hover_proxy = target.evaluate( + """el => { + const proxy = document.getElementById('paperReelHoverProxy'); + if (!proxy) return null; + const rect = proxy.getBoundingClientRect(); + const targetRect = el.getBoundingClientRect(); + const layer = document.getElementById('poster-history-pixel-layer'); + const layerRect = layer ? layer.getBoundingClientRect() : null; + const clippedWidth = layerRect ? Math.max(0, + Math.min(innerWidth, layerRect.right, targetRect.right) - + Math.max(0, layerRect.left, targetRect.left) + ) : 0; + const clippedHeight = layerRect ? Math.max(0, + Math.min(innerHeight, layerRect.bottom, targetRect.bottom) - + Math.max(0, layerRect.top, targetRect.top) + ) : 0; + return { + display:getComputedStyle(proxy).display, + opacity:getComputedStyle(proxy).opacity, + borderColor:getComputedStyle(proxy).borderTopColor, + borderStyle:getComputedStyle(proxy).borderTopStyle, + borderWidth:parseFloat(getComputedStyle(proxy).borderTopWidth) || 0, + boxShadow:getComputedStyle(proxy).boxShadow, + width:rect.width, + height:rect.height, + clippedWidth, + clippedHeight, + relativeLeft:layerRect ? rect.left - layerRect.left : 0, + relativeTop:layerRect ? rect.top - layerRect.top : 0, + layerWidth:layerRect ? layerRect.width : 0, + layerHeight:layerRect ? layerRect.height : 0, + section:proxy.dataset.paperReelSection || '', + targetSection:el.getAttribute('data-section') || '', + clickable:proxy.classList.contains('paper-reel-clickable') + }; + }""" + ) + hover_delta_valid = screenshot_pixel_delta_valid(hover_delta) + hover_changed = int(hover_delta.get("different_pixels") or 0) if hover_delta_valid else -1 + hover_minimum_changed_pixels = 64 if target_info.get("rasterFallback") else 1 + hover_spotlight_delta = ( + screenshot_spotlight_delta(raster_before, raster_after_hover, hover_proxy) + if raster_before is not None and raster_after_hover is not None + else None + ) + if ( + not isinstance(hover_state, dict) + or not hover_state.get("classApplied") + or not hover_state.get("bodyClass") + or ( + target_info.get("rasterFallback") + and not raster_proxy_state_valid( + hover_proxy, + section=str(target_info.get("section") or ""), + ) + ) + or ( + target_info.get("rasterFallback") + and not raster_hover_spotlight_valid(hover_proxy, hover_spotlight_delta) + ) + or not hover_delta_valid + or hover_changed < hover_minimum_changed_pixels + ): + add_finding( + findings, + "ERROR", + "POSTER_HOVER_NOT_VISUALLY_RENDERED", + "Poster hover must render a visible native highlight or a neutral historical-raster spotlight.", + data={ + "mode": label, + "state": hover_state, + "proxy": hover_proxy, + "delta": hover_delta, + "spotlight_delta": hover_spotlight_delta, + }, + ) + + target.evaluate( + """el => { + el.dispatchEvent(new MouseEvent('mouseleave', {bubbles:true, view:window})); + const tip = document.getElementById('paperReelTip'); + if (tip) tip.style.opacity = '0'; + }""" + ) + page.wait_for_timeout(250) + after_leave = stable_locator_screenshot(page, target) + leave_delta = screenshot_pixel_delta(before, after_leave) + raster_after_leave = ( + stable_locator_screenshot(page, raster_surface) + if raster_surface is not None + else None + ) + raster_leave_delta = ( + screenshot_pixel_delta(raster_before, raster_after_leave) + if raster_before is not None and raster_after_leave is not None + else None + ) + leave_delta_valid = screenshot_pixel_delta_valid(leave_delta) + leave_changed = int(leave_delta.get("different_pixels") or 0) if leave_delta_valid else -1 + leave_restore_bad = not leave_delta_valid + if leave_delta_valid: + if target_info.get("rasterFallback"): + leave_restore_bad = bool( + leave_changed != 0 + or not screenshot_pixel_delta_valid(raster_leave_delta) + or int(raster_leave_delta.get("different_pixels") or 0) != 0 + ) + else: + leave_restore_bad = bool( + leave_changed > 4 or int(leave_delta["max_channel_delta"]) > 1 + ) + if leave_restore_bad: + add_finding( + findings, + "ERROR", + "POSTER_HOVER_IDLE_NOT_RESTORED", + "Poster hover did not return to the exact idle pixels after mouseleave.", + data={ + "mode": label, + "section": target_info.get("section"), + "delta": leave_delta, + "raster_delta": raster_leave_delta, + }, + ) + + flash_before = stable_locator_screenshot(page, target) + page.evaluate("section => flashPosterSection(section)", target_info["section"]) + page.wait_for_timeout(180) + after_flash = target.screenshot(type="png", animations="disabled") + flash_delta = screenshot_pixel_delta(flash_before, after_flash) + flash_proxy = target.evaluate( + """el => { + const proxy = document.getElementById('paperReelFlashProxy'); + if (!proxy) return null; + const rect = proxy.getBoundingClientRect(); + const targetRect = el.getBoundingClientRect(); + const layer = document.getElementById('poster-history-pixel-layer'); + const layerRect = layer ? layer.getBoundingClientRect() : null; + const clippedWidth = layerRect ? Math.max(0, + Math.min(innerWidth, layerRect.right, targetRect.right) - + Math.max(0, layerRect.left, targetRect.left) + ) : 0; + const clippedHeight = layerRect ? Math.max(0, + Math.min(innerHeight, layerRect.bottom, targetRect.bottom) - + Math.max(0, layerRect.top, targetRect.top) + ) : 0; + return { + display:getComputedStyle(proxy).display, + opacity:getComputedStyle(proxy).opacity, + width:rect.width, + height:rect.height, + clippedWidth, + clippedHeight, + section:proxy.dataset.paperReelSection || '', + targetSection:el.getAttribute('data-section') || '', + clickable:proxy.classList.contains('paper-reel-clickable') + }; + }""" + ) + flash_delta_valid = screenshot_pixel_delta_valid(flash_delta) + flash_changed = int(flash_delta.get("different_pixels") or 0) if flash_delta_valid else -1 + flash_minimum_changed_pixels = 64 if target_info.get("rasterFallback") else 1 + if ( + ( + target_info.get("rasterFallback") + and not raster_proxy_state_valid( + flash_proxy, + section=str(target_info.get("section") or ""), + ) + ) + or not flash_delta_valid + or flash_changed < flash_minimum_changed_pixels + ): + add_finding( + findings, + "ERROR", + "POSTER_FLASH_NOT_VISUALLY_RENDERED", + "Poster flash changed DOM state but did not produce visible pixels.", + data={"mode": label, "section": target_info.get("section"), "proxy": flash_proxy, "delta": flash_delta}, + ) + page.wait_for_timeout(1650) + after_flash_timeout = stable_locator_screenshot(page, target) + flash_restore_delta = screenshot_pixel_delta(flash_before, after_flash_timeout) + flash_restore_state = frame.evaluate( + """() => { + const proxy = document.getElementById('paperReelFlashProxy'); + return { + activeElements:document.querySelectorAll('.paper-reel-flash').length, + proxyDisplay:proxy ? getComputedStyle(proxy).display : 'missing' + }; + }""" + ) + flash_restore_delta_valid = screenshot_pixel_delta_valid(flash_restore_delta) + flash_restore_changed = ( + int(flash_restore_delta.get("different_pixels") or 0) + if flash_restore_delta_valid + else -1 + ) + flash_restore_bad = not flash_restore_delta_valid + if flash_restore_delta_valid: + flash_restore_bad = ( + ( + flash_restore_changed != 0 + or not isinstance(flash_restore_state, dict) + or int(flash_restore_state.get("activeElements") or 0) != 0 + or flash_restore_state.get("proxyDisplay") != "none" + ) + if target_info.get("rasterFallback") + else ( + flash_restore_changed > 32 + or int(flash_restore_delta["max_channel_delta"]) > 2 + or not isinstance(flash_restore_state, dict) + or int(flash_restore_state.get("activeElements") or 0) != 0 + ) + ) + if flash_restore_bad: + add_finding( + findings, + "ERROR", + "POSTER_FLASH_IDLE_NOT_RESTORED", + "Poster flash did not return to its idle DOM and pixel state after timeout.", + data={ + "mode": label, + "section": target_info.get("section"), + "state": flash_restore_state, + "delta": flash_restore_delta, + }, + ) + + +def historical_raster_state(frame: Any) -> dict[str, Any]: + state = frame.evaluate( + """() => { + const layer = document.getElementById('poster-history-pixel-layer'); + if (!layer) return {present:false}; + const host = layer.closest('[data-poster-history-pixel-host="1"]'); + const hover = document.getElementById('paperReelHoverProxy'); + const flash = document.getElementById('paperReelFlashProxy'); + const resolvedDensitySources = {}; + for (const candidate of (layer.getAttribute('srcset') || '').split(',')) { + const match = candidate.trim().match(/^(.*\\S)\\s+([123]x)$/); + if (!match) continue; + try { + resolvedDensitySources[match[2]] = new URL(match[1], document.baseURI).href; + } catch (e) {} + } + return { + present:true, + hostValid:Boolean(host), + src:layer.getAttribute('src') || '', + canonicalSrc:new URL(layer.getAttribute('src') || '', document.baseURI).href, + srcset:layer.getAttribute('srcset') || '', + resolvedDensitySources, + currentSrc:layer.currentSrc || '', + complete:layer.complete, + naturalWidth:layer.naturalWidth, + naturalHeight:layer.naturalHeight, + devicePixelRatio:window.devicePixelRatio, + cssWidth:layer.getBoundingClientRect().width, + cssHeight:layer.getBoundingClientRect().height, + densitySources:layer.getAttribute('data-paper-reel-density-sources') || '', + imageRendering:getComputedStyle(layer).imageRendering, + fallbackMode:document.documentElement.getAttribute('data-paper-reel-raster-fallback') || '', + hoverDisplay:hover ? getComputedStyle(hover).display : 'missing', + flashDisplay:flash ? getComputedStyle(flash).display : 'missing' + }; + }""" + ) + return state if isinstance(state, dict) else {"present": False} + + +def validate_historical_raster_dpr1( + frame: Any, + findings: list[dict[str, Any]], + *, + label: str, +) -> bool: + state = historical_raster_state(frame) + if not state.get("present"): + return False + srcset = str(state.get("srcset") or "") + density_sources = str(state.get("densitySources") or "") + current_src = str(state.get("currentSrc") or "") + has_density_sources = ( + "1x" in srcset + and "2x" in srcset + and "3x" in srcset + and density_sources == "1,2,3" + ) + if (srcset or density_sources) and not has_density_sources: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_DENSITY_SOURCES_MISSING", + "Historical poster raster must keep its canonical 1x src and provide 2x/3x PDF-derived sources.", + data={"mode": label, **state}, + ) + if not state.get("complete") or int(state.get("naturalWidth") or 0) < 1: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_1X_BROKEN", + "Historical poster raster did not load at DPR1.", + data={"mode": label, **state}, + ) + if current_src != str(state.get("canonicalSrc") or ""): + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_DPR1_SOURCE_CHANGED", + "DPR1 must continue to use the original canonical historical PNG.", + data={"mode": label, **state}, + ) + if ( + not state.get("hostValid") + or state.get("fallbackMode") != "1" + or state.get("hoverDisplay") != "none" + or state.get("flashDisplay") != "none" + ): + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_IDLE_STATE_BAD", + "Historical raster fallback must be detected while both highlight proxies remain hidden at idle.", + data={"mode": label, **state}, + ) + return has_density_sources + + +def validate_historical_raster_high_dpr( + browser: Any, + url: str, + findings: list[dict[str, Any]], + *, + label: str, +) -> None: + for dpr in (2, 3): + context = browser.new_context( + viewport={"width": 1440, "height": 900}, + device_scale_factor=dpr, + ) + try: + page = context.new_page() + page.goto(url, wait_until="domcontentloaded") + page.wait_for_timeout(1200) + frame = page.locator("#posterFrame").element_handle().content_frame() + if frame is None: + add_finding( + findings, + "ERROR", + "POSTER_IFRAME_NOT_LOADED", + f"Poster iframe did not load for the {label} DPR{dpr} density gate.", + ) + continue + frame.wait_for_selector("#poster-history-pixel-layer", state="attached", timeout=5000) + frame.wait_for_function( + """() => { + const layer = document.getElementById('poster-history-pixel-layer'); + return !!(layer && layer.complete && layer.naturalWidth > 0 && layer.currentSrc); + }""", + timeout=10000, + ) + state = historical_raster_state(frame) + layer_png = frame.locator("#poster-history-pixel-layer").screenshot( + type="png", + animations="disabled", + ) + screenshot_dimensions = png_bytes_dimensions(layer_png) + css_width = float(state.get("cssWidth") or 0) + css_height = float(state.get("cssHeight") or 0) + expected_dimensions = (round(css_width * dpr), round(css_height * dpr)) + physical_size_ok = bool( + screenshot_dimensions + and abs(screenshot_dimensions[0] - expected_dimensions[0]) <= 2 + and abs(screenshot_dimensions[1] - expected_dimensions[1]) <= 2 + ) + resolved_density_sources = state.get("resolvedDensitySources") + expected_current_src = ( + resolved_density_sources.get(f"{dpr}x") + if isinstance(resolved_density_sources, dict) + else None + ) + if ( + not isinstance(expected_current_src, str) + or not expected_current_src + or str(state.get("currentSrc") or "") != expected_current_src + ): + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_WRONG_DENSITY_SOURCE", + f"Historical raster did not select its {dpr}x source at DPR{dpr}.", + data={ + "mode": label, + "dpr": dpr, + "expectedCurrentSrc": expected_current_src, + **state, + }, + ) + if not physical_size_ok or float(state.get("devicePixelRatio") or 0) != dpr: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_PHYSICAL_SIZE_WRONG", + f"Historical raster screenshot did not render at DPR{dpr} physical dimensions.", + data={ + "mode": label, + "dpr": dpr, + "expected": list(expected_dimensions), + "actual": list(screenshot_dimensions) if screenshot_dimensions else None, + **state, + }, + ) + if state.get("imageRendering") != "auto": + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_HIGH_DPR_PIXELATED", + "High-DPI historical raster must use image-rendering: auto.", + data={"mode": label, "dpr": dpr, **state}, + ) + except Exception as exc: + add_finding( + findings, + "ERROR", + "HISTORICAL_RASTER_DENSITY_GATE_FAILED", + f"Could not validate the {label} DPR{dpr} historical raster source.", + data={"error": str(exc)}, + ) + finally: + context.close() + + def browser_gate(viewer_dir: Path, screenshot: Path | None = None, *, contract: dict[str, Any] | None = None) -> list[dict[str, Any]]: findings: list[dict[str, Any]] = [] contract = contract or load_contract() @@ -1131,6 +2422,7 @@ def browser_gate(viewer_dir: Path, screenshot: Path | None = None, *, contract: with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={"width": 1440, "height": 900}) + has_historical_raster = False page.goto(url, wait_until="domcontentloaded") page.wait_for_timeout(1000) @@ -1224,6 +2516,8 @@ def browser_gate(viewer_dir: Path, screenshot: Path | None = None, *, contract: else: frame.wait_for_selector("[data-section]", state="attached", timeout=5000) frame.wait_for_selector("[data-section].paper-reel-clickable, .titlebar.paper-reel-clickable", state="attached", timeout=5000) + has_historical_raster = validate_historical_raster_dpr1(frame, findings, label="http") + validate_visible_poster_highlights(page, frame, findings, label="http") sid = frame.evaluate( """() => { const candidates = Array.from(document.querySelectorAll('[data-section]')) @@ -1318,6 +2612,8 @@ def browser_gate(viewer_dir: Path, screenshot: Path | None = None, *, contract: if screenshot: screenshot.parent.mkdir(parents=True, exist_ok=True) page.screenshot(path=str(screenshot), full_page=True) + if has_historical_raster: + validate_historical_raster_high_dpr(browser, url, findings, label="http") browser.close() except Exception as exc: add_finding(findings, "ERROR", "BROWSER_GATE_EXCEPTION", f"Browser reel gate failed: {exc}") @@ -1353,6 +2649,7 @@ def file_browser_gate(viewer_dir: Path, screenshot: Path | None = None, *, contr with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={"width": 1440, "height": 900}) + has_historical_raster = False page.goto(html_path.as_uri(), wait_until="domcontentloaded") page.wait_for_timeout(1200) @@ -1419,6 +2716,7 @@ def file_browser_gate(viewer_dir: Path, screenshot: Path | None = None, *, contr else: frame.wait_for_selector("[data-section]", state="attached", timeout=5000) frame.wait_for_selector("[data-section].paper-reel-clickable, .titlebar.paper-reel-clickable", state="attached", timeout=5000) + has_historical_raster = validate_historical_raster_dpr1(frame, findings, label="file") base_uri = frame.evaluate("() => document.baseURI") if "/assets/poster/" not in str(base_uri): add_finding(findings, "ERROR", "FILE_POSTER_BASE_URI_WRONG", "srcdoc poster must set base href to assets/poster/ so relative resources resolve.", data={"baseURI": base_uri}) @@ -1431,6 +2729,7 @@ def file_browser_gate(viewer_dir: Path, screenshot: Path | None = None, *, contr if broken_poster_images: add_finding(findings, "ERROR", "FILE_POSTER_IMAGE_BROKEN", "file-open poster has broken images.", data={"broken": broken_poster_images[:10]}) + validate_visible_poster_highlights(page, frame, findings, label="file") hover_result = frame.evaluate( """() => { const candidates = Array.from(document.querySelectorAll('[data-section].paper-reel-clickable')) @@ -1563,6 +2862,8 @@ def file_browser_gate(viewer_dir: Path, screenshot: Path | None = None, *, contr if screenshot: screenshot.parent.mkdir(parents=True, exist_ok=True) page.screenshot(path=str(screenshot), full_page=True) + if has_historical_raster: + validate_historical_raster_high_dpr(browser, html_path.as_uri(), findings, label="file") browser.close() except Exception as exc: add_finding(findings, "ERROR", "FILE_BROWSER_GATE_EXCEPTION", f"File browser reel gate failed: {exc}")