Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,34 @@
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
that PPT renders as a system default."""
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:
Expand Down Expand Up @@ -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/
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
205 changes: 203 additions & 2 deletions ResearchStudio-Reel/skills/paper2poster/references/fit_logos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -422,11 +423,202 @@ def _load_approved_logo_paths(base: Path) -> tuple[bool, list[Path]]:
return true;}"""


@dataclass
class _SourceNode:
"""One source-HTML element with exact character offsets.

``HTMLParser`` is used only as a locator. Replacements are spliced into the
original string, rather than serialising the parsed document, so whitespace,
attribute quoting, scripts, MathJax output, and fitted body geometry outside
the explicitly controlled nodes remain byte-for-byte unchanged.
"""

tag: str
attrs: dict[str, str]
start: int
end: int | None
parent: _SourceNode | None


class _SourceSpanParser(HTMLParser):
"""Locate element spans without rewriting or executing the source HTML."""

_VOID_TAGS = frozenset({
"area", "base", "br", "col", "embed", "hr", "img", "input", "link",
"meta", "param", "source", "track", "wbr",
})

def __init__(self, source: str):
super().__init__(convert_charrefs=False)
self.source = source
self.nodes: list[_SourceNode] = []
self._stack: list[_SourceNode] = []
self._line_starts = [0]
for i, char in enumerate(source):
if char == "\n":
self._line_starts.append(i + 1)

def _offset(self) -> int:
line, column = self.getpos()
return self._line_starts[line - 1] + column

@staticmethod
def _attr_map(attrs) -> dict[str, str]:
return {name: (value or "") for name, value in attrs}

def _open_node(self, tag: str, attrs, end: int | None) -> _SourceNode:
node = _SourceNode(
tag=tag.lower(),
attrs=self._attr_map(attrs),
start=self._offset(),
end=end,
parent=self._stack[-1] if self._stack else None,
)
self.nodes.append(node)
return node

def handle_starttag(self, tag, attrs):
raw = self.get_starttag_text() or ""
open_end = self._offset() + len(raw)
node = self._open_node(tag, attrs, open_end if tag.lower() in self._VOID_TAGS else None)
if node.end is None:
self._stack.append(node)

def handle_startendtag(self, tag, attrs):
raw = self.get_starttag_text() or ""
self._open_node(tag, attrs, self._offset() + len(raw))

def handle_endtag(self, tag):
wanted = tag.lower()
match = next((i for i in range(len(self._stack) - 1, -1, -1)
if self._stack[i].tag == wanted), None)
if match is None:
return
start = self._offset()
close_end = self.source.find(">", start)
if close_end < 0:
return
node = self._stack[match]
node.end = close_end + 1
del self._stack[match:]


def _has_class(node: _SourceNode, name: str) -> bool:
return name in node.attrs.get("class", "").split()


def _scan_insert_anchor(nodes: list[_SourceNode]) -> _SourceNode | None:
"""Find the same source-side insertion anchor used by ``_SCAN_JS``."""
takeaway = next((
node for node in nodes
if node.attrs.get("data-section") == "takeaway"
and _has_class(node, "section")
), None)
if takeaway is not None:
return takeaway

def siblings(node):
return [candidate for candidate in nodes if candidate.parent is node.parent]

def last_child(node):
peer_nodes = siblings(node)
return bool(peer_nodes) and peer_nodes[-1] is node

def last_of_type(node):
same_tag = [peer for peer in siblings(node) if peer.tag == node.tag]
return bool(same_tag) and same_tag[-1] is node

def inside(node, ancestor):
parent = node.parent
while parent is not None:
if parent is ancestor:
return True
parent = parent.parent
return False

# Equivalent to document.querySelector('.col:last-of-type .section:last-child').
last_cols = [node for node in nodes if _has_class(node, "col") and last_of_type(node)]
anchor = next((
node for node in nodes
if _has_class(node, "section") and last_child(node)
and any(inside(node, col) for col in last_cols)
), None)
if anchor is not None:
return anchor

# Equivalent to document.querySelector('.section:last-of-type').
return next((
node for node in nodes
if _has_class(node, "section") and last_of_type(node)
), None)


def _merge_controlled_html(source: str, titlebars: list[str], scan_html: str | None,
sync_scan: bool) -> str:
"""Splice only fit_logos-owned live nodes into the unexecuted source HTML.

Loading the poster runs MathJax and the measured-fill scripts, which mutate
figures and sections in memory. Persisting ``page.content()`` therefore
bakes those transient measurements into the poster. This merger uses the
live page only for the header/logo/QR nodes owned by this script; every other
character comes directly from the input file.
"""
parser = _SourceSpanParser(source)
parser.feed(source)
parser.close()

source_titlebars = [node for node in parser.nodes if _has_class(node, "titlebar")]
if len(source_titlebars) != len(titlebars):
raise ValueError(
"titlebar count changed while fitting logos "
f"(source={len(source_titlebars)}, live={len(titlebars)})"
)

edits: list[tuple[int, int, str]] = []
for node, replacement in zip(source_titlebars, titlebars):
if node.end is None:
raise ValueError("cannot locate closing tag for source titlebar")
edits.append((node.start, node.end, replacement))

if sync_scan:
source_scans = [
node for node in parser.nodes
if node.attrs.get("data-section") == "scan-to-read"
and _has_class(node, "section")
]
if len(source_scans) > 1:
raise ValueError(f"expected at most one Scan-to-Read section, found {len(source_scans)}")
if source_scans:
scan = source_scans[0]
if scan.end is None:
raise ValueError("cannot locate closing tag for source Scan-to-Read section")
edits.append((scan.start, scan.end, scan_html or ""))
elif scan_html is not None:
anchor = _scan_insert_anchor(parser.nodes)
if anchor is None or anchor.end is None:
raise ValueError("cannot persist new Scan-to-Read section: insertion anchor not found")
edits.append((anchor.end, anchor.end, scan_html))

# Controlled nodes are siblings in all supported headers/templates. Reject
# overlap instead of risking an ambiguous rewrite if a future template nests
# one inside another.
ordered = sorted(edits)
for (_, previous_end, _), (next_start, _, _) in zip(ordered, ordered[1:]):
if next_start < previous_end:
raise ValueError("controlled HTML replacement spans overlap")

merged = source
for start, end, replacement in sorted(edits, reverse=True):
merged = merged[:start] + replacement + merged[end:]
return merged


def bake(poster_path, max_rows=3, pad_frac=0.06):
"""Measure each logo zone at true canvas scale, pack, and rewrite poster.html."""
from playwright.sync_api import sync_playwright
poster = Path(poster_path).resolve()
base = poster.parent
source_html = poster.read_bytes().decode("utf-8")
resolved_canvas = _canvas.resolve_canvas(poster, None, label="[fit_logos]")
if not resolved_canvas:
raise ValueError(f"cannot resolve @page canvas from {poster}")
Expand Down Expand Up @@ -556,8 +748,17 @@ def bake(poster_path, max_rows=3, pad_frac=0.06):
# so a project-only paper reads "Project" not "Paper", and drop any empty tile.
if qr_labels:
pg.evaluate(_STAMP_QR_LABELS_JS, qr_labels)
html = pg.content()
poster.write_text(html, encoding="utf-8")
controlled = pg.evaluate("""()=>({
titlebars:[...document.querySelectorAll('.titlebar')].map(node=>node.outerHTML),
scan:(document.querySelector('.section[data-section="scan-to-read"]')||{}).outerHTML||null
})""")
html = _merge_controlled_html(
source_html,
controlled["titlebars"],
controlled["scan"],
sync_scan=bool(relocate_qrs or qr_labels),
)
poster.write_bytes(html.encode("utf-8"))
br.close()
return baked

Expand Down
5 changes: 5 additions & 0 deletions ResearchStudio-Reel/skills/paper2poster/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Loading