@@ -26,6 +26,30 @@ let _detectSeq = 0;
2626// underlying buffer; we always slice() bytes from originalBytesCache.
2727const _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+
2953async 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
5277export async function getDocPageCount ( filePath ) {
@@ -95,32 +120,14 @@ export async function renderCompareSideBySide(canvasOld, canvasNew, opts) {
95120 * its DOM containers.
96121 */
97122export 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+
175195async 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.
0 commit comments