Skip to content

Commit 00aacf9

Browse files
committed
Draw complex pages in a background thread (experimental)
pdf.js parses in its worker but paints on the main thread, so complex plans blocked scrolling for up to half a second per piece. Documents whose pages are slow to draw (over 120 ms) now get a second pdf.js instance in a worker that paints into an OffscreenCanvas; the picture comes back as an ImageBitmap and is shown through a bitmaprenderer canvas. - Page view, sharp zoom pieces and thumbnails share one drawing helper (render/draw.ts); worker renders have their own queue, pages before thumbnails, thumbnails never taking every place. - Pages that need SVG filters (luminosity soft masks, transfer functions) are reported back and drawn on the main thread, as before. - Edits and undo keep the document in the worker; closing a document closes it there. - The editing worker hands documents it holds to other threads over a MessageChannel (copied and transferred there), so neither the drawing worker nor pdf.js's reload after an edit copies a big file on the main thread. - Preferences: "Draw complex pages in the background (experimental)". On the architectural sample at 400%, panning goes from about 36 to 60 frames per second with no long tasks; on the 286 MB plan, going to a sheet and zooming to 400% block the main thread for 0.4-0.6 s instead of about 3 s, and undo after an edit for 0.1 s instead of 0.5 s or more. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H1UeohehDf7pyxWyDSZgJg
1 parent 5c73155 commit 00aacf9

15 files changed

Lines changed: 653 additions & 67 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ instead. These files are left out of the offline cache.
8484
| Layer | Where | What |
8585
| --- | --- | --- |
8686
| Rendering | `src/lib/pdfjs.ts`, `src/components/PageView.tsx` | [pdf.js](https://mozilla.github.io/pdf.js/) renders pages to canvas plus a selectable text layer. Pages are rendered lazily as they scroll into view. |
87+
| Background drawing | `src/lib/render/` | Documents whose pages are slow to draw (plans, maps) get a second pdf.js instance in a worker that paints into an `OffscreenCanvas`; pictures come back as `ImageBitmap`s. Pages that need SVG filters (luminosity soft masks, transfer functions) are drawn on the main thread. Experimental, can be turned off in Preferences. |
8788
| Editing | `src/lib/pdfOps.ts` | [@cantoo/pdf-lib](https://github.com/cantoo-scribe/pdf-lib) (a maintained pdf-lib fork with encryption and incremental saving) implements every document change as `bytes → bytes`: page operations, watermarks, headers/footers, metadata, forms and annotation export. DOM-free and unit-tested. |
8889
| Annotations I/O | `src/lib/appearance.ts`, `src/lib/annotIO.ts` | Every annotation gets an appearance stream. Saving writes native PDF annotations (`/Highlight`, `/Ink`, `/FreeText`, …); flattening paints the same appearance into the page. On open, supported annotations from any producer are imported for editing. |
8990
| Fonts | `src/lib/fonts.ts`, `src/lib/subset.ts` | Added text uses a Noto fallback stack (Noto Sans and Symbols 2 bundled; CJK fetched on demand), subset with HarfBuzz `hb-subset` (WebAssembly). |
@@ -112,6 +113,7 @@ PDF-XChange Editor, and what comes next is in [ROADMAP.md](ROADMAP.md).
112113

113114
### View & navigate
114115
- Continuous scrolling, lazy page rendering, HiDPI canvases
116+
- Complex pages (plans, maps) are drawn in a background thread, so scrolling and zooming stay smooth while they draw (experimental; File → Preferences; uses more memory)
115117
- Zoom in/out, presets, fit width, fit page, Ctrl+wheel zoom
116118
- Hand (pan), Select Text and Edit Comments tools, text selection & copy
117119
- Snapshot tool: drag a box to copy that part of the page (with comments) to the clipboard as an image; copy/save a whole page as an image

‎src/components/PageView.tsx‎

Lines changed: 20 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react';
2-
import { AnnotationMode, TextLayer } from 'pdfjs-dist';
2+
import { TextLayer } from 'pdfjs-dist';
33
import { useStore } from '../store/store';
44
import { viewSize } from '../lib/geometry';
55
import { AnnotationLayer } from './AnnotationLayer';
66
import { WidgetLayer } from './WidgetLayer';
77
import { ContentLayer } from './ContentLayer';
88
import { registerTextLayer } from '../lib/textSelection';
9-
import { PRIORITY, renderSlot } from '../lib/renderQueue';
9+
import { PRIORITY } from '../lib/renderQueue';
1010
import { docKey, keepCanvas, takeCanvas } from '../lib/pageCache';
1111
import { TextBoxLayer } from './TextBoxSelect';
1212
import { useContextMenu } from './Menu';
1313
import { pageMenu } from './contextMenus';
14+
import { drawPage } from '../lib/render/draw';
1415

1516
/** Upper bound for a page canvas, as in pdf.js's viewer (2^24 pixels). */
1617
const MAX_CANVAS_PIXELS = 16777216;
@@ -121,7 +122,6 @@ export const PageView = memo(function PageView({ index, visible }: Props) {
121122
let cancelled = false;
122123
let renderTask: { cancel(): void } | null = null;
123124
let building: TextLayer | null = null;
124-
let endMain = () => {};
125125
(async () => {
126126
try {
127127
// Re-rendering an already drawn page (zoom, edits): wait until the zoom settles;
@@ -133,30 +133,20 @@ export const PageView = memo(function PageView({ index, visible }: Props) {
133133
const page = await pdf.getPage(index + 1);
134134
if (cancelled) return;
135135
if (drawCanvas) {
136-
const slot = renderSlot(PRIORITY.page);
137-
endMain = slot.done;
138-
await slot.ready;
139-
if (cancelled) return;
140136
// Like pdf.js's viewer, limit the canvas size: huge canvases (high zoom on a
141137
// high-density screen) are slow to draw and can exhaust the browser's memory.
142138
const css = page.getViewport({ scale, rotation: geom.rotation });
143139
const outScale = Math.min(dpr, Math.sqrt(MAX_CANVAS_PIXELS / (css.width * css.height)));
144140
const viewport = page.getViewport({ scale: scale * outScale, rotation: geom.rotation });
145141
// Render into a fresh canvas and swap it in when done, so zooming does not flash white.
146-
const canvas = document.createElement('canvas');
147-
canvas.width = Math.floor(viewport.width);
148-
canvas.height = Math.floor(viewport.height);
149-
canvas.className = 'page-canvas';
150142
// Form fields are drawn as live HTML controls by WidgetLayer, so leave them off the canvas.
151-
const task = page.render({ canvasContext: canvas.getContext('2d', { alpha: false })!, viewport, annotationMode: AnnotationMode.ENABLE_FORMS });
152-
renderTask = task;
153-
await task.promise;
154-
if (cancelled) {
155-
canvas.width = canvas.height = 0;
156-
return;
157-
}
143+
const size = { width: Math.floor(viewport.width), height: Math.floor(viewport.height) };
144+
const draw = drawPage(pdf, page, { scale: scale * outScale, rotation: geom.rotation }, size, { priority: PRIORITY.page, noForms: true });
145+
renderTask = draw;
146+
const canvas = await draw.promise;
147+
if (cancelled || !canvas) return;
148+
canvas.className = 'page-canvas';
158149
swap(canvas);
159-
endMain();
160150
}
161151
const textDiv = textRef.current;
162152
if (!textDiv) return;
@@ -187,15 +177,12 @@ export const PageView = memo(function PageView({ index, visible }: Props) {
187177
renderedText.current = textKey;
188178
} catch (e) {
189179
if ((e as Error)?.name !== 'RenderingCancelledException' && !cancelled) console.error(e);
190-
} finally {
191-
endMain();
192180
}
193181
})();
194182
return () => {
195183
cancelled = true;
196184
renderTask?.cancel();
197185
building?.cancel();
198-
endMain();
199186
};
200187
}, [pdf, docVersion, index, scale, visible, geom.rotation, opening, dpr]);
201188

@@ -228,29 +215,24 @@ export const PageView = memo(function PageView({ index, visible }: Props) {
228215
return clip({ left: v.left - v.width * k, top: v.top - v.height * k, width: v.width * (1 + 2 * k), height: v.height * (1 + 2 * k) }, w, h) ?? v;
229216
};
230217
const render = async (box: Box) => {
231-
const slot = renderSlot(PRIORITY.page);
232218
let task: { cancel(): void } | null = null;
233219
let given = false;
234-
inFlight = { box, cancel: () => { given = true; task?.cancel(); slot.done(); } };
235-
const canvas = document.createElement('canvas');
220+
inFlight = { box, cancel: () => { given = true; task?.cancel(); } };
221+
let canvas: HTMLCanvasElement | null = null;
236222
try {
237-
await slot.ready;
238-
if (disposed || given) return;
239223
const page = await pdf.getPage(index + 1);
240224
if (disposed || given) return;
241225
const k = dpr;
242-
const viewport = page.getViewport({ scale: scale * k, rotation: geom.rotation, offsetX: -box.left * k, offsetY: -box.top * k });
243-
canvas.width = Math.ceil(box.width * k);
244-
canvas.height = Math.ceil(box.height * k);
245-
canvas.className = 'page-detail';
246-
Object.assign(canvas.style, { left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px` });
247-
const t = page.render({ canvasContext: canvas.getContext('2d', { alpha: false })!, viewport, annotationMode: AnnotationMode.ENABLE_FORMS });
248-
task = t;
249-
await t.promise;
250-
if (disposed || given) {
251-
canvas.width = canvas.height = 0;
226+
const at = { scale: scale * k, rotation: geom.rotation, offsetX: -box.left * k, offsetY: -box.top * k };
227+
const draw = drawPage(pdf, page, at, { width: Math.ceil(box.width * k), height: Math.ceil(box.height * k) }, { priority: PRIORITY.page, noForms: true });
228+
task = draw;
229+
canvas = await draw.promise;
230+
if (disposed || given || !canvas) {
231+
if (canvas) canvas.width = canvas.height = 0;
252232
return;
253233
}
234+
canvas.className = 'page-detail';
235+
Object.assign(canvas.style, { left: `${box.left}px`, top: `${box.top}px`, width: `${box.width}px`, height: `${box.height}px` });
254236
host.appendChild(canvas);
255237
pieces.push({ box, canvas });
256238
while (pieces.length > DETAIL_PIECES) {
@@ -259,10 +241,9 @@ export const PageView = memo(function PageView({ index, visible }: Props) {
259241
old.canvas.width = old.canvas.height = 0;
260242
}
261243
} catch (e) {
262-
canvas.width = canvas.height = 0;
244+
if (canvas) canvas.width = canvas.height = 0;
263245
if ((e as Error)?.name !== 'RenderingCancelledException' && !disposed && !given) console.error(e);
264246
} finally {
265-
slot.done();
266247
if (inFlight?.box === box) inFlight = null;
267248
}
268249
// The view may have moved on meanwhile.

‎src/components/PrefsDialog.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export function PrefsDialog({ close }: { close: () => void }) {
1616
const author = useStore((s) => s.author);
1717
const zoom = usePrefs((p) => p.defaultZoom);
1818
const session = useSession((x) => x.enabled);
19+
const background = usePrefs((p) => p.backgroundDrawing);
1920
return (
2021
<Modal title={t('Preferences')} onClose={close} footer={<button className="primary" onClick={close}>{t('Close')}</button>}>
2122
<h3 className="prefs-heading">{t('Appearance')}</h3>
@@ -44,6 +45,9 @@ export function PrefsDialog({ close }: { close: () => void }) {
4445
<input value={author} onChange={(e) => useStore.getState().setAuthor(e.target.value)} />
4546
</label>
4647
<label className="row"><input type="checkbox" style={NOFLEX} checked={session} onChange={(e) => void setSessionEnabled(e.target.checked)} /> {t('Restore open documents at the next start')}</label>
48+
<label className="row" title={t('Complex pages (plans, maps) are drawn in a background thread, so scrolling and zooming stay smooth. Uses more memory.')}>
49+
<input type="checkbox" style={NOFLEX} checked={background !== 'off'} onChange={(e) => setPrefs({ backgroundDrawing: e.target.checked ? 'auto' : 'off' })} /> {t('Draw complex pages in the background (experimental)')}
50+
</label>
4751
<h3 className="prefs-heading">{t('Window')}</h3>
4852
<div className="row">
4953
<span className="muted small" style={{ flex: 1 }}>{t('Pane widths, open panes and the ribbon tab are remembered.')}</span>

‎src/components/Thumbnails.tsx‎

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { memo, useCallback, useEffect, useRef, useState } from 'react';
2-
import { PRIORITY, renderSlot } from '../lib/renderQueue';
2+
import { PRIORITY } from '../lib/renderQueue';
33
import { docKey, keepCanvas, takeCanvas } from '../lib/pageCache';
4+
import { drawPage } from '../lib/render/draw';
45
import { RotateCcw, RotateCw, Trash2, FilePlus2, Copy, FileOutput } from 'lucide-react';
56
import { useStore } from '../store/store';
67
import { useCan } from '../store/permissions';
@@ -75,33 +76,25 @@ const Thumb = memo(function Thumb({ index, root }: { index: number; root: HTMLEl
7576
if (kept) return swap(kept);
7677
let cancelled = false;
7778
let task: { cancel(): void } | null = null;
78-
// The pages in the main view come first.
79-
const slot = renderSlot(PRIORITY.thumbnail);
8079
(async () => {
8180
try {
82-
await slot.ready;
83-
if (cancelled) return;
8481
const page = await pdf.getPage(index + 1);
8582
if (cancelled) return;
86-
const viewport = page.getViewport({ scale: scale * Math.min(window.devicePixelRatio || 1, 1.5), rotation: geom.rotation });
87-
const canvas = document.createElement('canvas');
88-
canvas.width = Math.floor(viewport.width);
89-
canvas.height = Math.floor(viewport.height);
90-
const t = page.render({ canvasContext: canvas.getContext('2d', { alpha: false })!, viewport });
91-
task = t;
92-
await t.promise;
93-
if (cancelled) return;
83+
const at = { scale: scale * Math.min(window.devicePixelRatio || 1, 1.5), rotation: geom.rotation };
84+
const viewport = page.getViewport(at);
85+
// The pages in the main view come first.
86+
const draw = drawPage(pdf, page, at, { width: Math.floor(viewport.width), height: Math.floor(viewport.height) }, { priority: PRIORITY.thumbnail });
87+
task = draw;
88+
const canvas = await draw.promise;
89+
if (cancelled || !canvas) return;
9490
swap(canvas);
9591
} catch {
9692
/* cancelled */
97-
} finally {
98-
slot.done();
9993
}
10094
})();
10195
return () => {
10296
cancelled = true;
10397
task?.cancel();
104-
slot.done();
10598
};
10699
}, [inView, pdf, docVersion, index, scale, geom.rotation, opening]);
107100

‎src/lib/pdfjs.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
33
import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist';
44
import type { PageGeom, Rect } from './types';
55
import { normRot } from './geometry';
6+
import { rememberSource } from './render/offscreen';
67

78
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
89
/**
@@ -39,11 +40,15 @@ const ASSETS =
3940
export async function openPdf(
4041
bytes: Uint8Array,
4142
askPassword: (retry: boolean) => string | null | Promise<string | null>,
42-
/** The caller no longer needs `bytes` (a throwaway document): hand them over without copying. */
43-
opts: { handOver?: boolean } = {},
43+
/**
44+
* handOver: the caller no longer needs `bytes` (a throwaway document): hand them over
45+
* without copying. copy: a copy of `bytes` made elsewhere, for pdf.js to take.
46+
*/
47+
opts: { handOver?: boolean; copy?: Uint8Array | null } = {},
4448
): Promise<{ pdf: PDFDocumentProxy; password?: string }> {
4549
let password: string | undefined;
46-
const task = pdfjs.getDocument({ data: opts.handOver ? bytes : bytes.slice(), isEvalSupported: false, worker: worker(), ...ASSETS });
50+
const data = opts.handOver ? bytes : opts.copy ?? bytes.slice();
51+
const task = pdfjs.getDocument({ data, isEvalSupported: false, worker: worker(), ...ASSETS });
4752
task.onPassword = async (update: (pw: string) => void, reason: number) => {
4853
const pw = await askPassword(reason === pdfjs.PasswordResponses.INCORRECT_PASSWORD);
4954
if (pw === null) {
@@ -55,6 +60,8 @@ export async function openPdf(
5560
};
5661
try {
5762
const pdf = await task.promise;
63+
// Kept for drawing slow pages in the background (render/offscreen.ts); not copied.
64+
if (!opts.handOver) rememberSource(pdf, bytes, password);
5865
return { pdf, password };
5966
} catch (e) {
6067
if (password === undefined && (e as Error)?.name === 'PasswordException') throw new PasswordCancelled();

‎src/lib/prefs.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,15 @@ export interface Prefs {
1414
propsWidth: number;
1515
/** The ribbon tab shown last (File is never remembered). */
1616
ribbonTab: string;
17+
/**
18+
* Draw pages in a background thread (render/offscreen.ts): 'auto' for documents slow to
19+
* draw, 'off', or 'always' (every document; for testing, not offered in the interface).
20+
*/
21+
backgroundDrawing: 'auto' | 'off' | 'always';
1722
}
1823

1924
const KEY = 'pdf-xploder.prefs';
20-
export const DEFAULT_PREFS: Prefs = { defaultZoom: 'width', showProps: true, sidebarWidth: 240, propsWidth: 250, ribbonTab: 'Home' };
25+
export const DEFAULT_PREFS: Prefs = { defaultZoom: 'width', showProps: true, sidebarWidth: 240, propsWidth: 250, ribbonTab: 'Home', backgroundDrawing: 'auto' };
2126

2227
export const PANE_MIN = 180;
2328
export const PANE_MAX = 520;

‎src/lib/render/draw.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Draw a page, or a piece of it, into a new canvas: in the background thread when the
3+
* document is drawn there (offscreen.ts), otherwise here, one turn at a time
4+
* (renderQueue.ts). Pages slow to draw here move their document to the background.
5+
*/
6+
import { AnnotationMode, type PDFDocumentProxy, type PDFPageProxy } from 'pdfjs-dist';
7+
import { renderSlot } from '../renderQueue';
8+
import { drawOffscreen, drawsOffscreen, noteMainRender } from './offscreen';
9+
10+
export interface DrawAt {
11+
scale: number;
12+
rotation: number;
13+
offsetX?: number;
14+
offsetY?: number;
15+
}
16+
17+
export interface DrawOptions {
18+
priority: number;
19+
/** Leave form fields off (the page view shows them as HTML controls). */
20+
noForms?: boolean;
21+
}
22+
23+
/** Resolves to the drawn canvas, or null when cancelled. */
24+
export function drawPage(
25+
pdf: PDFDocumentProxy,
26+
page: PDFPageProxy,
27+
at: DrawAt,
28+
size: { width: number; height: number },
29+
o: DrawOptions,
30+
): { promise: Promise<HTMLCanvasElement | null>; cancel(): void } {
31+
let cancelled = false;
32+
let task: { cancel(): void } | null = null;
33+
let slot: ReturnType<typeof renderSlot> | null = null;
34+
const cancel = () => {
35+
cancelled = true;
36+
task?.cancel();
37+
slot?.done();
38+
};
39+
const run = async (): Promise<HTMLCanvasElement | null> => {
40+
for (;;) {
41+
const off = drawOffscreen(pdf, { page: page.pageNumber - 1, ...at, ...size, noForms: o.noForms }, o.priority);
42+
task = off;
43+
const drawn = await off.promise;
44+
if (drawn || cancelled) return cancelled ? null : drawn;
45+
slot = renderSlot(o.priority);
46+
await slot.ready;
47+
if (cancelled) return null;
48+
// Moved to the background meanwhile (another page was slow): draw it there.
49+
if (!drawsOffscreen(pdf)) break;
50+
slot.done();
51+
slot = null;
52+
}
53+
const canvas = Object.assign(document.createElement('canvas'), size);
54+
try {
55+
const t = page.render({
56+
canvasContext: canvas.getContext('2d', { alpha: false })!,
57+
viewport: page.getViewport(at),
58+
annotationMode: o.noForms ? AnnotationMode.ENABLE_FORMS : AnnotationMode.ENABLE,
59+
});
60+
task = t;
61+
const t0 = performance.now();
62+
await t.promise;
63+
noteMainRender(pdf, performance.now() - t0);
64+
} catch (e) {
65+
canvas.width = canvas.height = 0;
66+
throw e;
67+
} finally {
68+
slot?.done();
69+
}
70+
if (cancelled) {
71+
canvas.width = canvas.height = 0;
72+
return null;
73+
}
74+
return canvas;
75+
};
76+
return { promise: run(), cancel };
77+
}

0 commit comments

Comments
 (0)