Skip to content

Commit b3ab7d4

Browse files
committed
feat: add reading annotations and accelerate reader startup
- Add reading-mode highlights and comments, native PDF annotation import and durable writeback, annotation visibility, deletion gestures, and passage help. - Restore open documents and reading positions across windows and app restarts, with a shared opt-out and a last-closed-document fallback. - Reuse compatible translation caches across provider changes, preserve cache provenance, and expose the preference with a two-request default budget. - Lazy-load settings, discover kernels without blocking PDF opening, defer recent previews, and persist advanced-option schemas at installation with version invalidation and background migration. - Reduce scratch canvas retention and scrolling pixel work, clean up snapshots, pause background animation, and refine native settings and continuous corners. - Preserve the original application icon. - Bundle runtime dependencies with licenses, retain portable extraction fallbacks, complete staging manifests, and document package and memory measurements. - Expand regression and Electron smoke coverage, including annotation fade timing. Validation: production build; 162 Node tests; packaged startup, document session, advanced-cache interaction, and full annotation smoke tests. Signed macOS arm64 application reinstalled with the previous bundle backed up.
1 parent b333e54 commit b3ab7d4

59 files changed

Lines changed: 2053 additions & 156 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ npm run package:mac
100100
npm run package:win
101101
```
102102

103+
Frontend libraries (Vue, MacVue, and Fluent UI) are build dependencies: Vite includes them in `dist`. Node dependencies used by the server or Electron main process remain runtime dependencies. Install with `npm ci` before building; `npm ci --omit=dev` cannot build or package the app.
104+
105+
The default Electron package bundles Express and PDF utilities into the backend/main scripts, retaining their licenses. It copies only external runtime modules into the staged `node_modules`; native PDF Inspector bindings and the PDF.js/DOMMatrix fallback for unsupported native targets remain available. Electron itself and packaging tools are supplied by the build toolchain.
106+
103107
For browser development, set `OPENAI_API_KEY`, run `npm run dev`, and open [127.0.0.1:5173](http://127.0.0.1:5173). Use `OPENAI_MODEL` to override the default model. Desktop environment variables can be loaded with `Launch PDFMathReader.command`.
104108

105109
```sh

‎doc/performance/memory-audit.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Memory audit — 2026-10-04
2+
3+
Live sampling of the existing /Applications installation (not the modified build):
4+
5+
| Process | PID | Physical footprint |
6+
| --- | --- | --- |
7+
| GPU Helper | 61340 | 850.0 MiB |
8+
| Reader Renderer | 61344 | 766.7 MiB |
9+
| Node utility | 61343 | 69.3 MiB |
10+
11+
GPU IOSurface allocations accounted for 748.7 MiB. vmmap could not inspect Electron's PartitionAlloc malloc zone; this establishes the graphics-heavy footprint, not complete object-level attribution. RSS from ps is not comparable to Activity Monitor physical footprint. Do not add these numbers as a deduplicated whole-system memory total.
12+
13+
Changes:
14+
15+
- MathRegion releases the completed render task and zeroes its temporary canvas in finally, including cancellation and stale-generation paths.
16+
- History thumbnail rendering zeroes the scratch canvas in finally.
17+
- Reading mode skips unused outgoing full-page snapshots.
18+
- Comparison animation consumes the existing snapshot instead of allocating another copy. Callers release snapshots after completion, cancellation, and skipped animations.
19+
- A render that completes after foreground deactivation cannot present/cache its frame.
20+
21+
Resolution, 64 MiB bitmap cache, 128 MiB resident page budget, prefetch window, translation concurrency and document isolation are unchanged.
22+
23+
Verification:
24+
25+
- npm run build passed.
26+
- npm test passed 124 tests at the time of the audit.
27+
- Electron performance smoke passed 120-page and 1000-page scenarios, layouts, scrolling, thumbnails, foreground/background transitions and document close. Close leaves cache bytes and registered PDF bytes at zero. The minimize assertion now captures its baseline after foreground=false is acknowledged: macOS can continue normal rendering during its minimize animation.
28+
- An isolated Electron renderer mounted twenty actual MathRegion Vue components with identical deterministic render input before and after the change. Retained scratch canvas backing dimensions dropped from 12,800,000 bytes to zero, while displayed canvas dimensions remained 12,800,000 bytes and sampled RGBA stayed [18,52,86,255]. See memory-region-result.json. This is canvas backing storage accounting, not measured GPU physical-footprint savings. The harness and bundles are in .cache/memory-audit/.
29+
30+
Logs: /tmp/pdf-memory-before.log, /tmp/pdf-memory-after.log, /tmp/pdf-memory-tests.log, /tmp/pdf-memory-component.log. vmmap summaries: /tmp/pdf-memory-{gpu,renderer,backend}-vmmap.txt.
31+
32+
Limits: no before/after whole-app measurement on the user's actual PDF and no installed-app replacement. The large live GPU/renderer numbers are from the existing installation. Native extraction bursts can raise backend peak memory; limiting them could affect throughput, so concurrency was preserved. Whole-app savings and latency equivalence on real image-heavy/translated PDFs remain unquantified. Concurrent annotation-related working-tree edits were preserved and are outside this audit's scope.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"before": {
3+
"displayed": 12800000,
4+
"temporary": 12800000,
5+
"pixel": [
6+
18,
7+
52,
8+
86,
9+
255
10+
]
11+
},
12+
"after": {
13+
"displayed": 12800000,
14+
"temporary": 0,
15+
"pixel": [
16+
18,
17+
52,
18+
86,
19+
255
20+
]
21+
}
22+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Build and runtime dependencies
2+
3+
Frontend libraries (`vue`, `@macvue/core`, `@fluentui/tokens`, and
4+
`@fluentui/web-components`) are devDependencies because Vite emits their code
5+
into `dist`. Express, pdf-lib, PDF Inspector, PDF.js, and DOMMatrix remain
6+
runtime dependencies for direct Node/Electron execution and portable extraction.
7+
8+
The default `bundle` stage incorporates Express and pdf-lib into the server/main
9+
bundles. A Node `createRequire` banner supports bundled CommonJS dependencies.
10+
Both bundles retain incorporated packages' licenses. Staged `node_modules`
11+
contains external runtime modules only; PDF.js and DOMMatrix are retained when
12+
the target has no native Inspector binding. Test packages additionally include
13+
pdf-lib for their fixtures.
14+
15+
## Local validation (2026-10-04, macOS arm64)
16+
17+
Using identical freshly built `dist` contents:
18+
19+
| Production staged content | Bytes | MiB |
20+
| --- | ---: | ---: |
21+
| Before Express bundling | 18,325,719 | 17.48 |
22+
| After Express bundling | 17,002,252 | 16.21 |
23+
| Saved | 1,323,467 | 1.26 |
24+
25+
This is a 7.22% reduction of application content, excluding Electron itself.
26+
It is not a measurement of the compressed distribution ZIP.
27+
28+
- `npm run build`: passed.
29+
- `npm test`: 155 tests passed, including staged backend HTTP authentication,
30+
PDF upload/text extraction, and portable target dependency checks.
31+
- `node electron/package.mjs --test --unsigned`: generated an ASAR macOS arm64
32+
test app with Electron 44.5.1. Test fixtures make this larger than production.
33+
- Desktop startup invocation exited with status 1 after a macOS
34+
`sandbox_extension_issue_file_to_process` permission error; GUI startup remains
35+
unverified.

‎doc/performance/scroll-preview.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Large-document scrolling preview — 2026-10-04
2+
3+
Previously, scrolling prevented renderPages from drawing and PDF.js onContinue postponed all render work until the 180 ms idle debounce elapsed. Newly visible pages could therefore remain blank throughout sustained scrolling.
4+
5+
Scrolling now paints only visible pages with a preview ratio capped at 1 and a nominal 4 MiB per-frame pixel budget. Pixel rounding can add a small amount above the nominal budget. Existing sharper displayed frames are retained. PDF.js can continue visible-page work while scrolling; offscreen and thumbnail work is cancelled/deferred. Idle restores the normal render ratio, neighboring-page prefetch, and thumbnails. Translation scheduling, document geometry, canvas cache and resident budgets are unchanged.
6+
7+
Build and 129 unit tests passed. The existing 120/1000-page performance smoke passed layout, scrolling, thumbnail, pause/resume, memory-bound and document-close checks. Logs: /tmp/pdf-scroll-build.log, /tmp/pdf-scroll-tests.log and /tmp/pdf-scroll-performance.log.
8+
9+
A synthetic test validates software behavior; real scanned PDFs and physical trackpad responsiveness remain to be evaluated. The modified source has not replaced /Applications/PDFMathReader.app.
10+
11+
The sustained-scroll regression holds scroll events every 40 ms on a previously unrendered page 500 in a 1000-page document. It passed with the preview visible after 100 ms while previewScrolling remained true (DPR 1, 2,733,520 canvas bytes); DPR returned to 2 after the scroll hold was removed (235 ms wait including idle debounce). Center pixels, memory budgets, and document-close release passed. Final smoke log: /tmp/pdf-scroll-performance-final.log. These timings are a single synthetic run, not a real-document latency guarantee.

‎electron/advanced-cache-smoke.mjs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import assert from 'node:assert/strict';
2+
import {app} from 'electron';
3+
export async function verifyAdvancedCache(window){
4+
const evaluate=code=>window.webContents.executeJavaScript(code);
5+
const wait=async code=>{for(let i=0;i<400;i++){if(await evaluate(code))return;await new Promise(resolve=>setTimeout(resolve,50));}throw Error('Advanced cache UI timed out: '+code);};
6+
await wait('window.previewReady===true');
7+
await evaluate('document.querySelector(\'[aria-label="Translation settings"]\').click()');
8+
await wait('!!document.querySelector(".mac-mode-control button")');
9+
await evaluate('[...document.querySelectorAll(".mac-mode-control button")].find(button=>button.textContent.trim()==="Fast").click()');
10+
await wait('!document.querySelector(".mac-mode-control button").disabled');
11+
await wait('!!document.querySelector(".advanced-settings summary")');
12+
// A delayed schema must leave the rest of Settings interactive.
13+
await evaluate(`(()=>{const upstream=window.fetch;window.__advancedPending=0;window.fetch=(url,options)=>{if(String(url).endsWith('/advanced')&&window.__advancedPending++<2)return Promise.resolve(new Response(JSON.stringify({id:'pdf_math_fast',options:[],pending:true}),{headers:{'Content-Type':'application/json'}}));return upstream(url,options);};document.querySelector('.advanced-settings summary').click();})()`);
14+
await wait('window.__advancedPending>=1');
15+
assert.equal(await evaluate('document.querySelector(".advanced-settings").open'),true);
16+
assert.equal(await evaluate('document.querySelector(".mac-mode-control button").disabled'),false);
17+
await evaluate('document.querySelector(".advanced-settings summary").click()');
18+
assert.equal(await evaluate('document.querySelector(".advanced-settings").open'),false,'Settings can collapse during detection');
19+
await evaluate('document.querySelector(".advanced-settings summary").click()');
20+
await wait('document.querySelectorAll(".advanced-option").length>0');
21+
const result=await evaluate(`fetch('/api/engines/pdf_math_fast/advanced').then(response=>response.json())`);
22+
assert.equal(result.pending,undefined);assert.ok(result.options.length>0);
23+
const second=await evaluate(`fetch('/api/engines/pdf_math_fast/advanced').then(response=>response.json())`);
24+
assert.deepEqual(second,result);
25+
console.log('Advanced cache UI passed: pending detection leaves controls interactive, collapse cancels polling, cached schema renders and repeats.');
26+
app.exit(0);
27+
}

‎electron/annotation-import.mjs‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {validateAnnotation} from './annotations.mjs';
2+
import {PDFDocument,PDFDict,PDFName,PDFHexString,PDFString,PDFArray,PDFNumber} from 'pdf-lib';
3+
function text(dict,key){return dict.lookupMaybe(PDFName.of(key),PDFString,PDFHexString)?.decodeText()||'';}
4+
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()):[];}
5+
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();}
6+
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};}
7+
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];}
8+
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){
10+
const pdf=await PDFDocument.load(bytes,{updateMetadata:false}),annotations=[],nativeRefs=[];
11+
for(const [index,page] of pdf.getPages().entries()){
12+
const array=page.node.Annots(),g=geometry(page);if(!array)continue;
13+
for(let i=0;i<array.size();i++){
14+
const dict=array.lookupMaybe(i,PDFDict);if(!dict)continue;
15+
if(text(dict,'NM').startsWith('PDFMathReader:')){try{const a=JSON.parse(text(dict,'PDFMathReader'));if(a?.id)annotations.push(validateAnnotation(a));}catch{}continue;}
16+
const kind=dict.lookupMaybe(PDFName.of('Subtype'),PDFName)?.toString();if(!['/Highlight','/Text'].includes(kind))continue;
17+
const rect=numbers(dict,'Rect');if(rect.length!==4)continue;
18+
const sourceRect=bounds([[rect[0],rect[1]],[rect[2],rect[3]]],g);if(!sourceRect)continue;
19+
const quads=kind==='/Highlight'?numbers(dict,'QuadPoints'):[],rects=[];
20+
for(let q=0;q+7<quads.length;q+=8){const r=bounds([[quads[q],quads[q+1]],[quads[q+2],quads[q+3]],[quads[q+4],quads[q+5]],[quads[q+6],quads[q+7]]],g);if(r)rects.push(r);}
21+
if(!rects.length)rects.push(sourceRect);
22+
const nativeRef=array.get(i).toString();if(!/^\d+ \d+ R$/.test(nativeRef))continue;
23+
const color=numbers(dict,'C');const rgb=color.length===3?color:color.length===1?[color[0],color[0],color[0]]:color.length===4?color.slice(0,3).map(c=>(1-c)*(1-color[3])):[1,.95,.42];
24+
const hex='#'+rgb.map(v=>Math.round(Math.max(0,Math.min(1,v))*255).toString(16).padStart(2,'0')).join('');
25+
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);
26+
}
27+
}
28+
return {annotations,nativeRefs};
29+
}

0 commit comments

Comments
 (0)