Skip to content

Commit bb377fb

Browse files
committed
perf(compare): drop redundant OLD page render and cache detection rasters
- renderCompareOverlay no longer rasterizes the OLD page into a hidden DOM canvas on every zoom/render — only the visible NEW page is drawn. This halves the PDF.js render work per overlay update. - Skip change detection on zoom-only re-renders (CompareView passes skipDetection: true from the zoom debounce). Detection bboxes live in a fixed detection-pixel space and are independent of display scale. - Cache rasterized ImageData per (filePath, pageNum, detectScale) with a small LRU so repeated change-detection passes (offset tweaks, page-pair navigation) reuse OLD/NEW rasters instead of re-rendering through PDF.js. - Remove the hidden overlayOldCanvas DOM element and its ref; OLD is now a pure offscreen detail of compare-viewport.
1 parent f3755d7 commit bb377fb

2 files changed

Lines changed: 90 additions & 45 deletions

File tree

open-pdf-studio/js/compare/compare-viewport.js

Lines changed: 67 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,30 @@ let _detectSeq = 0;
2626
// underlying buffer; we always slice() bytes from originalBytesCache.
2727
const _docCache = new Map();
2828

29+
// Cache of rasterized ImageData used by change detection. Keyed by
30+
// `${filePath}|${pageNum}|${scale}`. The same OLD rasterization is reused
31+
// across multiple detection passes (only the NEW side changes when the user
32+
// edits offsets, etc.). Capped to a small LRU to bound memory.
33+
const _imageDataCache = new Map();
34+
const _IMG_CACHE_MAX = 6;
35+
36+
function _imgCacheGet(key) {
37+
if (!_imageDataCache.has(key)) return null;
38+
// LRU bump
39+
const v = _imageDataCache.get(key);
40+
_imageDataCache.delete(key);
41+
_imageDataCache.set(key, v);
42+
return v;
43+
}
44+
function _imgCacheSet(key, v) {
45+
if (_imageDataCache.has(key)) _imageDataCache.delete(key);
46+
_imageDataCache.set(key, v);
47+
while (_imageDataCache.size > _IMG_CACHE_MAX) {
48+
const first = _imageDataCache.keys().next().value;
49+
_imageDataCache.delete(first);
50+
}
51+
}
52+
2953
async function _getDoc(filePath) {
3054
if (_docCache.has(filePath)) return _docCache.get(filePath);
3155
const bytes = getCachedPdfBytes(filePath);
@@ -47,6 +71,7 @@ export function clearCompareDocCache() {
4771
try { d.destroy?.(); } catch {}
4872
}
4973
_docCache.clear();
74+
_imageDataCache.clear();
5075
}
5176

5277
export async function getDocPageCount(filePath) {
@@ -95,32 +120,14 @@ export async function renderCompareSideBySide(canvasOld, canvasNew, opts) {
95120
* its DOM containers.
96121
*/
97122
export async function renderCompareOverlay(canvasOld, canvasNew, opts, canvasHighlights = null) {
98-
const { oldPath, newPath, oldPage, newPage, scale = 1.5, offset = { dx: 0, dy: 0, rotation: 0 } } = opts;
123+
const { newPath, newPage, scale = 1.5, skipDetection = false } = opts;
99124

100-
// Render NEW normally — this is the visible base layer.
125+
// Render NEW normally — this is the visible base layer. The OLD page is
126+
// never drawn into a visible canvas in overlay mode (only rasterized for
127+
// change detection below). This avoids one full PDF.js render pass on
128+
// every zoom step, which was the dominant cost.
101129
await _renderPageToCanvas(newPath, newPage, scale, canvasNew);
102130

103-
// Render OLD into the (hidden) old canvas for completeness; not displayed.
104-
if (canvasOld) {
105-
const tmpOldRaw = document.createElement('canvas');
106-
await _renderPageToCanvas(oldPath, oldPage, scale, tmpOldRaw);
107-
108-
canvasOld.width = canvasNew.width;
109-
canvasOld.height = canvasNew.height;
110-
const oldCtx = canvasOld.getContext('2d');
111-
oldCtx.fillStyle = '#ffffff';
112-
oldCtx.fillRect(0, 0, canvasOld.width, canvasOld.height);
113-
oldCtx.save();
114-
oldCtx.translate(offset.dx || 0, offset.dy || 0);
115-
if (offset.rotation) {
116-
oldCtx.translate(canvasOld.width / 2, canvasOld.height / 2);
117-
oldCtx.rotate((offset.rotation * Math.PI) / 180);
118-
oldCtx.translate(-canvasOld.width / 2, -canvasOld.height / 2);
119-
}
120-
oldCtx.drawImage(tmpOldRaw, 0, 0);
121-
oldCtx.restore();
122-
}
123-
124131
// Size the highlights canvas to match NEW; the actual rectangles are drawn
125132
// separately by paintHighlights() once changes are detected.
126133
if (canvasHighlights) {
@@ -131,8 +138,9 @@ export async function renderCompareOverlay(canvasOld, canvasNew, opts, canvasHig
131138
}
132139

133140
// Kick off async, debounced change detection on a separately rasterized copy
134-
// of both pages. Visual rendering is not blocked.
135-
scheduleChangeDetection(opts);
141+
// of both pages. Visual rendering is not blocked. Skipped when the caller
142+
// knows only zoom (which doesn't affect detection results) has changed.
143+
if (!skipDetection) scheduleChangeDetection(opts);
136144

137145
return { width: canvasNew.width, height: canvasNew.height };
138146
}
@@ -172,6 +180,18 @@ export function scheduleChangeDetection(opts) {
172180
}, 200);
173181
}
174182

183+
async function _rasterizeForDetection(filePath, pageNum, detectScale) {
184+
const key = `${filePath}|${pageNum}|${detectScale}`;
185+
const cached = _imgCacheGet(key);
186+
if (cached) return cached;
187+
const c = document.createElement('canvas');
188+
await _renderPageToCanvas(filePath, pageNum, detectScale, c);
189+
const data = c.getContext('2d').getImageData(0, 0, c.width, c.height);
190+
const entry = { width: c.width, height: c.height, data };
191+
_imgCacheSet(key, entry);
192+
return entry;
193+
}
194+
175195
async function runChangeDetection(opts) {
176196
const { oldPath, newPath, oldPage, newPage, offset = { dx: 0, dy: 0, rotation: 0 } } = opts;
177197
if (!oldPath || !newPath) return [];
@@ -184,21 +204,33 @@ async function runChangeDetection(opts) {
184204
const longest = Math.max(baseVp.width, baseVp.height);
185205
const detectScale = Math.min(1.5, DETECTION_MAX_DIM / longest);
186206

187-
const cOld = document.createElement('canvas');
188-
const cNewRaw = document.createElement('canvas');
189-
await _renderPageToCanvas(oldPath, oldPage, detectScale, cOld);
190-
await _renderPageToCanvas(newPath, newPage, detectScale, cNewRaw);
207+
// Rasterize both at detectScale, reusing cached ImageData when available.
208+
// This is the hot path on offset/page changes — detection scale is fixed
209+
// per (path,page), so cache hits are guaranteed across repeated calls.
210+
const [oldEntry, newEntry] = await Promise.all([
211+
_rasterizeForDetection(oldPath, oldPage, detectScale),
212+
_rasterizeForDetection(newPath, newPage, detectScale),
213+
]);
191214

192215
// Apply alignment offset to OLD so detection respects the same alignment as
193-
// the visual overlay. NEW remains at native position.
216+
// the visual overlay. NEW remains at native position. We blit the cached
217+
// OLD ImageData into a fresh aligned canvas (cheap drawImage of an
218+
// ImageBitmap-equivalent vs a full PDF.js re-render).
194219
const cOldAligned = document.createElement('canvas');
195-
cOldAligned.width = cNewRaw.width;
196-
cOldAligned.height = cNewRaw.height;
220+
cOldAligned.width = newEntry.width;
221+
cOldAligned.height = newEntry.height;
197222
const aCtx = cOldAligned.getContext('2d');
198223
aCtx.fillStyle = '#ffffff';
199224
aCtx.fillRect(0, 0, cOldAligned.width, cOldAligned.height);
225+
226+
// Materialize cached OLD ImageData onto an offscreen canvas to draw with
227+
// alignment transform (putImageData ignores transforms).
228+
const oldRaw = document.createElement('canvas');
229+
oldRaw.width = oldEntry.width;
230+
oldRaw.height = oldEntry.height;
231+
oldRaw.getContext('2d').putImageData(oldEntry.data, 0, 0);
232+
200233
aCtx.save();
201-
// Scale offsets from display scale to detection scale.
202234
const visualScale = opts.scale || 1.5;
203235
const off = {
204236
dx: (offset.dx || 0) * (detectScale / visualScale),
@@ -211,11 +243,11 @@ async function runChangeDetection(opts) {
211243
aCtx.rotate((off.rotation * Math.PI) / 180);
212244
aCtx.translate(-cOldAligned.width / 2, -cOldAligned.height / 2);
213245
}
214-
aCtx.drawImage(cOld, 0, 0);
246+
aCtx.drawImage(oldRaw, 0, 0);
215247
aCtx.restore();
216248

217249
const oldData = aCtx.getImageData(0, 0, cOldAligned.width, cOldAligned.height);
218-
const newData = cNewRaw.getContext('2d').getImageData(0, 0, cNewRaw.width, cNewRaw.height);
250+
const newData = newEntry.data;
219251

220252
const changes = detectChanges(oldData, newData);
221253
// Tag the detection scale so the UI can map bbox px back to display px.

open-pdf-studio/js/solid/components/compare/CompareView.jsx

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ export default function CompareView() {
3535
const { t } = useTranslation('ribbon');
3636
let oldCanvasRef;
3737
let newCanvasRef;
38-
let overlayOldCanvasRef;
3938
let overlayNewCanvasRef;
4039
let overlayHighlightCanvasRef;
4140
let bodyRef;
@@ -108,10 +107,23 @@ export default function CompareView() {
108107

109108
let busy = false;
110109
let pending = false;
110+
// When set, the next doRender() will skip change detection. Used by zoom-
111+
// only re-renders since detection bboxes are computed in a fixed
112+
// detection-px space and are independent of the display scale.
113+
let pendingSkipDetection = false;
111114

112-
const doRender = async () => {
113-
if (busy) { pending = true; return; }
115+
const doRender = async (opts2 = {}) => {
116+
if (busy) {
117+
pending = true;
118+
// Keep skip flag sticky-true so zoom-only renders never trigger
119+
// detection, but if any non-zoom render is requested while busy, clear
120+
// it so detection runs once after the busy render.
121+
if (!opts2.skipDetection) pendingSkipDetection = false;
122+
return;
123+
}
114124
busy = true;
125+
const skipDetection = !!opts2.skipDetection || pendingSkipDetection;
126+
pendingSkipDetection = false;
115127
const zoomAtRender = compareZoom();
116128
try {
117129
const opts = {
@@ -121,12 +133,13 @@ export default function CompareView() {
121133
newPage: compareNewPage(),
122134
scale: 1.5 * zoomAtRender,
123135
offset: compareOffset(),
136+
skipDetection,
124137
};
125138
if (!opts.oldPath || !opts.newPath) return;
126139
if (compareMode() === 'overlay') {
127140
if (overlayNewCanvasRef && overlayHighlightCanvasRef) {
128141
await renderCompareOverlay(
129-
overlayOldCanvasRef,
142+
null,
130143
overlayNewCanvasRef,
131144
opts,
132145
overlayHighlightCanvasRef,
@@ -163,7 +176,9 @@ export default function CompareView() {
163176
if (zoomDebounceTimer) clearTimeout(zoomDebounceTimer);
164177
zoomDebounceTimer = setTimeout(() => {
165178
zoomDebounceTimer = null;
166-
doRender();
179+
// Zoom-only re-render: skip change detection entirely. Bbox results
180+
// are in detection-pixel space and don't change with display scale.
181+
doRender({ skipDetection: true });
167182
}, ZOOM_DEBOUNCE_MS);
168183
};
169184

@@ -318,11 +333,9 @@ export default function CompareView() {
318333
style={`position:relative; background:#ffffff; box-shadow:0 0 0 1px #444; line-height:0; transform:scale(${cssScale()}); transform-origin:0 0;`}
319334
>
320335
<canvas ref={overlayNewCanvasRef} style="display:block;"></canvas>
321-
{/* OLD canvas is kept in DOM but hidden — used by detection only. */}
322-
<canvas
323-
ref={overlayOldCanvasRef}
324-
style="position:absolute; left:0; top:0; display:none;"
325-
></canvas>
336+
{/* OLD page is rasterized for change detection only via
337+
an offscreen canvas in compare-viewport.js — no DOM
338+
element required. */}
326339
<canvas
327340
ref={overlayHighlightCanvasRef}
328341
style="position:absolute; left:0; top:0; pointer-events:none;"

0 commit comments

Comments
 (0)