diff --git a/package.json b/package.json
index 814a18e..d29e475 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,7 @@
"scripts": {
"build": "node scripts/build.mjs",
"check": "node scripts/check-extension.mjs",
- "test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs"
+ "test": "node --test tests/enhancer-setup.test.mjs tests/document-title.test.mjs tests/metadata-doi.test.mjs tests/local-file-favicon.test.mjs tests/page-tab-labels.test.mjs tests/minimap.test.mjs tests/clickable-reference-links.test.mjs tests/printed-page-jump.test.mjs tests/markdown-copy.test.mjs tests/terminal-punctuation-selection.test.mjs tests/footnote-click-copy.test.mjs tests/chatgpt-summary-popup.test.mjs tests/chatgpt-pdf-relay.test.mjs tests/selection-gaps.test.mjs tests/image-gallery.test.mjs"
},
"dependencies": {
"pdfjs-dist": "6.3.289",
diff --git a/src/viewer.html b/src/viewer.html
index eeb4de1..0c8b864 100644
--- a/src/viewer.html
+++ b/src/viewer.html
@@ -14,6 +14,7 @@
+
@@ -327,6 +328,7 @@
Scan file URL
+
diff --git a/src/viewer/images/image-gallery-utils.js b/src/viewer/images/image-gallery-utils.js
new file mode 100644
index 0000000..d02a76f
--- /dev/null
+++ b/src/viewer/images/image-gallery-utils.js
@@ -0,0 +1,38 @@
+// Coordinates are normalized to the unrotated, rendered PDF page. Each
+// image contributes three corners: top-left, bottom-left, and top-right.
+const clampUnit = (value) => Math.max(0, Math.min(1, value));
+
+export function imageRegionsFromCoordinates(coordinates, pageWidth, pageHeight, {
+ minimumSide = 24,
+ maximumPageCoverage = 0.8,
+} = {}) {
+ if (!coordinates?.length || !pageWidth || !pageHeight) return [];
+ const regions = [];
+ for (let offset = 0; offset + 5 < coordinates.length; offset += 6) {
+ const corners = Array.from(coordinates.slice(offset, offset + 6));
+ if (!corners.every(Number.isFinite)) continue;
+ const [ax, ay, bx, by, cx, cy] = corners;
+ const xs = [ax, bx, cx, bx + cx - ax];
+ const ys = [ay, by, cy, by + cy - ay];
+ const x = clampUnit(Math.min(...xs));
+ const y = clampUnit(Math.min(...ys));
+ const width = clampUnit(Math.max(...xs)) - x;
+ const height = clampUnit(Math.max(...ys)) - y;
+ if (width <= 0 || height <= 0) continue;
+ if (width * height >= maximumPageCoverage) continue; // scanned page backgrounds
+ if (width * pageWidth < minimumSide || height * pageHeight < minimumSide) continue;
+ regions.push({ x, y, width, height });
+ }
+ return regions;
+}
+
+export function imageCropPixels(region, canvasWidth, canvasHeight) {
+ const x = Math.max(0, Math.floor(region.x * canvasWidth));
+ const y = Math.max(0, Math.floor(region.y * canvasHeight));
+ return {
+ x,
+ y,
+ width: Math.min(canvasWidth - x, Math.max(1, Math.ceil((region.x + region.width) * canvasWidth) - x)),
+ height: Math.min(canvasHeight - y, Math.max(1, Math.ceil((region.y + region.height) * canvasHeight) - y)),
+ };
+}
diff --git a/src/viewer/images/image-gallery.css b/src/viewer/images/image-gallery.css
new file mode 100644
index 0000000..de0e785
--- /dev/null
+++ b/src/viewer/images/image-gallery.css
@@ -0,0 +1,99 @@
+.image-gallery-dialog {
+ width: min(1000px, calc(100vw - 30px));
+ max-width: none;
+ max-height: min(92dvh, 1000px);
+ margin: auto;
+ padding: 0;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ color: var(--text);
+ background: var(--toolbar-bg);
+ box-shadow: 0 28px 100px rgb(0 0 0 / 40%);
+}
+.image-gallery-dialog::backdrop { background: rgb(0 0 0 / 76%); backdrop-filter: blur(6px); }
+.image-gallery-panel { display: flex; flex-direction: column; height: min(820px, 90dvh); min-height: 320px; }
+.image-gallery-header, .image-gallery-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ padding: 12px 18px;
+}
+.image-gallery-header { border-bottom: 1px solid var(--border); }
+.image-gallery-header h2 { font-size: 16px; font-weight: 650; line-height: 1.4; margin: 0; }
+.image-gallery-status { color: var(--muted); font-size: 12px; margin: 4px 0 0; }
+.image-gallery-close, .image-gallery-previous, .image-gallery-next {
+ flex: none;
+ display: grid;
+ place-items: center;
+ padding: 0;
+ border: 0;
+ border-radius: 9px;
+ background: var(--control-bg);
+ color: var(--text);
+ cursor: pointer;
+}
+.image-gallery-close { width: 32px; height: 32px; font-size: 25px; }
+.image-gallery-previous, .image-gallery-next { width: 38px; height: 56px; font-size: 36px; }
+.image-gallery-close:hover, .image-gallery-previous:hover:not(:disabled), .image-gallery-next:hover:not(:disabled), .image-gallery-jump:hover { background: var(--control-hover); }
+.image-gallery-dialog button:focus-visible { outline: 2px solid var(--search-focus-border); outline-offset: 2px; }
+.image-gallery-dialog button:disabled { opacity: 0.25; cursor: default; }
+.image-gallery-stage {
+ min-height: 0;
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px;
+}
+.image-gallery-picture {
+ min-height: 0;
+ flex: 1;
+ display: grid;
+ place-items: center;
+ height: 100%;
+ overflow: hidden;
+ background: rgb(120 120 125 / 8%);
+ border-radius: 10px;
+}
+.image-gallery-picture img { display: block; width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; filter: none; }
+.image-gallery-empty { max-width: 35ch; color: var(--muted); font-size: 13px; text-align: center; line-height: 1.6; }
+.image-gallery-footer { border-top: 1px solid var(--border); font-size: 13px; }
+.image-gallery-jump { padding: 8px 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--control-bg); color: var(--text); cursor: pointer; white-space: nowrap; }
+.image-gallery-thumbnails {
+ display: flex;
+ flex: none;
+ gap: 8px;
+ padding: 10px 14px 14px;
+ min-height: 105px;
+ max-height: 145px;
+ overflow-x: auto;
+ overflow-y: hidden;
+ border-top: 1px solid var(--border);
+ overscroll-behavior-inline: contain;
+}
+.image-gallery-thumb {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ align-items: center;
+ flex: 0 0 90px;
+ height: 100px;
+ gap: 5px;
+ padding: 5px;
+ border: 2px solid transparent;
+ border-radius: 9px;
+ background: var(--control-bg);
+ color: var(--muted);
+ font-size: 11px;
+ cursor: pointer;
+}
+.image-gallery-thumb[aria-pressed="true"] { border-color: var(--page-border); color: var(--text); }
+.image-gallery-thumb img { max-width: 100%; max-height: 68px; min-height: 0; object-fit: contain; border-radius: 3px; filter: none; }
+@media (max-width: 540px) {
+ .image-gallery-stage { gap: 5px; padding: 6px; }
+ .image-gallery-previous, .image-gallery-next { width: 29px; height: 45px; font-size: 29px; }
+ .image-gallery-header, .image-gallery-footer { padding: 10px; }
+ .image-gallery-dialog { width: calc(100vw - 12px); }
+}
diff --git a/src/viewer/images/image-gallery.js b/src/viewer/images/image-gallery.js
new file mode 100644
index 0000000..a3e8280
--- /dev/null
+++ b/src/viewer/images/image-gallery.js
@@ -0,0 +1,335 @@
+import { pdfDocumentSessionReady } from "../pdf-document-session.js";
+import { imageCropPixels, imageRegionsFromCoordinates } from "./image-gallery-utils.js";
+
+const menu = document.querySelector("#tools-menu");
+const menuToggle = document.querySelector("#tools-button");
+if (menu && menuToggle) {
+ installImageGallery(menu, menuToggle);
+}
+
+function installImageGallery(toolsMenu, toolsButton) {
+ const openButton = document.createElement("button");
+ openButton.id = "open-image-gallery";
+ openButton.type = "button";
+ openButton.className = "tool-button";
+ openButton.setAttribute("role", "menuitem");
+ openButton.textContent = "▧ Browse document images";
+ toolsMenu.insertBefore(openButton, toolsMenu.querySelector("#qr-file-url"));
+
+ const dialog = document.createElement("dialog");
+ dialog.className = "image-gallery-dialog";
+ dialog.setAttribute("aria-labelledby", "image-gallery-title");
+ dialog.innerHTML = [
+ '',
+ '',
+ '',
+ '
',
+ '
![]()
Finding images in this document…
',
+ '
',
+ '
',
+ '',
+ '',
+ '',
+ ].join("");
+ document.body.append(dialog);
+
+ const status = dialog.querySelector(".image-gallery-status");
+ const picture = dialog.querySelector(".image-gallery-picture img");
+ const empty = dialog.querySelector(".image-gallery-empty");
+ const previous = dialog.querySelector(".image-gallery-previous");
+ const next = dialog.querySelector(".image-gallery-next");
+ const caption = dialog.querySelector(".image-gallery-caption");
+ const jump = dialog.querySelector(".image-gallery-jump");
+ const thumbnails = dialog.querySelector(".image-gallery-thumbnails");
+ const close = dialog.querySelector(".image-gallery-close");
+
+ const entries = [];
+ let pdfDocument = null;
+ let nextPageToScan = 1;
+ let scannedPages = 0;
+ let failedPages = 0;
+ let scanJob = null;
+ let scanController = null;
+ let scanTask = null;
+ let selectionTask = null;
+ let selectionVersion = 0;
+ let selectedIndex = -1;
+ let selectedUrl = null;
+
+ function updateScanStatus() {
+ if (!pdfDocument) return;
+ const finished = nextPageToScan > pdfDocument.numPages;
+ status.textContent = finished
+ ? entries.length + " images on " + pdfDocument.numPages + " pages" +
+ (failedPages ? " (" + failedPages + " pages could not be scanned)" : "")
+ : "Scanning page " + Math.min(nextPageToScan, pdfDocument.numPages) +
+ " of " + pdfDocument.numPages + " · " + entries.length + " images found";
+ if (selectedIndex === -1) {
+ empty.textContent = finished
+ ? "No embedded images found. Vector drawings and scanned page backgrounds are not included."
+ : "Searching for embedded images…";
+ }
+ }
+
+ function select(index) {
+ if (index < 0 || index >= entries.length || !dialog.open) return;
+ selectedIndex = index;
+ const entry = entries[index];
+ caption.textContent = "Image " + (index + 1) + " of " + entries.length + " · PDF page " + entry.pageNumber;
+ previous.disabled = index === 0;
+ next.disabled = index === entries.length - 1;
+ jump.disabled = false;
+ for (const [itemIndex, item] of entries.entries()) {
+ item.button.setAttribute("aria-pressed", String(itemIndex === index));
+ }
+ entry.button.scrollIntoView({ block: "nearest", inline: "nearest" });
+ selectionVersion += 1;
+ const version = selectionVersion;
+ selectionTask?.cancel();
+ selectionTask = null;
+ picture.hidden = true;
+ empty.hidden = false;
+ empty.textContent = "Rendering image…";
+ void renderSelection(entry, version);
+ }
+
+ async function renderSelection(entry, version) {
+ let page;
+ let canvas;
+ let url;
+ try {
+ page = await pdfDocument.getPage(entry.pageNumber);
+ if (version !== selectionVersion || !dialog.open) return;
+ const base = page.getViewport({ scale: 1, rotation: 0 });
+ const maximumScale = Math.min(
+ 4,
+ Math.sqrt(16_000_000 / Math.max(1, base.width * base.height)),
+ 8192 / Math.max(base.width, base.height),
+ );
+ const scale = Math.max(0.05, Math.min(maximumScale, 1200 / Math.max(1, base.width * entry.region.width)));
+ const viewport = page.getViewport({ scale, rotation: 0 });
+ canvas = document.createElement("canvas");
+ canvas.width = Math.max(1, Math.floor(viewport.width));
+ canvas.height = Math.max(1, Math.floor(viewport.height));
+ const task = page.render({
+ canvasContext: canvas.getContext("2d", { alpha: false }),
+ viewport,
+ });
+ selectionTask = task;
+ await task.promise;
+ if (selectionTask === task) selectionTask = null;
+ if (version !== selectionVersion || !dialog.open) return;
+ url = await encodeCrop(canvas, entry.region, 1800, 1800);
+ if (version !== selectionVersion || !dialog.open) return;
+ if (selectedUrl) URL.revokeObjectURL(selectedUrl);
+ selectedUrl = url;
+ url = null;
+ picture.src = selectedUrl;
+ picture.alt = "Embedded image " + (selectedIndex + 1) + ", PDF page " + entry.pageNumber;
+ picture.hidden = false;
+ empty.hidden = true;
+ } catch (error) {
+ if (version === selectionVersion && dialog.open) {
+ picture.src = entry.thumbnailUrl;
+ picture.alt = "Image preview from PDF page " + entry.pageNumber;
+ picture.hidden = false;
+ empty.hidden = true;
+ }
+ } finally {
+ if (url) URL.revokeObjectURL(url);
+ if (canvas) { canvas.width = 0; canvas.height = 0; }
+ // PDF.js defers cleanup while other rendering tasks still use the page.
+ page?.cleanup();
+ }
+ }
+
+ async function encodeCrop(source, region, limitWidth, limitHeight) {
+ const crop = imageCropPixels(region, source.width, source.height);
+ const ratio = Math.min(1, limitWidth / crop.width, limitHeight / crop.height);
+ const output = document.createElement("canvas");
+ output.width = Math.max(1, Math.round(crop.width * ratio));
+ output.height = Math.max(1, Math.round(crop.height * ratio));
+ output.getContext("2d", { alpha: false }).drawImage(
+ source, crop.x, crop.y, crop.width, crop.height,
+ 0, 0, output.width, output.height,
+ );
+ try {
+ const blob = await new Promise((resolve, reject) => {
+ output.toBlob((value) => value ? resolve(value) : reject(new Error("Image encoding failed")), "image/webp", 0.9);
+ });
+ return URL.createObjectURL(blob);
+ } finally {
+ output.width = 0;
+ output.height = 0;
+ }
+ }
+
+ function addImage(pageNumber, region, thumbnailUrl) {
+ const entry = { pageNumber, region, thumbnailUrl };
+ const index = entries.length;
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = "image-gallery-thumb";
+ button.setAttribute("aria-label", "Image " + (index + 1) + " on PDF page " + pageNumber);
+ button.setAttribute("aria-pressed", "false");
+ const img = document.createElement("img");
+ img.src = thumbnailUrl;
+ img.alt = "";
+ img.loading = "lazy";
+ const label = document.createElement("span");
+ label.textContent = "p. " + pageNumber;
+ button.append(img, label);
+ button.addEventListener("click", () => select(index));
+ entry.button = button;
+ entries.push(entry);
+ thumbnails.append(button);
+ if (selectedIndex === -1) {
+ select(0);
+ } else {
+ caption.textContent = "Image " + (selectedIndex + 1) + " of " + entries.length +
+ " · PDF page " + entries[selectedIndex].pageNumber;
+ next.disabled = selectedIndex === entries.length - 1;
+ }
+ }
+
+ async function scanPage(pageNumber, signal) {
+ let page;
+ let canvas;
+ const prepared = [];
+ try {
+ page = await pdfDocument.getPage(pageNumber);
+ if (signal.aborted) return false;
+ const base = page.getViewport({ scale: 1, rotation: 0 });
+ const scale = Math.min(1, 650 / base.width, 900 / base.height);
+ const viewport = page.getViewport({ scale, rotation: 0 });
+ canvas = document.createElement("canvas");
+ canvas.width = Math.max(1, Math.floor(viewport.width));
+ canvas.height = Math.max(1, Math.floor(viewport.height));
+ page.imageCoordinates = null;
+ const task = page.render({
+ canvasContext: canvas.getContext("2d", { alpha: false }),
+ viewport,
+ recordImages: true,
+ });
+ scanTask = task;
+ await task.promise;
+ if (scanTask === task) scanTask = null;
+ if (signal.aborted) return false;
+ const regions = imageRegionsFromCoordinates(page.imageCoordinates, base.width, base.height);
+ for (const region of regions) {
+ if (signal.aborted) return false;
+ const thumbnailUrl = await encodeCrop(canvas, region, 150, 110);
+ prepared.push({ region, thumbnailUrl });
+ }
+ if (signal.aborted) return false;
+ for (const { region, thumbnailUrl } of prepared) addImage(pageNumber, region, thumbnailUrl);
+ return true;
+ } finally {
+ if (signal.aborted) {
+ for (const item of prepared) URL.revokeObjectURL(item.thumbnailUrl);
+ }
+ if (scanTask && signal.aborted) {
+ scanTask.cancel();
+ scanTask = null;
+ }
+ if (canvas) { canvas.width = 0; canvas.height = 0; }
+ page?.cleanup();
+ }
+ }
+
+ async function runScan(controller) {
+ while (dialog.open && !controller.signal.aborted && nextPageToScan <= pdfDocument.numPages) {
+ const pageNumber = nextPageToScan;
+ try {
+ if (!(await scanPage(pageNumber, controller.signal))) break;
+ } catch (error) {
+ if (controller.signal.aborted) break;
+ failedPages += 1; // One damaged PDF page must not block later images.
+ }
+ if (controller.signal.aborted) break;
+ nextPageToScan = pageNumber + 1;
+ scannedPages += 1;
+ updateScanStatus();
+ await new Promise((resolve) => setTimeout(resolve, 16)); // Keep the reader responsive.
+ }
+ updateScanStatus();
+ }
+
+ function startScan() {
+ if (!dialog.open || !pdfDocument || scanJob || nextPageToScan > pdfDocument.numPages) return;
+ const controller = new AbortController();
+ scanController = controller;
+ scanJob = runScan(controller).finally(() => {
+ if (scanController === controller) scanController = null;
+ scanJob = null;
+ if (dialog.open && nextPageToScan <= pdfDocument.numPages) startScan();
+ });
+ }
+
+ function goToSourcePage() {
+ const entry = entries[selectedIndex];
+ if (!entry) return;
+ dialog.close();
+ const pageInput = document.querySelector("#page-number");
+ if (pageInput) {
+ pageInput.value = String(entry.pageNumber);
+ pageInput.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ }
+
+ openButton.addEventListener("click", async () => {
+ toolsMenu.hidden = true;
+ toolsButton.setAttribute("aria-expanded", "false");
+ dialog.showModal();
+ close.focus();
+ if (!pdfDocument) {
+ const session = await pdfDocumentSessionReady;
+ if (!session?.document) {
+ empty.textContent = "This PDF is not available.";
+ return;
+ }
+ pdfDocument = session.document;
+ }
+ if (!dialog.open) return;
+ if (selectedIndex >= 0) select(selectedIndex);
+ updateScanStatus();
+ startScan();
+ });
+ close.addEventListener("click", () => dialog.close());
+ previous.addEventListener("click", () => select(selectedIndex - 1));
+ next.addEventListener("click", () => select(selectedIndex + 1));
+ jump.addEventListener("click", goToSourcePage);
+ dialog.addEventListener("keydown", (event) => {
+ if (event.altKey || event.ctrlKey || event.metaKey) return;
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
+ event.preventDefault();
+ select(selectedIndex + (event.key === "ArrowRight" ? 1 : -1));
+ }
+ });
+ dialog.addEventListener("close", () => {
+ scanController?.abort();
+ scanTask?.cancel();
+ selectionVersion += 1;
+ selectionTask?.cancel();
+ selectionTask = null;
+ if (selectedUrl) URL.revokeObjectURL(selectedUrl);
+ selectedUrl = null;
+ picture.removeAttribute("src");
+ picture.hidden = true;
+ toolsButton.focus();
+ });
+ window.addEventListener("pagehide", () => {
+ if (dialog.open) dialog.close();
+ for (const entry of entries) URL.revokeObjectURL(entry.thumbnailUrl);
+ entries.length = 0;
+ thumbnails.replaceChildren();
+ selectedIndex = -1;
+ nextPageToScan = 1;
+ });
+}
diff --git a/tests/image-gallery.test.mjs b/tests/image-gallery.test.mjs
new file mode 100644
index 0000000..59450ee
--- /dev/null
+++ b/tests/image-gallery.test.mjs
@@ -0,0 +1,42 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { imageCropPixels, imageRegionsFromCoordinates } from "../src/viewer/images/image-gallery-utils.js";
+
+test("finds individual embedded image bounds in document order", () => {
+ const coords = new Float32Array([
+ 0.2, 0.1, 0.2, 0.4, 0.7, 0.1,
+ 0.1, 0.6, 0.1, 0.8, 0.4, 0.6,
+ ]);
+ const regions = imageRegionsFromCoordinates(coords, 600, 800);
+ assert.equal(regions.length, 2);
+ assert.ok(Math.abs(regions[0].x - 0.2) < 1e-5);
+ assert.ok(Math.abs(regions[0].width - 0.5) < 1e-5);
+ assert.ok(Math.abs(regions[0].height - 0.3) < 1e-5);
+ assert.ok(Math.abs(regions[1].y - 0.6) < 1e-5);
+});
+
+test("skips full-page scan backgrounds and tiny decorative icons", () => {
+ const coords = [
+ 0, 0, 0, 1, 1, 0, // scanned full page
+ 0.1, 0.1, 0.1, 0.12, 0.12, 0.1, // tiny icon
+ 0.2, 0.2, 0.2, 0.5, 0.7, 0.2, // useful image
+ ];
+ assert.equal(imageRegionsFromCoordinates(coords, 600, 800).length, 1);
+});
+
+test("clamps rotated and out-of-page image corners", () => {
+ const regions = imageRegionsFromCoordinates([
+ 0.3, -0.2, 0.8, 0.3, 0.1, 0.7,
+ ], 600, 800);
+ assert.equal(regions.length, 1);
+ assert.equal(regions[0].x, 0.1);
+ assert.equal(regions[0].y, 0);
+ assert.ok(regions[0].height > 0);
+});
+
+test("pixel crops stay inside render canvas and include fractional edges", () => {
+ assert.deepEqual(imageCropPixels({ x: 0.2, y: 0.1, width: 0.5, height: 0.3 }, 101, 201),
+ { x: 20, y: 20, width: 51, height: 61 });
+ const clipped = imageCropPixels({ x: 0.9, y: 0.9, width: 0.2, height: 0.2 }, 100, 100);
+ assert.deepEqual(clipped, { x: 90, y: 90, width: 10, height: 10 });
+});