Slightly reduce the memory used by thumbnails

In using a blob instead of a base64 string it's possible to reduce the memory.
And simplify a bit the thumbnails themselves.
This commit is contained in:
Calixte Denizet 2025-11-25 22:25:07 +01:00 committed by calixteman
parent a965536485
commit 423379e42f
No known key found for this signature in database
GPG Key ID: 0C5442631EE0691F
4 changed files with 102 additions and 114 deletions

View File

@ -21,13 +21,13 @@ describe("PDF Thumbnail View", () => {
await page.waitForSelector(thumbSelector, { visible: true }); await page.waitForSelector(thumbSelector, { visible: true });
await page.waitForSelector( await page.waitForSelector(
'#thumbnailView .thumbnail[data-loaded="true"]' "#thumbnailView .thumbnail:not(.missingThumbnailImage)"
); );
const src = await page.$eval(thumbSelector, el => el.src); const src = await page.$eval(thumbSelector, el => el.src);
expect(src) expect(src)
.withContext(`In ${browserName}`) .withContext(`In ${browserName}`)
.toMatch(/^data:image\//); .toMatch(/^blob:http:/);
}) })
); );
}); });

View File

@ -23,7 +23,11 @@
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
/** @typedef {import("./pdf_rendering_queue").PDFRenderingQueue} PDFRenderingQueue */ /** @typedef {import("./pdf_rendering_queue").PDFRenderingQueue} PDFRenderingQueue */
import { OutputScale, RenderingCancelledException } from "pdfjs-lib"; import {
FeatureTest,
OutputScale,
RenderingCancelledException,
} from "pdfjs-lib";
import { AppOptions } from "./app_options.js"; import { AppOptions } from "./app_options.js";
import { RenderingStates } from "./ui_utils.js"; import { RenderingStates } from "./ui_utils.js";
@ -31,13 +35,6 @@ const DRAW_UPSCALE_FACTOR = 2; // See comment in `PDFThumbnailView.draw` below.
const MAX_NUM_SCALING_STEPS = 3; const MAX_NUM_SCALING_STEPS = 3;
const THUMBNAIL_WIDTH = 98; // px const THUMBNAIL_WIDTH = 98; // px
function zeroCanvas(c) {
// Zeroing the width and height causes Firefox to release graphics
// resources immediately, which can greatly reduce memory consumption.
c.width = 0;
c.height = 0;
}
/** /**
* @typedef {Object} PDFThumbnailViewOptions * @typedef {Object} PDFThumbnailViewOptions
* @property {HTMLDivElement} container - The viewer element. * @property {HTMLDivElement} container - The viewer element.
@ -61,12 +58,15 @@ function zeroCanvas(c) {
*/ */
class TempImageFactory { class TempImageFactory {
static #tempCanvas = null;
static getCanvas(width, height) { static getCanvas(width, height) {
const tempCanvas = (this.#tempCanvas ||= document.createElement("canvas")); let tempCanvas;
if (FeatureTest.isOffscreenCanvasSupported) {
tempCanvas = new OffscreenCanvas(width, height);
} else {
tempCanvas = document.createElement("canvas");
tempCanvas.width = width; tempCanvas.width = width;
tempCanvas.height = height; tempCanvas.height = height;
}
// Since this is a temporary canvas, we need to fill it with a white // Since this is a temporary canvas, we need to fill it with a white
// background ourselves. `#getPageDrawContext` uses CSS rules for this. // background ourselves. `#getPageDrawContext` uses CSS rules for this.
@ -75,14 +75,7 @@ class TempImageFactory {
ctx.fillStyle = "rgb(255, 255, 255)"; ctx.fillStyle = "rgb(255, 255, 255)";
ctx.fillRect(0, 0, width, height); ctx.fillRect(0, 0, width, height);
ctx.restore(); ctx.restore();
return [tempCanvas, tempCanvas.getContext("2d")]; return [tempCanvas, ctx];
}
static destroyCanvas() {
if (this.#tempCanvas) {
zeroCanvas(this.#tempCanvas);
}
this.#tempCanvas = null;
} }
} }
@ -126,27 +119,24 @@ class PDFThumbnailView {
this.renderingState = RenderingStates.INITIAL; this.renderingState = RenderingStates.INITIAL;
this.resume = null; this.resume = null;
const anchor = document.createElement("a"); const anchor = (this.anchor = document.createElement("a"));
anchor.href = linkService.getAnchorUrl("#page=" + id); anchor.href = linkService.getAnchorUrl(`#page=${id}`);
anchor.setAttribute("data-l10n-id", "pdfjs-thumb-page-title"); anchor.setAttribute("data-l10n-id", "pdfjs-thumb-page-title");
anchor.setAttribute("data-l10n-args", this.#pageL10nArgs); anchor.setAttribute("data-l10n-args", this.#pageL10nArgs);
anchor.onclick = function () { anchor.onclick = () => {
linkService.goToPage(id); linkService.goToPage(id);
return false; return false;
}; };
this.anchor = anchor;
const div = document.createElement("div"); const div = (this.div = document.createElement("div"));
div.className = "thumbnail"; div.classList.add("thumbnail", "missingThumbnailImage");
div.setAttribute("data-page-number", this.id); div.setAttribute("data-page-number", this.id);
this.div = div;
this.#updateDims(); this.#updateDims();
const img = document.createElement("div"); const image = (this.image = document.createElement("img"));
img.className = "thumbnailImage"; image.className = "thumbnailImage";
this._placeholderImg = img;
div.append(img); div.append(image);
anchor.append(div); anchor.append(div);
container.append(anchor); container.append(anchor);
} }
@ -155,13 +145,11 @@ class PDFThumbnailView {
const { width, height } = this.viewport; const { width, height } = this.viewport;
const ratio = width / height; const ratio = width / height;
this.canvasWidth = THUMBNAIL_WIDTH; const canvasWidth = (this.canvasWidth = THUMBNAIL_WIDTH);
this.canvasHeight = (this.canvasWidth / ratio) | 0; const canvasHeight = (this.canvasHeight = (canvasWidth / ratio) | 0);
this.scale = this.canvasWidth / width; this.scale = canvasWidth / width;
const { style } = this.div; this.div.style.height = `${canvasHeight}px`;
style.setProperty("--thumbnail-width", `${this.canvasWidth}px`);
style.setProperty("--thumbnail-height", `${this.canvasHeight}px`);
} }
setPdfPage(pdfPage) { setPdfPage(pdfPage) {
@ -175,14 +163,16 @@ class PDFThumbnailView {
reset() { reset() {
this.cancelRendering(); this.cancelRendering();
this.renderingState = RenderingStates.INITIAL; this.renderingState = RenderingStates.INITIAL;
this.div.removeAttribute("data-loaded");
this.image?.replaceWith(this._placeholderImg);
this.#updateDims(); this.#updateDims();
if (this.image) { const { image } = this;
this.image.removeAttribute("src"); const url = image.src;
delete this.image; if (url) {
URL.revokeObjectURL(url);
image.removeAttribute("data-l10n-id");
image.removeAttribute("data-l10n-args");
image.src = "";
this.div.classList.add("missingThumbnailImage");
} }
} }
@ -213,7 +203,6 @@ class PDFThumbnailView {
#getPageDrawContext(upscaleFactor = 1) { #getPageDrawContext(upscaleFactor = 1) {
// Keep the no-thumbnail outline visible, i.e. `data-loaded === false`, // Keep the no-thumbnail outline visible, i.e. `data-loaded === false`,
// until rendering/image conversion is complete, to avoid display issues. // until rendering/image conversion is complete, to avoid display issues.
const canvas = document.createElement("canvas");
const outputScale = new OutputScale(); const outputScale = new OutputScale();
const width = upscaleFactor * this.canvasWidth, const width = upscaleFactor * this.canvasWidth,
height = upscaleFactor * this.canvasHeight; height = upscaleFactor * this.canvasHeight;
@ -224,6 +213,9 @@ class PDFThumbnailView {
this.maxCanvasPixels, this.maxCanvasPixels,
this.maxCanvasDim this.maxCanvasDim
); );
// Because of: https://bugzilla.mozilla.org/show_bug.cgi?id=2003060
// we need use a HTMLCanvasElement here.
const canvas = document.createElement("canvas");
canvas.width = (width * outputScale.sx) | 0; canvas.width = (width * outputScale.sx) | 0;
canvas.height = (height * outputScale.sy) | 0; canvas.height = (height * outputScale.sy) | 0;
@ -234,23 +226,23 @@ class PDFThumbnailView {
return { canvas, transform }; return { canvas, transform };
} }
#convertCanvasToImage(canvas) { async #convertCanvasToImage(canvas) {
if (this.renderingState !== RenderingStates.FINISHED) { if (this.renderingState !== RenderingStates.FINISHED) {
throw new Error("#convertCanvasToImage: Rendering has not finished."); throw new Error("#convertCanvasToImage: Rendering has not finished.");
} }
const reducedCanvas = this.#reduceImage(canvas); const reducedCanvas = this.#reduceImage(canvas);
const { image } = this;
const image = document.createElement("img"); const { promise, resolve } = Promise.withResolvers();
image.className = "thumbnailImage"; reducedCanvas.toBlob(resolve);
const blob = await promise;
image.src = URL.createObjectURL(blob);
image.setAttribute("data-l10n-id", "pdfjs-thumb-page-canvas"); image.setAttribute("data-l10n-id", "pdfjs-thumb-page-canvas");
image.setAttribute("data-l10n-args", this.#pageL10nArgs); image.setAttribute("data-l10n-args", this.#pageL10nArgs);
image.src = reducedCanvas.toDataURL(); this.div.classList.remove("missingThumbnailImage");
this.image = image; if (!FeatureTest.isOffscreenCanvasSupported) {
// Clean up the canvas element since it is no longer needed.
this.div.setAttribute("data-loaded", true); reducedCanvas.width = reducedCanvas.height = 0;
this._placeholderImg.replaceWith(image); }
zeroCanvas(reducedCanvas);
} }
async draw() { async draw() {
@ -303,7 +295,6 @@ class PDFThumbnailView {
await renderTask.promise; await renderTask.promise;
} catch (e) { } catch (e) {
if (e instanceof RenderingCancelledException) { if (e instanceof RenderingCancelledException) {
zeroCanvas(canvas);
return; return;
} }
error = e; error = e;
@ -317,8 +308,7 @@ class PDFThumbnailView {
} }
this.renderingState = RenderingStates.FINISHED; this.renderingState = RenderingStates.FINISHED;
this.#convertCanvasToImage(canvas); await this.#convertCanvasToImage(canvas);
zeroCanvas(canvas);
this.eventBus.dispatch("thumbnailrendered", { this.eventBus.dispatch("thumbnailrendered", {
source: this, source: this,
@ -449,14 +439,9 @@ class PDFThumbnailView {
*/ */
setPageLabel(label) { setPageLabel(label) {
this.pageLabel = typeof label === "string" ? label : null; this.pageLabel = typeof label === "string" ? label : null;
this.anchor.setAttribute("data-l10n-args", this.#pageL10nArgs); this.anchor.setAttribute("data-l10n-args", this.#pageL10nArgs);
this.image.setAttribute("data-l10n-args", this.#pageL10nArgs);
if (this.renderingState !== RenderingStates.FINISHED) {
return;
}
this.image?.setAttribute("data-l10n-args", this.#pageL10nArgs);
} }
} }
export { PDFThumbnailView, TempImageFactory }; export { PDFThumbnailView };

View File

@ -27,7 +27,7 @@ import {
scrollIntoView, scrollIntoView,
watchScroll, watchScroll,
} from "./ui_utils.js"; } from "./ui_utils.js";
import { PDFThumbnailView, TempImageFactory } from "./pdf_thumbnail_view.js"; import { PDFThumbnailView } from "./pdf_thumbnail_view.js";
const THUMBNAIL_SCROLL_MARGIN = -19; const THUMBNAIL_SCROLL_MARGIN = -19;
const THUMBNAIL_SELECTED_CLASS = "selected"; const THUMBNAIL_SELECTED_CLASS = "selected";
@ -174,7 +174,6 @@ class PDFThumbnailViewer {
thumbnail.reset(); thumbnail.reset();
} }
} }
TempImageFactory.destroyCanvas();
} }
#resetView() { #resetView() {
@ -209,10 +208,11 @@ class PDFThumbnailViewer {
.then(firstPdfPage => { .then(firstPdfPage => {
const pagesCount = pdfDocument.numPages; const pagesCount = pdfDocument.numPages;
const viewport = firstPdfPage.getViewport({ scale: 1 }); const viewport = firstPdfPage.getViewport({ scale: 1 });
const fragment = document.createDocumentFragment();
for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) { for (let pageNum = 1; pageNum <= pagesCount; ++pageNum) {
const thumbnail = new PDFThumbnailView({ const thumbnail = new PDFThumbnailView({
container: this.container, container: fragment,
eventBus: this.eventBus, eventBus: this.eventBus,
id: pageNum, id: pageNum,
defaultViewport: viewport.clone(), defaultViewport: viewport.clone(),
@ -234,6 +234,7 @@ class PDFThumbnailViewer {
// Ensure that the current thumbnail is always highlighted on load. // Ensure that the current thumbnail is always highlighted on load.
const thumbnailView = this._thumbnails[this._currentPageNumber - 1]; const thumbnailView = this._thumbnails[this._currentPageNumber - 1];
thumbnailView.div.classList.add(THUMBNAIL_SELECTED_CLASS); thumbnailView.div.classList.add(THUMBNAIL_SELECTED_CLASS);
this.container.append(fragment);
}) })
.catch(reason => { .catch(reason => {
console.error("Unable to initialize thumbnail viewer", reason); console.error("Unable to initialize thumbnail viewer", reason);

View File

@ -717,61 +717,63 @@ body {
} }
#thumbnailView { #thumbnailView {
--thumbnail-width: 98px;
display: flex;
flex-wrap: wrap;
width: calc(100% - 60px); width: calc(100% - 60px);
padding: 10px 30px 0; padding: 10px 30px 0;
}
#thumbnailView > a:is(:active, :focus) { > a {
outline: 0; width: auto;
} height: auto;
.thumbnail { > .thumbnail {
/* Define these variables here, and not in :root, since the individual
thumbnails may have different sizes. */
--thumbnail-width: 0;
--thumbnail-height: 0;
float: var(--inline-start);
width: var(--thumbnail-width); width: var(--thumbnail-width);
height: var(--thumbnail-height);
margin: 0 10px 5px; margin: 0 10px 5px;
padding: 1px; padding: 1px;
border: 7px solid transparent; border: 7px solid transparent;
border-radius: 2px; border-radius: 2px;
&.selected {
border-color: var(--thumbnail-selected-color) !important;
> .thumbnailImage {
opacity: 1 !important;
}
} }
#thumbnailView > a:last-of-type > .thumbnail { &.missingThumbnailImage {
border: 1px dashed rgb(132 132 132);
padding: 7px;
> .thumbnailImage {
display: none;
}
}
> .thumbnailImage {
width: 100%;
opacity: 0.9;
}
}
&:is(:active, :focus) {
outline: 0;
}
&:last-of-type > .thumbnail {
margin-bottom: 10px; margin-bottom: 10px;
} }
a:focus > .thumbnail, &:focus > .thumbnail,
.thumbnail:hover { .thumbnail:hover {
border-color: var(--thumbnail-hover-color); border-color: var(--thumbnail-hover-color);
}
.thumbnail.selected { > .thumbnailImage {
border-color: var(--thumbnail-selected-color) !important;
}
.thumbnailImage {
width: var(--thumbnail-width);
height: var(--thumbnail-height);
opacity: 0.9;
}
a:focus > .thumbnail > .thumbnailImage,
.thumbnail:hover > .thumbnailImage {
opacity: 0.95; opacity: 0.95;
} }
.thumbnail.selected > .thumbnailImage {
opacity: 1 !important;
} }
}
.thumbnail:not([data-loaded]) > .thumbnailImage {
width: calc(var(--thumbnail-width) - 2px);
height: calc(var(--thumbnail-height) - 2px);
border: 1px dashed rgb(132 132 132);
} }
.treeWithDeepNesting > .treeItem, .treeWithDeepNesting > .treeItem,