From 0a92635f6ba7d211af0f7e12d07095e09d9778fe Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:50:01 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=96=8C=EF=B8=8F=20Add=20embedded-imag?= =?UTF-8?q?e=20gallery=20to=20PDF=20viewer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scan embedded images on demand, browse with thumbnails and keyboard navigation, and jump to each image's source page. Exclude page-sized scan backgrounds and retain natural image colors. Co-authored-by: Codex Web --- src/viewer.html | 2 + src/viewer/images/image-gallery-utils.js | 38 +++ src/viewer/images/image-gallery.css | 99 +++++++ src/viewer/images/image-gallery.js | 335 +++++++++++++++++++++++ tests/image-gallery.test.mjs | 42 +++ 5 files changed, 516 insertions(+) create mode 100644 src/viewer/images/image-gallery-utils.js create mode 100644 src/viewer/images/image-gallery.css create mode 100644 src/viewer/images/image-gallery.js create mode 100644 tests/image-gallery.test.mjs 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 = [ + '', + ].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 }); +}); From f611afbeb1401db37dfa826da130b0740ef2c260 Mon Sep 17 00:00:00 2001 From: John Wooten <60260750+JFWooten4@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:51:44 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A7=AA=20Run=20image-gallery=20geomet?= =?UTF-8?q?ry=20tests=20in=20the=20CI=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include the new image gallery unit tests in npm test, which runs as part of the build workflow. Co-authored-by: Codex Web --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",