Skip to content

Commit e84da73

Browse files
feat(web): real paper titles + fast figure thumbnails
- Fetch arXiv titles into data/paper_titles.json; cards and the drawer show the title with the id chip, and hide the chip when there is no distinct title (was rendering the id twice). - Pre-render small WebP figure/table thumbnails (scripts/render_figure_thumbs.py), served from the existing /pages mount; the gallery and paper drawer load these instead of full-page images, with a page CSS-crop fallback when a thumb is absent. - Drawer figure thumbnails now open the full-resolution page with the region boxed. - fetch_paper_titles.py: force UTF-8 stdout so the run survives non-ASCII titles on a Windows console.
1 parent 4c9c1d1 commit e84da73

7 files changed

Lines changed: 189 additions & 11 deletions

File tree

data/paper_titles.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"2604.22753v1": "Spend Less, Fit Better: Budget-Efficient Scaling Law Fitting via Active Experiment Selection",
3+
"2604.27742v1": "Linear-Core Surrogates: Smooth Loss Functions with Linear Rates for Classification and Structured Prediction",
4+
"2604.27883v1": "Decoupled Descent: Exact Test Error Tracking Via Approximate Message Passing",
5+
"2604.28144v1": "Global Optimality for Constrained Exploration via Penalty Regularization",
6+
"2604.28149v1": "Explainable Load Forecasting with Covariate-Informed Time Series Foundation Models",
7+
"2604.28159v1": "Continuous-tone Simple Points: An $\\ell_0$-Norm of Cyclic Gradient for Topology-Preserving Data-Driven Image Segmentation",
8+
"2604.28169v1": "PhyCo: Learning Controllable Physical Priors for Generative Motion",
9+
"2604.28173v1": "Action Motifs: Self-Supervised Hierarchical Representation of Human Body Movements",
10+
"2604.28175v1": "Strait: Perceiving Priority and Interference in ML Inference Serving",
11+
"2604.28176v1": "Defending Quantum Classifiers against Adversarial Perturbations through Quantum Autoencoders",
12+
"2604.28177v1": "AEGIS: A Holistic Benchmark for Evaluating Forensic Analysis of AI-Generated Academic Images",
13+
"2604.28180v1": "An adaptive wavelet-based PINN for problems with localized high-magnitude source",
14+
"2604.28181v1": "Synthetic Computers at Scale for Long-Horizon Productivity Simulation",
15+
"2604.28182v1": "Exploration Hacking: Can LLMs Learn to Resist RL Training?",
16+
"2604.28186v1": "Computing Equilibrium beyond Unilateral Deviation",
17+
"2604.28190v1": "Representation Fréchet Loss for Visual Generation",
18+
"2604.28192v1": "LaST-R1: Reinforcing Action via Adaptive Physical Latent Reasoning for VLA Models",
19+
"2604.28193v1": "Generalizable Sparse-View 3D Reconstruction from Unconstrained Images",
20+
"2604.28196v1": "HERMES++: Toward a Unified Driving World Model for 3D Scene Understanding and Generation",
21+
"2604.28197v1": "OmniRobotHome: A Multi-Camera Platform for Real-Time Multiadic Human-Robot Interaction"
22+
}

scripts/fetch_paper_titles.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import asyncio
1717
import json
1818
import re
19+
import sys
1920
import xml.etree.ElementTree as ET
2021
from pathlib import Path
2122

@@ -77,6 +78,10 @@ async def fetch_all(paper_ids: list[str]) -> dict[str, str]:
7778

7879

7980
def main() -> None:
81+
# Windows consoles default to cp1252; the status line and arxiv titles carry
82+
# non-ASCII (≈, Greek, accents) that crash the prints without this.
83+
if hasattr(sys.stdout, "reconfigure"):
84+
sys.stdout.reconfigure(encoding="utf-8")
8085
parser = argparse.ArgumentParser(description=__doc__)
8186
parser.add_argument(
8287
"--pages-dir",

scripts/render_figure_thumbs.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""Pre-render small WebP thumbnails for every gallery figure/table.
2+
3+
The web UI's figure thumbnails were CSS-crops of the *full* page PNG (~0.5 MB
4+
each), so opening a paper with many figures pulled megabytes before anything
5+
showed. This renders a small thumbnail per gallery item once, served statically
6+
from the existing ``/pages`` mount — the thumbnail becomes a plain ``<img>``.
7+
8+
Source per item:
9+
- figure: downscale the Docling crop already on disk (``data/figures/<paper>/
10+
<id>.png``; ``id`` is the chunk_id with ``:`` → ``_``).
11+
- table (no crop) or a figure whose crop is missing: crop the bbox region out
12+
of the page render (``data/pages/<paper>/<paper>_p<N>.png``) — pages render
13+
at 150 DPI, bbox is in PDF points (1/72"), so px = pt * 150/72.
14+
15+
Output: ``<pages_dir>/<paper>/thumbs/<id>.webp`` (committed with the page PNGs so
16+
the self-contained prod image bundles them). Items are read from a running
17+
server's ``/figures`` so the set matches exactly what the gallery shows
18+
(decoration noise already filtered).
19+
20+
Run (server up): ``uv run python -m scripts.render_figure_thumbs``
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import argparse
26+
import json
27+
import sys
28+
import urllib.request
29+
from pathlib import Path
30+
31+
from PIL import Image
32+
33+
_DPI = 150 # render_pages default; bbox points → px is pt * _DPI/72
34+
_PT_TO_PX = _DPI / 72.0
35+
36+
37+
def _safe_id(chunk_id: str) -> str:
38+
# Mirror docling_parser._safe_filename so the crop file / thumb name match.
39+
return chunk_id.replace(":", "_")
40+
41+
42+
def _fetch_items(base_url: str) -> list[dict[str, object]]:
43+
with urllib.request.urlopen(f"{base_url}/figures?limit=1000", timeout=30) as resp:
44+
items: list[dict[str, object]] = json.load(resp)
45+
return items
46+
47+
48+
def _thumb_from_crop(crop_path: Path, max_px: int) -> Image.Image:
49+
img = Image.open(crop_path).convert("RGB")
50+
img.thumbnail((max_px, max_px))
51+
return img
52+
53+
54+
def _thumb_from_page(page_path: Path, bbox: list[float], max_px: int) -> Image.Image:
55+
img = Image.open(page_path).convert("RGB")
56+
x0, y0, x1, y1 = (v * _PT_TO_PX for v in bbox)
57+
# Clamp to the page so a slightly-oversized bbox can't error.
58+
x0, y0 = max(0, x0), max(0, y0)
59+
x1, y1 = min(img.width, x1), min(img.height, y1)
60+
crop = img.crop((round(x0), round(y0), round(x1), round(y1)))
61+
crop.thumbnail((max_px, max_px))
62+
return crop
63+
64+
65+
def main() -> None:
66+
if hasattr(sys.stdout, "reconfigure"):
67+
sys.stdout.reconfigure(encoding="utf-8")
68+
p = argparse.ArgumentParser(description=__doc__)
69+
p.add_argument("--base-url", default="http://127.0.0.1:8000", help="Running server.")
70+
p.add_argument("--pages-dir", type=Path, default=Path("data/pages"))
71+
p.add_argument("--figures-dir", type=Path, default=Path("data/figures"))
72+
p.add_argument("--max-px", type=int, default=512, help="Longest thumbnail edge.")
73+
p.add_argument("--quality", type=int, default=80, help="WebP quality.")
74+
args = p.parse_args()
75+
76+
items = _fetch_items(args.base_url)
77+
print(f"Rendering thumbnails for {len(items)} gallery items...")
78+
written = page_fallback = skipped = 0
79+
for it in items:
80+
paper, page = str(it["paper_id"]), it["page_number"]
81+
bbox_raw = it.get("bbox")
82+
bbox = bbox_raw if isinstance(bbox_raw, list) else None
83+
sid = _safe_id(str(it["chunk_id"]))
84+
out_dir = args.pages_dir / paper / "thumbs"
85+
out_dir.mkdir(parents=True, exist_ok=True)
86+
out = out_dir / f"{sid}.webp"
87+
88+
crop_path = args.figures_dir / paper / f"{sid}.png"
89+
try:
90+
if crop_path.exists():
91+
thumb = _thumb_from_crop(crop_path, args.max_px)
92+
elif bbox:
93+
page_path = args.pages_dir / paper / f"{paper}_p{page}.png"
94+
if not page_path.exists():
95+
skipped += 1
96+
continue
97+
thumb = _thumb_from_page(page_path, bbox, args.max_px)
98+
page_fallback += 1
99+
else:
100+
skipped += 1
101+
continue
102+
thumb.save(out, "WEBP", quality=args.quality, method=6)
103+
written += 1
104+
except Exception as exc: # one bad item shouldn't abort the whole run
105+
print(f" WARN {it['chunk_id']}: {exc}")
106+
skipped += 1
107+
108+
print(f"Wrote {written} thumbnails ({page_fallback} cropped from page), {skipped} skipped.")
109+
110+
111+
if __name__ == "__main__":
112+
main()

web/app/api.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,15 @@
4141
return `${ORIGIN}/pages/${encodeURIComponent(paperId)}/${encodeURIComponent(paperId)}_p${page}.png`;
4242
}
4343

44+
// Pre-rendered figure/table thumbnail (scripts/render_figure_thumbs.py). The
45+
// file is keyed by chunk_id with ":" → "_" (mirrors the Docling crop name).
46+
// Small WebP served from the same /pages mount; callers fall back to a
47+
// full-page CSS-crop when a thumb is absent (e.g. a freshly uploaded paper).
48+
function figThumbUrl(paperId, chunkId) {
49+
const safe = chunkId.replace(/:/g, "_");
50+
return `${ORIGIN}/pages/${encodeURIComponent(paperId)}/thumbs/${encodeURIComponent(safe)}.webp`;
51+
}
52+
4453
async function loadPapers() {
4554
try {
4655
const r = await fetch("/papers");
@@ -512,6 +521,7 @@
512521
SUGGESTIONS,
513522
supportsVision,
514523
pageImageUrl,
524+
figThumbUrl,
515525
loadPapers,
516526
loadHealth,
517527
loadFigures,

web/app/figures.jsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,9 @@ function splitCaptionData(text) {
6868
// Crop a 150-DPI page image to a figure's PDF-point bbox. Computes the crop
6969
// transform from the image's natural size on load; falls back to the full page
7070
// width until then (and when a chunk has no bbox).
71-
function FigCrop({ url, bbox, fallbackH = 150 }) {
71+
function FigCrop({ url, bbox, fallbackH = 150, eager = false, thumb = null }) {
7272
const [s, setS] = useState(null);
73+
const [thumbFailed, setThumbFailed] = useState(false);
7374
const onLoad = (e) => {
7475
const img = e.target;
7576
const nW = img.naturalWidth, nH = img.naturalHeight;
@@ -85,12 +86,23 @@ function FigCrop({ url, bbox, fallbackH = 150 }) {
8586
aspect: (fw * nW) / (fh * nH),
8687
});
8788
};
89+
// Prefer the pre-rendered thumbnail (a small WebP of the crop) — a plain
90+
// <img>, no full-page download. Fall back to the page CSS-crop if it 404s
91+
// (e.g. a freshly uploaded paper whose thumb hasn't been rendered).
92+
if (thumb && !thumbFailed) {
93+
return (
94+
<div className="fig-crop" style={{ position: "relative", width: "100%", overflow: "hidden", background: "var(--panel-2)" }}>
95+
<img src={thumb} alt="" loading={eager ? "eager" : "lazy"} decoding="async"
96+
onError={() => setThumbFailed(true)} style={{ width: "100%", display: "block" }} />
97+
</div>
98+
);
99+
}
88100
return (
89101
<div className="fig-crop"
90102
style={s
91103
? { position: "relative", width: "100%", aspectRatio: String(s.aspect), overflow: "hidden", background: "#fff" }
92-
: { position: "relative", width: "100%", height: fallbackH, overflow: "hidden", background: "#fff" }}>
93-
<img src={url} alt="" loading="lazy" onLoad={onLoad}
104+
: { position: "relative", width: "100%", height: fallbackH, overflow: "hidden", background: "var(--panel-2)" }}>
105+
<img src={url} alt="" loading={eager ? "eager" : "lazy"} decoding="async" onLoad={onLoad}
94106
style={s
95107
? { position: "absolute", width: s.widthPct + "%", left: s.leftPct + "%", top: s.topPct + "%", maxWidth: "none" }
96108
: { width: "100%", display: "block" }} />
@@ -109,7 +121,7 @@ function FigureCard({ f, onOpen }) {
109121
const hasCap = name && !/^\[.+\]$/.test(name.trim());
110122
return (
111123
<button className="figure-card" onClick={() => onOpen(f)}>
112-
<FigCrop url={f.page_image_url} bbox={f.bbox} />
124+
<FigCrop url={f.page_image_url} bbox={f.bbox} thumb={window.RAG.figThumbUrl(f.paper_id, f.chunk_id)} />
113125
<div className="figure-card-body">
114126
{hasCap
115127
? (name.includes("$") || name.includes("\\("))

web/app/papers.jsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,18 @@
33
serves are shown (no fabricated authors/venue/citations). */
44

55
function PaperCard({ p, figCount, onOpen }) {
6+
// The id chip duplicates the heading whenever there's no real title — the
7+
// heading falls back to paper_id (data/paper_titles.json unpopulated). Show
8+
// the chip only when a distinct title exists, so the id appears once.
9+
const hasTitle = p.title && p.title.trim() && p.title.trim() !== p.paper_id;
610
return (
711
<button className="paper-card" onClick={() => onOpen(p)}>
8-
<div className="paper-card-top">
9-
<span className="mono paper-id">{p.paper_id}</span>
10-
{p.is_arxiv && <span className="paper-venue">arXiv</span>}
11-
</div>
12+
{(hasTitle || p.is_arxiv) && (
13+
<div className="paper-card-top">
14+
{hasTitle && <span className="mono paper-id">{p.paper_id}</span>}
15+
{p.is_arxiv && <span className="paper-venue">arXiv</span>}
16+
</div>
17+
)}
1218
<h3 className="serif paper-title">{p.title || p.paper_id}</h3>
1319
<div className="paper-stats">
1420
<span className="metric"><Icon name="papers" size={12} /> <b>{p.page_count}</b> pages</span>
@@ -26,18 +32,21 @@ function drawerCaption(raw) {
2632
}
2733

2834
function PaperDrawer({ p, figs, onClose }) {
35+
const [pageItem, setPageItem] = useState(null);
2936
useEffect(() => {
3037
if (!p) return;
3138
const onEsc = (e) => { if (e.key === "Escape") onClose(); };
3239
document.addEventListener("keydown", onEsc);
3340
return () => document.removeEventListener("keydown", onEsc);
3441
}, [p, onClose]);
3542
if (!p) return null;
43+
const hasTitle = p.title && p.title.trim() && p.title.trim() !== p.paper_id;
3644
return (
45+
<React.Fragment>
3746
<div className="drawer-scrim" onClick={onClose}>
3847
<div className="drawer rise-r" onClick={(e) => e.stopPropagation()}>
3948
<div className="drawer-head">
40-
<span className="mono paper-id">{p.paper_id}</span>
49+
{hasTitle && <span className="mono paper-id">{p.paper_id}</span>}
4150
<button className="btn ghost sm" onClick={onClose}><Icon name="x" size={15} /></button>
4251
</div>
4352
<div className="drawer-body">
@@ -59,8 +68,10 @@ function PaperDrawer({ p, figs, onClose }) {
5968
<h4 className="section-h">Indexed figures</h4>
6069
<div className="fig-grid-2">
6170
{figs.map((f) => (
62-
<div key={f.chunk_id} className="figthumb">
63-
<FigCrop url={f.page_image_url} bbox={f.bbox} fallbackH={92} />
71+
<div key={f.chunk_id} className="figthumb figthumb-click"
72+
onClick={() => setPageItem({ chunk_id: f.chunk_id, paper: f.paper_id, page: f.page_number, pages: [f.page_number], kind: "visual", bbox: f.bbox || null, text: f.caption || "" })}
73+
title="View source region on page">
74+
<FigCrop url={f.page_image_url} bbox={f.bbox} fallbackH={92} eager thumb={window.RAG.figThumbUrl(f.paper_id, f.chunk_id)} />
6475
<div className="figthumb-meta"><span className="mono">p.{f.page_number}</span> · {clip(drawerCaption(f.caption), 40)}</div>
6576
</div>
6677
))}
@@ -70,6 +81,8 @@ function PaperDrawer({ p, figs, onClose }) {
7081
</div>
7182
</div>
7283
</div>
84+
<PageRegionModal item={pageItem} onClose={() => setPageItem(null)} paperTitle={() => p.title || p.paper_id} />
85+
</React.Fragment>
7386
);
7487
}
7588

web/app/styles2.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
}
4545
.paper-card:hover { border-color: var(--accent-line); transform: translateY(-2px); box-shadow: var(--shadow); }
4646
.paper-card-top { display: flex; align-items: center; justify-content: space-between; }
47+
/* Keep the venue badge right when the id chip is hidden (title-less papers). */
48+
.paper-card-top .paper-venue { margin-left: auto; }
4749
.paper-id { font-size: 11px; color: var(--accent); }
4850
.paper-venue { font-size: 11px; color: var(--text-faint); font-family: "IBM Plex Mono", monospace; }
4951
.paper-title { margin: 0; font-size: 16px; font-weight: 500; line-height: 1.3; letter-spacing: -0.005em; }
@@ -56,6 +58,8 @@
5658
.drawer-scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; justify-content: flex-end; z-index: 100; backdrop-filter: blur(2px); }
5759
.drawer { width: 480px; max-width: 92vw; height: 100%; background: var(--panel); border-left: 1px solid var(--border); display: flex; flex-direction: column; box-shadow: -20px 0 60px rgba(0,0,0,0.35); }
5860
.drawer-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border-soft); flex: none; }
61+
/* Keep the close button right when the id chip is hidden (title-less papers). */
62+
.drawer-head .btn { margin-left: auto; }
5963
.drawer-body { padding: 22px 24px 40px; overflow-y: auto; }
6064
.drawer-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-top: 20px; }
6165
.ds { border: 1px solid var(--border-soft); border-radius: 10px; padding: 12px; background: var(--panel-2); display: flex; flex-direction: column; gap: 4px; }

0 commit comments

Comments
 (0)