From d1bb79570d637908e8eb42953e19bce4b42642c6 Mon Sep 17 00:00:00 2001 From: Andreas Tsouchlos Date: Mon, 30 Mar 2026 00:03:19 +0200 Subject: [PATCH] Change PDF rendering --- src-tauri/assets/viewer/index.html | 322 +------------------- src-tauri/assets/viewer/message-bridge.js | 37 +++ src-tauri/assets/viewer/page-manager.js | 235 ++++++++++++++ src-tauri/assets/viewer/render-worker.js | 87 ++++++ src-tauri/assets/viewer/viewer.js | 230 ++++++++++++++ src-tauri/assets/viewer/viewport-tracker.js | 72 +++++ src-tauri/assets/viewer/zoom-controller.js | 146 +++++++++ src-tauri/src/config/mod.rs | 2 + src-tauri/src/pdf_protocol.rs | 67 ++++ 9 files changed, 891 insertions(+), 307 deletions(-) create mode 100644 src-tauri/assets/viewer/message-bridge.js create mode 100644 src-tauri/assets/viewer/page-manager.js create mode 100644 src-tauri/assets/viewer/render-worker.js create mode 100644 src-tauri/assets/viewer/viewer.js create mode 100644 src-tauri/assets/viewer/viewport-tracker.js create mode 100644 src-tauri/assets/viewer/zoom-controller.js diff --git a/src-tauri/assets/viewer/index.html b/src-tauri/assets/viewer/index.html index b341a91..a94007f 100644 --- a/src-tauri/assets/viewer/index.html +++ b/src-tauri/assets/viewer/index.html @@ -71,7 +71,7 @@ font-size: 12px; } - /* Scrollable viewport — contains the zoomed pages wrapper */ + /* Scrollable viewport — contains the pages wrapper */ #canvas-container { flex: 1; overflow-y: auto; @@ -79,8 +79,7 @@ background: #1d2021; } - /* Inner column that receives the CSS zoom for instant visual feedback. - CSS zoom (unlike transform) affects layout, so scrollbars stay correct. */ + /* Column of page placeholders/canvases */ #pages-wrapper { display: flex; flex-direction: column; @@ -91,17 +90,21 @@ margin: 0 auto; } - .page-wrapper { flex-shrink: 0; } + /* Each page: sized div that holds a canvas once rendered. + White background makes unrendered placeholders look like blank pages. */ + .page-wrapper { + flex-shrink: 0; + position: relative; + background: #fff; + box-shadow: 0 3px 14px rgba(0, 0, 0, 0.55); + } .page-wrapper canvas { display: block; - background: #fff; - box-shadow: 0 3px 14px rgba(0, 0, 0, 0.55); } -
@@ -112,309 +115,14 @@
— / — - Loading PDF.js… + Loading… -
+
+
+
- + diff --git a/src-tauri/assets/viewer/message-bridge.js b/src-tauri/assets/viewer/message-bridge.js new file mode 100644 index 0000000..32167bc --- /dev/null +++ b/src-tauri/assets/viewer/message-bridge.js @@ -0,0 +1,37 @@ +/** + * MessageBridge — postMessage protocol between the PDF viewer iframe + * and the parent Leptos application. + * + * Inbound (parent → iframe): "pdf.page.next" | "pdf.page.prev" + * Outbound (iframe → parent): { type: "brittle:keydown", key, ctrlKey, ... } + */ +export class MessageBridge { + /** + * @param {Function} onPageNext - () => void + * @param {Function} onPagePrev - () => void + */ + constructor(onPageNext, onPagePrev) { + this._handler = ev => { + if (ev.data === "pdf.page.next") onPageNext(); + if (ev.data === "pdf.page.prev") onPagePrev(); + }; + window.addEventListener("message", this._handler); + } + + /** Forward a keydown event to the parent window for global keybindings. */ + forwardKeydown(ev) { + if (window.parent === window) return; + window.parent.postMessage({ + type: "brittle:keydown", + key: ev.key, + ctrlKey: ev.ctrlKey, + shiftKey: ev.shiftKey, + altKey: ev.altKey, + metaKey: ev.metaKey, + }, "*"); + } + + disconnect() { + window.removeEventListener("message", this._handler); + } +} diff --git a/src-tauri/assets/viewer/page-manager.js b/src-tauri/assets/viewer/page-manager.js new file mode 100644 index 0000000..10a017f --- /dev/null +++ b/src-tauri/assets/viewer/page-manager.js @@ -0,0 +1,235 @@ +/** + * PageManager — page lifecycle: PLACEHOLDER → RENDERING → RENDERED → PLACEHOLDER + * + * Each page is a sized div (placeholder). When a page enters the buffer, + * a canvas is created and a render is dispatched to the worker. When a page + * leaves the buffer, its canvas is removed and the page is reset to placeholder. + */ + +const MAX_CANVAS_PIXELS = 16_777_216; // 4096 × 4096 + +/** + * Clamp render scale so the canvas pixel count stays within the budget. + * CSS dimensions stay correct — pages appear at the right size, just at + * reduced effective DPI when zoomed very high. Same strategy as Chrome's viewer. + */ +function clampedRenderScale(vpWidth, vpHeight, desiredScale, dpr) { + const w = vpWidth * desiredScale * dpr; + const h = vpHeight * desiredScale * dpr; + const pixels = w * h; + if (pixels <= MAX_CANVAS_PIXELS) return desiredScale * dpr; + return desiredScale * dpr * Math.sqrt(MAX_CANVAS_PIXELS / pixels); +} + +const State = Object.freeze({ PLACEHOLDER: 0, RENDERING: 1, RENDERED: 2 }); + +export class PageManager { + /** + * @param {HTMLElement} wrapper - #pages-wrapper container (already in DOM) + * @param {Array<{width:number,height:number}>} viewports - scale=1 viewports (0-indexed) + * @param {number} initialScale - initial display scale + * @param {number} dpr - devicePixelRatio + * @param {Function} dispatchRender - (pageNum, scale, vpWidth, vpHeight, gen) => void + */ + constructor(wrapper, viewports, initialScale, dpr, dispatchRender) { + this._wrapper = wrapper; + this._viewports = viewports; + this._scale = initialScale; + this._dpr = dpr; + this._dispatchRender = dispatchRender; + this._renderGen = 0; + this._states = new Array(viewports.length).fill(State.PLACEHOLDER); + this._canvases = new Array(viewports.length).fill(null); + this._wrappers = []; + + this._buildPlaceholders(); + } + + get pageWrappers() { return this._wrappers; } + get numPages() { return this._viewports.length; } + get renderGen() { return this._renderGen; } + + _buildPlaceholders() { + for (let i = 0; i < this._viewports.length; i++) { + const vp = this._viewports[i]; + const wrap = document.createElement("div"); + wrap.className = "page-wrapper"; + wrap.dataset.page = String(i + 1); + wrap.style.width = vp.width * this._scale + "px"; + wrap.style.height = vp.height * this._scale + "px"; + this._wrapper.appendChild(wrap); + this._wrappers.push(wrap); + } + } + + /** + * Called on each IntersectionObserver tick. + * Renders pages entering the buffer; cleans up pages leaving it. + * Visible pages are prioritized in the render queue. + * + * @param {Set} bufferSet - 1-based page numbers in the buffer zone + * @param {Set} visibleSet - 1-based page numbers currently on screen + */ + reconcile(bufferSet, visibleSet) { + const gen = this._renderGen; + + // Prioritize visible pages, then the rest of the buffer + const toRender = [ + ...[...visibleSet].sort((a, b) => a - b), + ...[...bufferSet].filter(p => !visibleSet.has(p)).sort((a, b) => a - b), + ]; + + for (const pageNum of toRender) { + const i = pageNum - 1; + if (i < 0 || i >= this._viewports.length) continue; + if (this._states[i] === State.PLACEHOLDER) { + this._startRender(i, gen); + } + } + + // Clean up pages no longer in the buffer + for (let i = 0; i < this._viewports.length; i++) { + const pageNum = i + 1; + if (!bufferSet.has(pageNum) && this._states[i] === State.RENDERED) { + this._cleanup(i); + } + // Also cancel stale RENDERING pages outside the buffer + if (!bufferSet.has(pageNum) && this._states[i] === State.RENDERING) { + this._states[i] = State.PLACEHOLDER; + } + } + } + + _startRender(i, gen) { + const vp = this._viewports[i]; + const scale = clampedRenderScale(vp.width, vp.height, this._scale, this._dpr); + this._states[i] = State.RENDERING; + this._dispatchRender(i + 1, scale, vp.width, vp.height, gen); + } + + _cleanup(i) { + this._states[i] = State.PLACEHOLDER; + const canvas = this._canvases[i]; + if (canvas) { + canvas.remove(); + this._canvases[i] = null; + } + // Reset wrapper size (without canvas it still holds placeholder dimensions) + } + + /** + * Called when the worker returns a rendered bitmap. + * + * @param {number} pageNum + * @param {number} gen - render generation the bitmap was rendered for + * @param {ImageBitmap} bitmap + */ + onRendered(pageNum, gen, bitmap) { + if (gen !== this._renderGen) { + bitmap.close(); + return; + } + const i = pageNum - 1; + if (i < 0 || i >= this._viewports.length || this._states[i] !== State.RENDERING) { + bitmap.close(); + return; + } + + const vp = this._viewports[i]; + const wrap = this._wrappers[i]; + const cssW = vp.width * this._scale; + const cssH = vp.height * this._scale; + + // Remove the old canvas (may be present — kept visible during re-render to + // avoid a blank flash). Both mutations land in the same paint frame. + const old = wrap.querySelector("canvas"); + if (old) old.remove(); + + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.style.width = cssW + "px"; + canvas.style.height = cssH + "px"; + + // Zero-copy display — fall back to drawImage if bitmaprenderer unavailable + const ctx = canvas.getContext("bitmaprenderer"); + if (ctx) { + ctx.transferFromImageBitmap(bitmap); + } else { + canvas.getContext("2d").drawImage(bitmap, 0, 0); + bitmap.close(); + } + + // Remove CSS zoom and set explicit size (canvas is now at the right dimensions) + wrap.style.zoom = "1"; + wrap.style.width = cssW + "px"; + wrap.style.height = cssH + "px"; + wrap.appendChild(canvas); + + this._canvases[i] = canvas; + this._states[i] = State.RENDERED; + } + + /** + * Called after the zoom debounce fires. + * Increments renderGen (cancels stale work), resizes all placeholders, + * then re-renders the buffer pages at the new resolution. + * + * @param {number} newScale + * @param {Set} bufferSet + * @param {Set} visibleSet + */ + onScaleChange(newScale, bufferSet, visibleSet) { + this._scale = newScale; + this._renderGen++; + + for (let i = 0; i < this._viewports.length; i++) { + const pageNum = i + 1; + const vp = this._viewports[i]; + const wrap = this._wrappers[i]; + const cssW = vp.width * newScale; + const cssH = vp.height * newScale; + wrap.style.width = cssW + "px"; + wrap.style.height = cssH + "px"; + wrap.style.zoom = "1"; + + if (bufferSet.has(pageNum)) { + // Keep the old canvas visible (stretched to the new CSS size) so there + // is no blank flash while the new render is in flight. onRendered() + // will replace it atomically in the same synchronous JS turn. + const canvas = this._canvases[i]; + if (canvas) { + canvas.style.width = cssW + "px"; + canvas.style.height = cssH + "px"; + } + if (this._states[i] !== State.PLACEHOLDER) { + this._states[i] = State.PLACEHOLDER; + } + } else { + // Off-screen: clean up immediately — not visible, so no flash. + const canvas = this._canvases[i]; + if (canvas) { canvas.remove(); this._canvases[i] = null; } + this._states[i] = State.PLACEHOLDER; + } + } + + this.reconcile(bufferSet, visibleSet); + } + + /** + * Returns the 1-based number of the topmost visible page. + * + * @param {Set} visibleSet + */ + getCurrentPage(visibleSet) { + if (visibleSet.size > 0) return Math.min(...visibleSet); + // Fall back to scroll position + const top = this._wrapper.parentElement?.getBoundingClientRect().top ?? 0; + for (const wrap of this._wrappers) { + if (wrap.getBoundingClientRect().bottom > top + 4) { + return parseInt(wrap.dataset.page, 10); + } + } + return 1; + } +} diff --git a/src-tauri/assets/viewer/render-worker.js b/src-tauri/assets/viewer/render-worker.js new file mode 100644 index 0000000..0a48d15 --- /dev/null +++ b/src-tauri/assets/viewer/render-worker.js @@ -0,0 +1,87 @@ +/** + * render-worker.js — Web Worker for off-main-thread PDF rendering. + * + * Has its own PDF.js instance. Renders pages via OffscreenCanvas and transfers + * ImageBitmap objects back to the main thread with zero-copy transfer. + * + * Message protocol: + * + * Main → Worker: + * { type: "init", pdfData: ArrayBuffer } (transferred, not copied) + * { type: "render", pageNum, scale, gen } + * { type: "cancel", gen } — renderGen check handles this implicitly + * { type: "cleanup" } — pdfDoc.cleanup() (free caches) + * { type: "destroy" } — pdfDoc.destroy(); self.close() + * + * Worker → Main: + * { type: "ready", numPages } + * { type: "rendered", pageNum, gen, bitmap } (bitmap as transferable) + * { type: "error", message } + */ + +importScripts("brittle://app/pdfjs/build/pdf.min.js"); + +const pdfjsLib = globalThis.pdfjsLib; +// Do NOT set workerSrc to a brittle:// URL here. When PDF.js tries to spawn its +// own sub-worker with new Worker("brittle://…") and that fails, it falls back to +// a "fake worker" that injects a