Skip to content

Commit fa86c06

Browse files
committed
perf: speed up large PDFs opened from start page
Reuse the parsed PDF during annotation preparation and preserve original bytes when no annotation changes are needed. Stop background recent-document previews when opening a document. Add byte-preservation and annotation-cleanup regression tests plus a 17 MB, 1000-page start-page opening smoke check. Measured click-to-ready time improved from 413 ms to 272 ms. 72 unit tests, startup and annotation shortcut checks passed; the full annotation UI smoke remains blocked by its stale custom deletion-dialog assertion.
1 parent d5a864a commit fa86c06

6 files changed

Lines changed: 70 additions & 10 deletions

File tree

‎electron/annotation-import.mjs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import {validateAnnotation} from './annotations.mjs';
1+
import {validateAnnotation,embedAnnotationDocument} from './annotations.mjs';
22
import {PDFDocument,PDFDict,PDFName,PDFHexString,PDFString,PDFArray,PDFNumber} from 'pdf-lib';
33
function text(dict,key){return dict.lookupMaybe(PDFName.of(key),PDFString,PDFHexString)?.decodeText()||'';}
44
function numbers(dict,key){const array=dict.lookupMaybe(PDFName.of(key),PDFArray);return array?Array.from({length:array.size()},(_,i)=>array.lookup(i,PDFNumber).asNumber()):[];}
55
function date(value){const match=/^D:(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?/.exec(value);if(!match)return new Date().toISOString();return new Date(Date.UTC(+match[1],+(match[2]||1)-1,+(match[3]||1),+(match[4]||0),+(match[5]||0),+(match[6]||0))).toISOString();}
66
function geometry(page){const crop=page.getCropBox(),rotation=((page.getRotation().angle%360)+360)%360;return {crop,rotation,width:rotation%180?crop.height:crop.width,height:rotation%180?crop.width:crop.height};}
77
function viewport(x,y,g){const c=g.crop;if(g.rotation===90)return [y-c.y,x-c.x];if(g.rotation===180)return [c.x+c.width-x,y-c.y];if(g.rotation===270)return [c.y+c.height-y,c.x+c.width-x];return [x-c.x,c.y+c.height-y];}
88
function bounds(points,g){const converted=points.map(([x,y])=>viewport(x,y,g)),xs=converted.map(p=>p[0]),ys=converted.map(p=>p[1]);const x=Math.max(0,Math.min(...xs)),y=Math.max(0,Math.min(...ys)),right=Math.min(g.width,Math.max(...xs)),bottom=Math.min(g.height,Math.max(...ys));return right>x&&bottom>y?{x,y,width:right-x,height:bottom-y}:null;}
9-
export async function importPDFAnnotations(bytes){
9+
export async function importPDFAnnotations(bytes,{prepare=false}={}){
1010
const pdf=await PDFDocument.load(bytes,{updateMetadata:false}),annotations=[],nativeRefs=[];
1111
for(const [index,page] of pdf.getPages().entries()){
12-
const array=page.node.Annots(),g=geometry(page);if(!array)continue;
12+
const array=page.node.Annots();if(!array)continue;const g=geometry(page);
1313
for(let i=0;i<array.size();i++){
1414
const dict=array.lookupMaybe(i,PDFDict);if(!dict)continue;
1515
if(text(dict,'NM').startsWith('PDFMathReader:')){try{const a=JSON.parse(text(dict,'PDFMathReader'));if(a?.id)annotations.push(validateAnnotation(a));}catch{}continue;}
@@ -25,5 +25,5 @@ export async function importPDFAnnotations(bytes){
2525
const contents=text(dict,'Contents');annotations.push({id:`native:${nativeRef}`,nativeRef,page:index+1,kind:kind==='/Highlight'?'highlight':'comment',origin:'source',text:kind==='/Highlight'?contents:'',comment:kind==='/Text'?contents:'',color:hex,rects,sourceRect,createdAt:date(text(dict,'M')),author:text(dict,'T'),dateUnknown:!text(dict,'M')});nativeRefs.push(nativeRef);
2626
}
2727
}
28-
return {annotations,nativeRefs};
28+
return {annotations,nativeRefs,...prepare?{bytes:new Uint8Array(await embedAnnotationDocument(pdf,[],nativeRefs,bytes))}:{}};
2929
}

‎electron/annotations.mjs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,18 @@ function addManagedAnnotation(page,annotation,context){
178178

179179
export async function embedAnnotations(bytes,annotations,nativeRefs=[]){
180180
if(!Array.isArray(nativeRefs)||nativeRefs.some(ref=>typeof ref!=='string'||!/^\d+ \d+ R$/.test(ref)))invalid('Invalid annotation native references.');
181-
const replaced=new Set(nativeRefs);
182181
const input=asBytes(bytes);
183182
const normalized=validateAnnotations(annotations);
184183
const pdf=await PDFDocument.load(input,{updateMetadata:false});
184+
return embedAnnotationDocument(pdf,normalized,nativeRefs,input);
185+
}
186+
187+
// Reuse an already parsed document during import. Preserve the original bytes
188+
// when there is nothing to replace, including unrelated links and form widgets.
189+
export async function embedAnnotationDocument(pdf,annotations,nativeRefs=[],originalBytes){
190+
if(!Array.isArray(nativeRefs)||nativeRefs.some(ref=>typeof ref!=='string'||!/^\d+ \d+ R$/.test(ref)))invalid('Invalid annotation native references.');
191+
const normalized=validateAnnotations(annotations),replaced=new Set(nativeRefs);
192+
let changed=normalized.length>0;
185193
const context=pdf.context;
186194
const pages=pdf.getPages();
187195
for(const page of pages){
@@ -193,6 +201,8 @@ export async function embedAnnotations(bytes,annotations,nativeRefs=[]){
193201
if(dict?.get(PDFName.of('Subtype'))?.toString()==='/Popup'&&replaced.has(dict.get(PDFName.of('Parent'))?.toString()))continue;
194202
if((!dict||!managedAnnotation(dict))&&!replaced.has(annots.get(index).toString()))retained.push(annots.get(index));
195203
}
204+
if(retained.length===annots.size())continue;
205+
changed=true;
196206
if(retained.length)page.node.set(PDFName.Annots,context.obj(retained));
197207
else page.node.delete(PDFName.Annots);
198208
}
@@ -202,6 +212,7 @@ export async function embedAnnotations(bytes,annotations,nativeRefs=[]){
202212
const ref=addManagedAnnotation(page,annotation,context);
203213
const annots=page.node.Annots()||context.obj([]);annots.push(ref);page.node.set(PDFName.Annots,annots);
204214
}
215+
if(!changed&&originalBytes)return asBytes(originalBytes);
205216
return Buffer.from(await pdf.save({useObjectStreams:false,updateFieldAppearances:false}));
206217
}
207218

‎electron/large-open-smoke.mjs‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import assert from 'node:assert/strict';
2+
import {app} from 'electron';
3+
import {PDFDocument,StandardFonts} from 'pdf-lib';
4+
import {mkdtemp,writeFile,rm} from 'node:fs/promises';
5+
import {join} from 'node:path';
6+
import {tmpdir} from 'node:os';
7+
export async function verifyLargeOpen(window,recents){
8+
const evaluate=code=>window.webContents.executeJavaScript(code);
9+
const wait=async code=>{for(let i=0;i<1200;i++){if(await evaluate(code))return;await new Promise(r=>setTimeout(r,25));}throw Error('Large open timed out: '+code);};
10+
const root=await mkdtemp(join(tmpdir(),'large-open-')),path=join(root,'Portrait and landscape.pdf');
11+
try{
12+
const pdf=await PDFDocument.create(),font=await pdf.embedFont(StandardFonts.Helvetica);
13+
for(let i=0;i<1000;i++){const page=pdf.addPage(i%2?[792,612]:[612,792]);page.drawText('Large document page '+(i+1),{x:30,y:300,font,size:18});}
14+
pdf.context.register(pdf.context.stream(new Uint8Array(16*1024*1024)));
15+
const bytes=await pdf.save({useObjectStreams:false});await writeFile(path,bytes);await recents.remember(path);
16+
await evaluate('window.previewPreferences.save({automatic:false,reduceMotion:true})');
17+
const loaded=new Promise(r=>window.webContents.once('did-finish-load',r));window.webContents.reload();await loaded;
18+
await wait('window.previewReady===true && !!document.querySelector(".recent-document")');
19+
const started=Date.now();await evaluate('document.querySelector(".recent-document").click()');
20+
await wait('window.previewRenderDiagnostics?.().totalPages===1000 && !window.previewRenderDiagnostics().opening && window.previewRenderDiagnostics().metrics.firstPageMs!==null');
21+
const report=await evaluate('window.previewPerformanceReport()');
22+
assert.ok(await evaluate('document.querySelector(".page canvas")?.width>0'));
23+
console.log('Large start-page open',JSON.stringify({fileBytes:bytes.length,clickToReadyMs:Date.now()-started,firstScreenMs:report.firstScreenMs,stages:report.stages}));
24+
}finally{await rm(root,{recursive:true,force:true});}
25+
app.exit(0);
26+
}

‎electron/main.mjs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {registerWindowsPDF} from './windows-file-association.mjs';
2727
import {windowChromeOptions,commandAccelerator,closeWindowAccelerator,shortcutAction,serializeApplicationMenu,menuPathItems} from './window-chrome.mjs';
2828
import {appendPerformanceReport,loadPerformanceReports,memoryMetricToBytes,MAX_PERFORMANCE_REPORTS,sumMemoryMetrics,validatePerformanceReport,writePerformanceReports} from './performance-tracker.mjs';
2929
import {importPDFAnnotations} from './annotation-import.mjs';
30-
import {createAnnotationStore,stripManagedAnnotations,embedAnnotations} from './annotations.mjs';
30+
import {createAnnotationStore,stripManagedAnnotations} from './annotations.mjs';
3131

3232
const recentContextMenuLabels={
3333
en:{open:'Open',openWindow:'Open in New Window',hide:'Remove from Recents',copy:'Copy File Location',reveal:'Reveal in Finder',status:'View Translation Status'},
@@ -258,7 +258,7 @@ else {
258258
if(source?.path&&source.reliable){ticket=randomBytes(16).toString('hex');state.tickets.set(ticket,source.path);}
259259
return {...result,ticket};
260260
});
261-
ipcMain.handle('previewAnnotations:prepare',async(event,value)=>{const target=trustedWindow(event);if(!(value instanceof Uint8Array)||value.byteLength>50*1024*1024)throw Error('Invalid annotation PDF bytes.');const imported=await importPDFAnnotations(value);windows.get(target).unkeyedAnnotationSource={bytes:Buffer.from(value),reliable:false,preserveWithoutPath:true};return {...imported,bytes:new Uint8Array(await embedAnnotations(value,[],imported.nativeRefs))};});
261+
ipcMain.handle('previewAnnotations:prepare',async(event,value)=>{const target=trustedWindow(event);if(!(value instanceof Uint8Array)||value.byteLength>50*1024*1024)throw Error('Invalid annotation PDF bytes.');const imported=await importPDFAnnotations(value,{prepare:true});windows.get(target).unkeyedAnnotationSource={bytes:Buffer.from(value),reliable:false,preserveWithoutPath:true};return imported;});
262262
ipcMain.handle('previewAnnotations:confirmDelete',async(event,kind)=>{
263263
const target=trustedWindow(event);
264264
if(!['highlight','comment'].includes(kind))throw Error('Invalid annotation kind.');
@@ -436,7 +436,7 @@ else {
436436
ipcMain.handle('window:new',async event=>{trustedWindow(event);await createWindow();});
437437
if(!smoke&&preferences.load().restoreDocuments){for(const document of documentSession.restore()){try{await validateSystemPDF(document.path);}catch{continue;}if(!pendingFiles.includes(document.path))await createWindow(document.path,document.view);}}
438438
window=[...windows.keys()][0]||await createWindow(pendingFiles.shift());backend=windows.get(window).backend;documentsReady=true;await deliverPendingFiles();
439-
if(smoke){const checks=await import('./smoke.mjs');if(smoke==='information-emphasis')await (await import('./information-emphasis-smoke.mjs')).verifyInformationEmphasis(window,recents);else if(smoke==='topic-sentences')await (await import('./topic-sentences-smoke.mjs')).verifyTopicSentences(window);else if(smoke==='page-edits')await (await import('./page-edits-smoke.mjs')).verifyPageEdits(window,recents);else if(smoke==='cache-progress')await (await import('./cache-progress-smoke.mjs')).verifyCacheProgress(window);else if(smoke==='quick-links')await (await import('./quick-links-smoke.mjs')).verifyQuickLinks(window);else if(smoke==='translation-prefetch')await (await import('./translation-prefetch-smoke.mjs')).verifyTranslationPrefetch(window,recents);else if(smoke==='pdf-navigation')await (await import('./pdf-navigation-smoke.mjs')).verifyPDFNavigation(window,recents);else if(['sidebar','sidebar-keys'].includes(smoke))await (await import('./sidebar-smoke.mjs')).verifySidebar(window,recents);else if(smoke==='advanced-cache')await (await import('./advanced-cache-smoke.mjs')).verifyAdvancedCache(window);else if(smoke==='file-menu')await (await import('./file-menu-smoke.mjs')).verifyFileMenu(window,recents);else if(smoke==='recent-menu')await (await import('./recent-menu-smoke.mjs')).verifyRecentMenu(window,recents);else if(smoke==='startup')await (await import('./startup-smoke.mjs')).verifyStartup(window);else if(smoke==='session')await (await import('./session-smoke.mjs')).verifySession(window,windows,createWindow);else if(smoke==='layout-settings')await (await import('./layout-settings-smoke.mjs')).verifyLayoutSettings(window);else if(smoke==='windows-settings')await (await import('./windows-settings-smoke.mjs')).verifyWindowsSettings(window);else if(smoke==='locales')await (await import('./locales-smoke.mjs')).verifyLocales(window);else if(smoke==='advanced')await (await import('./advanced-smoke.mjs')).verifyAdvanced(window);else if(smoke==='fluent')await (await import('./fluent-smoke.mjs')).verifyFluent(window);else if(smoke==='search')await (await import('./search-smoke.mjs')).verifySearch(window);else if(smoke==='multi-window')await (await import('./multi-window-smoke.mjs')).verifyMultiWindow(window,windows,createWindow);else if(smoke==='crop')await (await import('./crop-smoke.mjs')).verifyCrop(window,recents);else if(smoke==='fit-width')await (await import('./fit-width-smoke.mjs')).verifyFitWidth(window,recents);else if(smoke==='reading-view')await (await import('./reading-view-smoke.mjs')).verifyReadingView(window,recents);else if(smoke==='performance')await (await import('./performance-smoke.mjs')).verifyPerformance(window);else if(smoke==='benchmark')await (await import('./benchmark-smoke.mjs')).verifyBenchmark(window);else if(smoke==='coverage')await (await import('./coverage-smoke.mjs')).verifyCoverage(window,backend,token);else if(smoke==='resize'){await (await import('./recents-smoke.mjs')).verifyRecents(window,recents);await (await import('./resize-smoke.mjs')).verifyResize(window);}else if(smoke==='animation')await (await import('./animation-smoke.mjs')).verifyAnimation(window);else if(smoke==='ux')await (await import('./ux-smoke.mjs')).verifyUX(window,recents);else if(smoke==='layout-region')await (await import('./layout-region-smoke.mjs')).verifyLayoutRegion(window);else if(smoke==='kernel-choice')await (await import('./kernel-choice-smoke.mjs')).verifyKernelChoice(window);else if(smoke==='kernels')await (await import('./kernel-smoke.mjs')).verifyKernelUI(window);else if(['annotations','annotation-shortcuts'].includes(smoke))await (await import('./annotations-smoke.mjs')).verifyAnnotations(window,recents);else if(smoke==='file-open')await checks.verifySystemOpen(window);else await checks.verify(window,backend,token,smoke,credentials);}
439+
if(smoke){const checks=await import('./smoke.mjs');if(smoke==='information-emphasis')await (await import('./information-emphasis-smoke.mjs')).verifyInformationEmphasis(window,recents);else if(smoke==='topic-sentences')await (await import('./topic-sentences-smoke.mjs')).verifyTopicSentences(window);else if(smoke==='page-edits')await (await import('./page-edits-smoke.mjs')).verifyPageEdits(window,recents);else if(smoke==='cache-progress')await (await import('./cache-progress-smoke.mjs')).verifyCacheProgress(window);else if(smoke==='quick-links')await (await import('./quick-links-smoke.mjs')).verifyQuickLinks(window);else if(smoke==='translation-prefetch')await (await import('./translation-prefetch-smoke.mjs')).verifyTranslationPrefetch(window,recents);else if(smoke==='pdf-navigation')await (await import('./pdf-navigation-smoke.mjs')).verifyPDFNavigation(window,recents);else if(['sidebar','sidebar-keys'].includes(smoke))await (await import('./sidebar-smoke.mjs')).verifySidebar(window,recents);else if(smoke==='advanced-cache')await (await import('./advanced-cache-smoke.mjs')).verifyAdvancedCache(window);else if(smoke==='large-open')await (await import('./large-open-smoke.mjs')).verifyLargeOpen(window,recents);else if(smoke==='file-menu')await (await import('./file-menu-smoke.mjs')).verifyFileMenu(window,recents);else if(smoke==='recent-menu')await (await import('./recent-menu-smoke.mjs')).verifyRecentMenu(window,recents);else if(smoke==='startup')await (await import('./startup-smoke.mjs')).verifyStartup(window);else if(smoke==='session')await (await import('./session-smoke.mjs')).verifySession(window,windows,createWindow);else if(smoke==='layout-settings')await (await import('./layout-settings-smoke.mjs')).verifyLayoutSettings(window);else if(smoke==='windows-settings')await (await import('./windows-settings-smoke.mjs')).verifyWindowsSettings(window);else if(smoke==='locales')await (await import('./locales-smoke.mjs')).verifyLocales(window);else if(smoke==='advanced')await (await import('./advanced-smoke.mjs')).verifyAdvanced(window);else if(smoke==='fluent')await (await import('./fluent-smoke.mjs')).verifyFluent(window);else if(smoke==='search')await (await import('./search-smoke.mjs')).verifySearch(window);else if(smoke==='multi-window')await (await import('./multi-window-smoke.mjs')).verifyMultiWindow(window,windows,createWindow);else if(smoke==='crop')await (await import('./crop-smoke.mjs')).verifyCrop(window,recents);else if(smoke==='fit-width')await (await import('./fit-width-smoke.mjs')).verifyFitWidth(window,recents);else if(smoke==='reading-view')await (await import('./reading-view-smoke.mjs')).verifyReadingView(window,recents);else if(smoke==='performance')await (await import('./performance-smoke.mjs')).verifyPerformance(window);else if(smoke==='benchmark')await (await import('./benchmark-smoke.mjs')).verifyBenchmark(window);else if(smoke==='coverage')await (await import('./coverage-smoke.mjs')).verifyCoverage(window,backend,token);else if(smoke==='resize'){await (await import('./recents-smoke.mjs')).verifyRecents(window,recents);await (await import('./resize-smoke.mjs')).verifyResize(window);}else if(smoke==='animation')await (await import('./animation-smoke.mjs')).verifyAnimation(window);else if(smoke==='ux')await (await import('./ux-smoke.mjs')).verifyUX(window,recents);else if(smoke==='layout-region')await (await import('./layout-region-smoke.mjs')).verifyLayoutRegion(window);else if(smoke==='kernel-choice')await (await import('./kernel-choice-smoke.mjs')).verifyKernelChoice(window);else if(smoke==='kernels')await (await import('./kernel-smoke.mjs')).verifyKernelUI(window);else if(['annotations','annotation-shortcuts'].includes(smoke))await (await import('./annotations-smoke.mjs')).verifyAnnotations(window,recents);else if(smoke==='file-open')await checks.verifySystemOpen(window);else await checks.verify(window,backend,token,smoke,credentials);}
440440
}).catch(async error=>{if(smoke){await backend?.close();console.error('Desktop smoke failed:',error.stack||error.message);if(smoke==='file-open')await writeFile('/tmp/preview-system-open-result.json',JSON.stringify({passed:false,error:error.message}));app.exit(1);return;}dialog.showErrorBox('PDFMathReader',`The local reader could not start.\n\n${error?.message||'The backend utility process did not become ready.'}\n\nQuit and reopen PDFMathReader.`);app.quit();});
441441
app.on('before-quit',event=>{
442442
if(!backend || quitting)return;

‎server/annotations.test.mjs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,26 @@ test('native comments and highlights import, migrate, recover from copied PDF, a
9494
await assert.rejects(store.save({key:'missing',annotations:[]},{path:join(root,'missing.pdf'),reliable:true}),/ENOENT/);
9595
}finally{await rm(root,{recursive:true,force:true});}
9696
});
97+
98+
test('opening an unannotated or link-only PDF preserves its exact bytes',async()=>{
99+
const {importPDFAnnotations}=await import('../electron/annotation-import.mjs');
100+
const pdf=await PDFDocument.create(),page=pdf.addPage();
101+
for(const withLink of [false,true]){
102+
if(withLink)page.node.set(PDFName.Annots,pdf.context.obj([pdf.context.register(pdf.context.obj({Type:'Annot',Subtype:'Link',Rect:[10,10,30,30],A:{S:'URI',URI:PDFHexString.fromText('https://example.com')}}))]));
103+
const bytes=await pdf.save(),prepared=await importPDFAnnotations(bytes,{prepare:true});
104+
assert.deepEqual(prepared.bytes,bytes);assert.deepEqual(prepared.annotations,[]);
105+
}
106+
});
107+
108+
test('prepared PDF removes imported native and managed annotations while preserving unrelated links',async()=>{
109+
const {importPDFAnnotations}=await import('../electron/annotation-import.mjs');
110+
const pdf=await PDFDocument.create(),page=pdf.addPage();addExternalAnnotation(pdf,page);
111+
const native=page.node.Annots().get(0),popup=pdf.context.register(pdf.context.obj({Type:'Annot',Subtype:'Popup',Rect:[0,0,20,20],Parent:native}));page.node.Annots().push(popup);
112+
const link=pdf.context.register(pdf.context.obj({Type:'Annot',Subtype:'Link',Rect:[10,10,30,30]}));page.node.Annots().push(link);
113+
const managed=pdf.context.register(pdf.context.obj({Type:'Annot',Subtype:'Text',NM:PDFHexString.fromText('PDFMathReader:invalid'),Rect:[20,20,40,40]}));page.node.Annots().push(managed);
114+
const original=await pdf.save(),prepared=await importPDFAnnotations(original,{prepare:true});
115+
assert.equal(prepared.annotations.length,1);assert.equal(prepared.nativeRefs.length,1);
116+
const cleaned=await PDFDocument.load(prepared.bytes),retained=pageAnnotations(cleaned.getPage(0));
117+
assert.equal(retained.length,1);assert.equal(retained[0].get(PDFName.of('Subtype')).toString(),'/Link');
118+
assert.equal(pageAnnotations((await PDFDocument.load(original)).getPage(0)).length,4);
119+
});

0 commit comments

Comments
 (0)