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:
parent
99317e9010
commit
d70b4aa36e
3 changed files with 233 additions and 39 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue