Change PDF rendering

This commit is contained in:
2026-03-30 00:03:19 +02:00
parent 7f9d766ce0
commit d1bb79570d
9 changed files with 891 additions and 307 deletions

View File

@@ -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<number>} bufferSet - 1-based page numbers in the buffer zone
* @param {Set<number>} 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<number>} bufferSet
* @param {Set<number>} 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<number>} 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;
}
}