feat(pdftract-2825c): add comparison mode support to inspector frontend

Phase 7.9.8: Comparison mode UI enhancements

- Added 9th layer toggle (diff overlay) for comparison mode
- Implemented side-by-side document comparison UI
- Added scroll sync between comparison panels
- Added diff overlay rendering (added/removed/changed blocks)
- Updated keyboard shortcuts to support 1-9 (was 1-8)
- Bundle size: 5.63 KB gzipped (still well under 80 KB limit)

Ref: pdftract-2825c

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-27 22:52:15 -04:00
parent 99317e9010
commit d70b4aa36e
3 changed files with 233 additions and 39 deletions

View file

@ -1,60 +1,212 @@
// pdftract inspector - Phase 7.9.3 frontend bundle
// Single-page vanilla web app, <80KB gzipped, no framework, no CDN
// Phase 7.9.8: Comparison mode support
const STORAGE_PREFIX='pdftract-inspector-';
const LAYERS=['spans','blocks','columns','reading-order','confidence-heatmap','ocr','mcid','anchors'];
const LAYER_KEYS=['spans','blocks','columns','reading_order','confidence_heatmap','ocr','mcid','anchors'];
const LAYERS=['spans','blocks','columns','reading-order','confidence-heatmap','ocr','mcid','anchors','diff'];
const LAYER_KEYS=['spans','blocks','columns','reading_order','confidence_heatmap','ocr','mcid','anchors','diff'];
let currentPage=0;
let totalPages=0;
let totalPagesA=0;
let totalPagesB=0;
let pageData=null;
let isComparisonMode=false;
let pageDiff=null;
let scrollSync=true;
function init(){loadLayerState();setupKeyboard();setupToggles();setupSearch();setupNav();loadFragment()}
function init(){loadLayerState();setupKeyboard();setupToggles();setupSearch();setupNav();setupComparisonMode();loadFragment()}
async function loadDocument(){
const res=await fetch('/api/document');
if(!res.ok)throw new Error('Failed to load document');
const data=await res.json();
totalPages=data.pages?.length||0;
totalPagesA=data.pages?.length||0;
// Check if comparison mode is active
const compareRes=await fetch('/api/compare/document');
if(compareRes.ok){
const compareData=await compareRes.json();
if(compareData.b){
isComparisonMode=true;
totalPagesB=compareData.b.pages?.length||0;
totalPages=Math.max(totalPagesA,totalPagesB);
showComparisonMode(true);
}else{
isComparisonMode=false;
totalPages=totalPagesA;
showComparisonMode(false);
}
}else{
isComparisonMode=false;
totalPages=totalPagesA;
showComparisonMode(false);
}
renderThumbnails();
loadFragment()
}
async function loadPage(index){
const res=await fetch(`/api/page/${index}`);
if(!res.ok)throw new Error('Failed to load page');
pageData=await res.json();
currentPage=index;
renderPage();
renderJson();
if(isComparisonMode){
await loadComparisonPage(index);
}else{
await loadSinglePage(index);
}
updateActiveThumbnail();
updateFragment();
updateNavState()
}
async function loadThumbnails(){
const container=document.getElementById('thumbnails');
container.innerHTML='';
for(let i=0;i<totalPages;i++){
const thumb=document.createElement('div');
thumb.className='thumbnail';
thumb.dataset.index=i;
const img=document.createElement('img');
img.className='thumbnail-img';
img.src=`/api/page/${i}/thumbnail`;
img.alt=`Page ${i+1}`;
img.loading='lazy';
const num=document.createElement('div');
num.className='thumbnail-number';
num.textContent=`${i+1}`;
thumb.appendChild(img);
thumb.appendChild(num);
thumb.addEventListener('click',()=>loadPage(i));
container.appendChild(thumb)
}
async function loadSinglePage(index){
const res=await fetch(`/api/page/${index}`);
if(!res.ok)throw new Error('Failed to load page');
pageData=await res.json();
await renderPageSingle();
renderJson();
}
function renderThumbnails(){loadThumbnails()}
async function loadComparisonPage(index){
const res=await fetch(`/api/compare/page/${index}`);
if(!res.ok)throw new Error('Failed to load comparison page');
const data=await res.json();
pageData=data;
pageDiff=data.diff;
// Load SVGs for both sides
const [sideARes,sideBRes]=await Promise.all([
fetch(`/api/compare/page/${index}/svg/a`),
fetch(`/api/compare/page/${index}/svg/b`)
]);
const svgA=sideARes.ok?await sideARes.text():null;
const svgB=sideBRes.ok?await sideBRes.text():null;
renderPageComparison(svgA,svgB);
renderJson();
}
async function renderPageSingle(){
const container=document.getElementById('canvas-container');
container.innerHTML='';
const res=await fetch(`/api/page/${currentPage}/svg`);
if(!res.ok)throw new Error('Failed to load SVG');
const svg=await res.text();
const wrapper=document.createElement('div');
wrapper.id='page-svg';
wrapper.innerHTML=svg;
// Add diff overlay if present
if(pageDiff){
const diffOverlay=renderDiffOverlay(pageDiff);
wrapper.querySelector('svg').innerHTML+=diffOverlay;
}
setupTooltips(wrapper);
container.appendChild(wrapper)
}
function renderPageComparison(svgA,svgB){
const container=document.getElementById('canvas-container');
const compareContainer=document.getElementById('compare-container');
container.innerHTML='';
container.appendChild(compareContainer);
compareContainer.style.display='flex';
// Render side A
const wrapperA=document.getElementById('svg-a');
wrapperA.innerHTML=svgA||'<div class="loading">Page not available</div>';
if(svgA){
const svg=wrapperA.querySelector('svg');
if(svg&&pageDiff){
const diffOverlay=renderDiffOverlay(pageDiff,'a');
svg.innerHTML+=diffOverlay;
}
setupTooltips(wrapperA);
}
// Render side B
const wrapperB=document.getElementById('svg-b');
wrapperB.innerHTML=svgB||'<div class="loading">Page not available</div>';
if(svgB){
const svg=wrapperB.querySelector('svg');
if(svg&&pageDiff){
const diffOverlay=renderDiffOverlay(pageDiff,'b');
svg.innerHTML+=diffOverlay;
}
setupTooltips(wrapperB);
}
// Setup scroll sync
setupScrollSync(wrapperA,wrapperB)
}
function renderDiffOverlay(diff,side='both'){
let overlay='';
// Get page dimensions from current page data
const width=pageData.a?.width||pageData.b?.width||612;
const height=pageData.a?.height||pageData.b?.height||792;
// Render removed blocks (red) - only on side A
if(side==='a'||side==='both'){
for(const idx of diff.removed_blocks||[]){
const block=(pageData.a?.blocks||[])[idx];
if(block){
const [x0,y0,x1,y1]=block.bbox;
overlay+=`<rect class="diff-removed layer-diff" x="${x0}" y="${y0}" width="${x1-x0}" height="${y1-y0}" rx="2"/>`;
}
}
}
// Render added blocks (green) - only on side B
if(side==='b'||side==='both'){
for(const idx of diff.added_blocks||[]){
const block=(pageData.b?.blocks||[])[idx];
if(block){
const [x0,y0,x1,y1]=block.bbox;
overlay+=`<rect class="diff-added layer-diff" x="${x0}" y="${y0}" width="${x1-x0}" height="${y1-y0}" rx="2"/>`;
}
}
}
// Render changed blocks (yellow) - both sides
if(side==='a'||side==='both'){
for(const idx of diff.changed_blocks||[]){
const block=(pageData.a?.blocks||[])[idx];
if(block){
const [x0,y0,x1,y1]=block.bbox;
overlay+=`<rect class="diff-changed layer-diff" x="${x0}" y="${y0}" width="${x1-x0}" height="${y1-y0}" rx="2"/>`;
}
}
}
if(side==='b'){
for(const idx of diff.changed_blocks||[]){
const block=(pageData.b?.blocks||[])[idx];
if(block){
const [x0,y0,x1,y1]=block.bbox;
overlay+=`<rect class="diff-changed layer-diff" x="${x0}" y="${y0}" width="${x1-x0}" height="${y1-y0}" rx="2"/>`;
}
}
}
return overlay
}
function setupScrollSync(wrapperA,wrapperB){
const syncScroll=(source,target)=>{
if(!scrollSync)return;
const scrollRatio=source.scrollTop/(source.scrollHeight-source.clientHeight);
target.scrollTop=scrollRatio*(target.scrollHeight-target.clientHeight)
};
wrapperA.addEventListener('scroll',()=>syncScroll(wrapperA,wrapperB));
wrapperB.addEventListener('scroll',()=>syncScroll(wrapperB,wrapperA))
}
async function renderPage(){
const container=document.getElementById('canvas-container');
@ -106,6 +258,28 @@ function setupToggles(){
})
}
function setupComparisonMode(){
const syncCheckbox=document.getElementById('sync-scroll');
if(syncCheckbox){
syncCheckbox.addEventListener('change',e=>{
scrollSync=e.target.checked
})
}
}
function showComparisonMode(show){
const diffBtn=document.getElementById('btn-diff');
const compareControls=document.querySelector('.comparison-controls');
if(show){
if(diffBtn)diffBtn.style.display='';
if(compareControls)compareControls.style.display='flex';
}else{
if(diffBtn)diffBtn.style.display='none';
if(compareControls)compareControls.style.display='none';
}
}
function setupKeyboard(){
document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT')return;
@ -118,7 +292,7 @@ function setupKeyboard(){
}else if(e.key==='/'){
e.preventDefault();
document.getElementById('search-input').focus()
}else if(e.key>='1'&&e.key<='8'){
}else if(e.key>='1'&&e.key<='9'){
const idx=parseInt(e.key)-1;
const layer=LAYERS[idx];
if(layer)toggleLayer(layer)

View file

@ -28,11 +28,21 @@ body{font-family:system-ui,-apple-system,sans-serif;font-size:14px;line-height:1
.json-tree{flex:1;overflow:auto;padding:12px;font-size:12px;font-family:ui-monospace,monospace;white-space:pre-wrap;word-break:break-all}
.loading{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:16px;color:#666}
.tooltip{position:absolute;background:#333;color:#fff;padding:6px 10px;border-radius:4px;font-size:12px;pointer-events:none;z-index:1000;max-width:300px;white-space:pre-wrap;word-break:break-word}
.layer-spans,.layer-blocks,.layer-columns,.layer-reading-order,.layer-confidence-heatmap,.layer-ocr,.layer-mcid,.layer-anchors{display:none}
html[data-layers~="spans"] .layer-spans,html[data-layers~="blocks"] .layer-blocks,html[data-layers~="columns"] .layer-columns,html[data-layers~="reading-order"] .layer-reading-order,html[data-layers~="confidence-heatmap"] .layer-confidence-heatmap,html[data-layers~="ocr"] .layer-ocr,html[data-layers~="mcid"] .layer-mcid,html[data-layers~="anchors"] .layer-anchors{display:block}
.layer-spans,.layer-blocks,.layer-columns,.layer-reading-order,.layer-confidence-heatmap,.layer-ocr,.layer-mcid,.layer-anchors,.layer-diff{display:none}
html[data-layers~="spans"] .layer-spans,html[data-layers~="blocks"] .layer-blocks,html[data-layers~="columns"] .layer-columns,html[data-layers~="reading-order"] .layer-reading-order,html[data-layers~="confidence-heatmap"] .layer-confidence-heatmap,html[data-layers~="ocr"] .layer-ocr,html[data-layers~="mcid"] .layer-mcid,html[data-layers~="anchors"] .layer-anchors,html[data-layers~="diff"] .layer-diff{display:block}
.tooltip-key{color:#8f8}
.tooltip-value{color:#8cf}
.tooltip-number{color:#f8c}
.search-highlight{background:#ffeb3b;outline:2px solid #ff9800}
.search-match-found{animation:highlight-pulse 1s ease-out}
@keyframes highlight-pulse{0%{background:#ff9800}100%{background:#ffeb3b}}
.compare-container{display:flex;gap:20px;justify-content:center;align-items:flex-start;width:100%;height:100%}
.compare-side{flex:1;max-width:50%;display:flex;flex-direction:column;align-items:center;background:#fff;box-shadow:0 2px 8px rgba(0,0,0,.1);border-radius:4px;overflow:hidden}
.compare-label{padding:8px 16px;background:#f9f9f9;border-bottom:1px solid #ddd;font-weight:600;font-size:13px;width:100%;text-align:center}
.svg-wrapper{flex:1;overflow:auto;position:relative;min-height:400px}
.comparison-controls{display:flex;gap:12px;align-items:center;padding-left:12px;border-left:1px solid #ddd;margin-left:8px}
.sync-toggle{display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer;user-select:none}
.sync-toggle input{cursor:pointer}
.diff-removed{fill:none;stroke:#dc3545;stroke-width:2;stroke-dasharray:4,2;opacity:.7}
.diff-added{fill:none;stroke:#28a745;stroke-width:2;stroke-dasharray:4,2;opacity:.7}
.diff-changed{fill:none;stroke:#ffc107;stroke-width:2;stroke-dasharray:4,2;opacity:.7}

View file

@ -8,7 +8,7 @@ Implemented the inspector frontend as a single-page vanilla web app with the fol
- `crates/pdftract-cli/src/inspect/frontend/style.css` (3,291 bytes raw)
- `crates/pdftract-cli/src/inspect/frontend/app.js` (5,494 bytes raw)
**Total bundle size: 10,748 bytes raw, 3,584 bytes gzipped** (well under the 80 KB limit)
**Total bundle size: 18,703 bytes raw, 5,630 bytes gzipped** (well under the 80 KB limit)
## Features Implemented
@ -29,17 +29,21 @@ Implemented the inspector frontend as a single-page vanilla web app with the fol
- Tooltip styling
- High contrast colors for confidence heatmap
### app.js (~5.5 KB)
### app.js (~11.6 KB)
- Vanilla ES modules with fetch() for API calls
- URL fragment parsing for #page=N navigation
- localStorage persistence for overlay toggles (namespaced "pdftract-inspector-*")
- Keyboard shortcuts:
- ArrowLeft/ArrowRight: prev/next page
- '/': focus search
- '1'-'8': toggle layer N
- '1'-'9': toggle layer N (includes diff layer)
- Search functionality with debouncing
- Dynamic thumbnail loading
- SVG rendering with tooltip support
- Comparison mode support (Phase 7.9.8):
- Side-by-side document comparison
- Scroll sync between panels
- Diff overlay rendering (added/removed/changed blocks)
### Integration
- Updated `inspect.rs` to serve frontend files via `include_str!()`
@ -58,11 +62,11 @@ Implemented the inspector frontend as a single-page vanilla web app with the fol
| Criteria | Status | Notes |
|----------|--------|-------|
| Bundle stripped+gzipped size < 80 KB | **PASS** | 3,914 bytes gzipped (3.8 KB) |
| Bundle stripped+gzipped size < 80 KB | **PASS** | 5,630 bytes gzipped (5.5 KB) |
| index.html loads in Chrome, Firefox, Safari | **PASS** | Standard HTML5, modern browser APIs only |
| 8 layer toggles work via CSS only | **PASS** | CSS-only toggling via data attributes |
| localStorage persists toggle state | **PASS** | Namespaced to "pdftract-inspector-layers" |
| Keyboard shortcuts 1-8 + arrow keys + '/' | **PASS** | All shortcuts implemented |
| Keyboard shortcuts 1-8 + arrow keys + '/' | **PASS** | All shortcuts implemented (now 1-9 with diff) |
| URL fragment #page=14 jumps to page 14 | **PASS** | Fragment parsing on load |
| Frontend works offline (no CDN URLs) | **PASS** | No external dependencies |
@ -86,6 +90,12 @@ Implemented the inspector frontend as a single-page vanilla web app with the fol
- Fixed tooltip handler to use correct data attribute names (`data-spanIndex`, `data-blockIndex`) instead of expecting a single `data-tooltip` attribute
- This matches the actual SVG rendering output from spans.rs and blocks.rs which provide individual data attributes
- Added comparison mode support (Phase 7.9.8):
- 9th layer toggle for diff overlay
- Side-by-side document comparison UI
- Scroll sync between comparison panels
- Diff overlay rendering for added/removed/changed blocks
- Bundle size increased to 5.63 KB gzipped (still well under 80 KB limit)
## Git Commits