From 5b991730433e4d101dee44e748425a4db8f13e5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 23:19:08 +0000 Subject: [PATCH 1/8] Bump actions/create-github-app-token from 3.1.1 to 3.2.0 Bumps [actions/create-github-app-token](https://github.com/actions/create-github-app-token) from 3.1.1 to 3.2.0. - [Release notes](https://github.com/actions/create-github-app-token/releases) - [Changelog](https://github.com/actions/create-github-app-token/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/create-github-app-token/compare/1b10c78c7865c340bc4f6099eb2f838309f1e8c3...bcd2ba49218906704ab6c1aa796996da409d3eb1) --- updated-dependencies: - dependency-name: actions/create-github-app-token dependency-version: 3.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/notify-pdf-sync.yml | 2 +- .github/workflows/update_locales.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/notify-pdf-sync.yml b/.github/workflows/notify-pdf-sync.yml index a614523f0edc4..741e22f332a11 100644 --- a/.github/workflows/notify-pdf-sync.yml +++ b/.github/workflows/notify-pdf-sync.yml @@ -46,7 +46,7 @@ jobs: - name: Generate app token if: steps.check.outputs.has_added == 'true' id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ secrets.CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} diff --git a/.github/workflows/update_locales.yml b/.github/workflows/update_locales.yml index ca50ec6fc6460..a7bc621576e2f 100644 --- a/.github/workflows/update_locales.yml +++ b/.github/workflows/update_locales.yml @@ -17,7 +17,7 @@ jobs: steps: - name: Generate app token id: app-token - uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: client-id: ${{ secrets.CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} From 16a9f1cafc6268e65014393c05d9504cbf4e532e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 23:20:52 +0000 Subject: [PATCH 2/8] Bump github/codeql-action from 4.35.3 to 4.35.4 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.3 to 4.35.4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/e46ed2cbd01164d986452f91f178727624ae40d7...68bde559dea0fdcac2102bfdf6230c5f70eb485e) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.35.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 245c34a15dd09..58726ba85268d 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,13 +24,13 @@ jobs: persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 with: languages: ${{ matrix.language }} queries: security-and-quality - name: Autobuild CodeQL - uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3 + uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4 From abe8b564a346070b7fc921db0d1335fdb7ff2e1d Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Wed, 20 May 2026 16:39:21 +0200 Subject: [PATCH 3/8] Don't use "internal" `EventBus` methods in the integration-tests This way *guarantees* that any and all internal viewer state has been updated first, before any test-specific code runs. --- test/integration/reorganize_pages_spec.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs index 6b05f9f3ea032..9343ed3f802d5 100644 --- a/test/integration/reorganize_pages_spec.mjs +++ b/test/integration/reorganize_pages_spec.mjs @@ -3066,7 +3066,7 @@ describe("Reorganize Pages View", () => { await waitAndClick(page, getThumbnailSelector(2)); const handleMerged = await createPromise(page, resolve => { - window.PDFViewerApplication.eventBus._on( + window.PDFViewerApplication.eventBus.on( "thumbnailsloaded", resolve, { once: true } @@ -3125,7 +3125,7 @@ describe("Reorganize Pages View", () => { await waitAndClick(page, getThumbnailSelector(1)); const handleMerged = await createPromise(page, resolve => { - window.PDFViewerApplication.eventBus._on( + window.PDFViewerApplication.eventBus.on( "thumbnailsloaded", resolve, { once: true } @@ -3161,7 +3161,7 @@ describe("Reorganize Pages View", () => { await waitForThumbnailVisible(page, 1); const handleMerged = await createPromise(page, resolve => { - window.PDFViewerApplication.eventBus._on( + window.PDFViewerApplication.eventBus.on( "thumbnailsloaded", resolve, { once: true } @@ -3197,7 +3197,7 @@ describe("Reorganize Pages View", () => { await waitForTextToBe(page, labelSelector, `${FSI}1${PDI} selected`); const handleMerged = await createPromise(page, resolve => { - window.PDFViewerApplication.eventBus._on( + window.PDFViewerApplication.eventBus.on( "thumbnailsloaded", resolve, { once: true } From 429b469ecbfad83027f6a9a816ea3a4fc03869f5 Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Wed, 20 May 2026 14:45:29 +0200 Subject: [PATCH 4/8] Add basic integration-tests for the `PdfTextExtractor` class --- test/integration/jasmine-boot.js | 1 + test/integration/text_extractor_spec.mjs | 119 +++++++++++++++++++++++ web/genericcom.js | 13 +++ 3 files changed, 133 insertions(+) create mode 100644 test/integration/text_extractor_spec.mjs diff --git a/test/integration/jasmine-boot.js b/test/integration/jasmine-boot.js index 4ed75f5612199..f61cab215be1e 100644 --- a/test/integration/jasmine-boot.js +++ b/test/integration/jasmine-boot.js @@ -44,6 +44,7 @@ async function runTests(results) { "signature_editor_spec.mjs", "simple_viewer_spec.mjs", "stamp_editor_spec.mjs", + "text_extractor_spec.mjs", "text_field_spec.mjs", "text_layer_spec.mjs", "text_layer_images_spec.mjs", diff --git a/test/integration/text_extractor_spec.mjs b/test/integration/text_extractor_spec.mjs new file mode 100644 index 0000000000000..c665217b87dee --- /dev/null +++ b/test/integration/text_extractor_spec.mjs @@ -0,0 +1,119 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { closePages, loadAndWait } from "./test_utils.mjs"; + +async function dispatchRequestTextContent(page, id) { + return page.evaluate(requestId => { + const event = new CustomEvent("requestTextContent", { + bubbles: true, + cancelable: true, + detail: { requestId }, + }); + window.dispatchEvent(event); + }, id); +} + +async function getReportTextData(page) { + await page.waitForFunction(() => window._reportTextData !== undefined); + return page.evaluate(() => { + const data = window._reportTextData; + delete window._reportTextData; + return data; + }); +} + +describe("PdfTextExtractor", () => { + describe("Simple multi-page document", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait("basicapi.pdf", ".textLayer .endOfContent"); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("check that all text is extracted", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await dispatchRequestTextContent(page, 1); + + const { text, requestId } = await getReportTextData(page); + + expect(text).toEqual( + [ + "Table Of Content", + "Chapter 1 .......................................................... 2", + "Paragraph 1.1 ...................................................... 3", + "page 1 / 3", + "Chapter 1", + "page 2 / 3", + "Paragraph 1.1", + "Powered by TCPDF (www.tcpdf.org)", + "page 3 / 3", + ].join("\n") + ); + expect(requestId).toEqual(1); + }) + ); + }); + }); + + describe("Multi-page document, with disableAutoFetch=true set", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "tracemonkey.pdf", + ".textLayer .endOfContent", + null, + null, + { + disableAutoFetch: true, + disableStream: true, + } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("check that all text is extracted", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await dispatchRequestTextContent(page, 2); + + const { text, requestId } = await getReportTextData(page); + + expect( + text.startsWith( + "Trace-based Just-in-Time Type Specialization for Dynamic\nLanguages" + ) + ).toBeTrue(); + expect( + text.endsWith( + "Conference on Virtual Execution Environments, pages 83–93. ACM\nPress, 2007." + ) + ).toBeTrue(); + expect(text.length).toEqual(82804); + expect(requestId).toEqual(2); + }) + ); + }); + }); +}); diff --git a/web/genericcom.js b/web/genericcom.js index 77909b4874e81..c0f7e8877213b 100644 --- a/web/genericcom.js +++ b/web/genericcom.js @@ -39,6 +39,19 @@ class Preferences extends BasePreferences { } class ExternalServices extends BaseExternalServices { + constructor() { + super(); + + if (typeof PDFJSDev === "undefined" || PDFJSDev.test("TESTING")) { + // For testing purposes. + Object.defineProperty(this, "reportText", { + value: data => { + window._reportTextData = data; + }, + }); + } + } + async createL10n() { return new GenericL10n(AppOptions.get("localeProperties")?.lang); } From d79043b3afdd239f3cd8eaed2f8e5c13f33cec59 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 20 May 2026 14:28:54 +0200 Subject: [PATCH 5/8] Allow merging a PDF by dropping it onto the thumbnail viewer Drop an external PDF anywhere in the views-manager thumbnail sidebar to merge it at the cursor, rather than always inserting after the current page via the "Add file" button. The drop reuses the blue separator from page-move drag so the user can see exactly where the inserted pages will land, and the merge path is shared with the existing picker so post-merge selection/current-page behavior stays consistent. --- test/integration/reorganize_pages_spec.mjs | 149 ++++++++++++ web/pdf_thumbnail_viewer.js | 268 ++++++++++++++++----- web/views_manager.css | 15 +- 3 files changed, 365 insertions(+), 67 deletions(-) diff --git a/test/integration/reorganize_pages_spec.mjs b/test/integration/reorganize_pages_spec.mjs index 6b05f9f3ea032..929f0871173dd 100644 --- a/test/integration/reorganize_pages_spec.mjs +++ b/test/integration/reorganize_pages_spec.mjs @@ -40,10 +40,27 @@ import { waitForTextToBe, waitForTooltipToBe, } from "./test_utils.mjs"; +import fs from "fs"; import path from "path"; const __dirname = import.meta.dirname; +async function createPDFDataTransfer(page, filename) { + const pdfPath = path.join(__dirname, "../pdfs", filename); + const pdfData = fs.readFileSync(pdfPath).toString("base64"); + return page.evaluateHandle( + (data, name) => { + const transfer = new DataTransfer(); + const view = Uint8Array.fromBase64(data); + const file = new File([view], name, { type: "application/pdf" }); + transfer.items.add(file); + return transfer; + }, + pdfData, + filename + ); +} + async function waitForThumbnailVisible(page, pageNums) { await showViewsManager(page); @@ -3227,4 +3244,136 @@ describe("Reorganize Pages View", () => { ); }); }); + + describe("Drag-and-drop PDF merge", () => { + let pages; + + beforeEach(async () => { + pages = await loadAndWait( + "three_pages_with_number.pdf", + '.page[data-page-number = "1"] .endOfContent', + "1", + null, + { enableSplitMerge: true, enableMerge: true } + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("should show the marker and merge before the first thumbnail", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForThumbnailVisible(page, [1, 2, 3]); + + const dataTransfer = await createPDFDataTransfer( + page, + "three_pages_with_number.pdf" + ); + const markerInfo = await page.evaluate( + (transfer, selector) => { + const container = document.getElementById("thumbnailsView"); + const target = document.querySelector(selector); + const { left, top, width, height } = + target.getBoundingClientRect(); + const clientX = left + width / 4; + const clientY = top + height / 4; + const dispatchDragEvent = type => { + target.dispatchEvent( + new DragEvent(type, { + bubbles: true, + cancelable: true, + clientX, + clientY, + dataTransfer: transfer, + }) + ); + }; + + dispatchDragEvent("dragenter"); + dispatchDragEvent("dragover"); + + const marker = container.querySelector(":scope > .dragMarker"); + const { width: markerWidth = 0, height: markerHeight = 0 } = + marker?.getBoundingClientRect() ?? {}; + const translate = marker?.style.translate ?? ""; + const filesLength = transfer.files.length; + + dispatchDragEvent("dragleave"); + const survivedDragLeave = !!container.querySelector( + ":scope > .dragMarker" + ); + + return { + markerHeight, + markerWidth, + filesLength, + survivedDragLeave, + translate, + }; + }, + dataTransfer, + getThumbnailSelector(1) + ); + + expect(markerInfo.markerWidth + markerInfo.markerHeight) + .withContext(`In ${browserName}, marker dimensions`) + .toBeGreaterThan(0); + expect(markerInfo.filesLength) + .withContext(`In ${browserName}, dropped files`) + .toBe(1); + expect(markerInfo.translate.includes("NaN")) + .withContext(`In ${browserName}, marker position`) + .toBeFalse(); + expect(markerInfo.survivedDragLeave) + .withContext(`In ${browserName}, marker after child dragleave`) + .toBeTrue(); + + const handleMerged = await createPromise(page, resolve => { + const listener = ({ pagesCount }) => { + if (pagesCount !== 6) { + return; + } + window.PDFViewerApplication.eventBus.off("pagesloaded", listener); + resolve(); + }; + window.PDFViewerApplication.eventBus.on("pagesloaded", listener); + }); + await page.evaluate( + (transfer, selector) => { + const target = document.querySelector(selector); + const { left, top, width, height } = + target.getBoundingClientRect(); + target.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + clientX: left + width / 4, + clientY: top + height / 4, + dataTransfer: transfer, + }) + ); + }, + dataTransfer, + getThumbnailSelector(1) + ); + await awaitPromise(handleMerged); + + await page.waitForFunction( + () => parseInt(document.getElementById("pageNumber").max, 10) === 6 + ); + await page.waitForFunction( + () => window.PDFViewerApplication.page === 1 + ); + await waitForHavingContents(page, [1, 2, 3, 1, 2, 3]); + await waitForTextToBe( + page, + "#viewsManagerStatusActionLabel", + `${FSI}3${PDI} selected` + ); + }) + ); + }); + }); }); diff --git a/web/pdf_thumbnail_viewer.js b/web/pdf_thumbnail_viewer.js index 70b46dbe45a43..443c199be64e9 100644 --- a/web/pdf_thumbnail_viewer.js +++ b/web/pdf_thumbnail_viewer.js @@ -94,6 +94,10 @@ class PDFThumbnailViewer { #dragAC = null; + #abortSignal = undefined; + + #externalDragActive = false; + #draggedContainer = null; #thumbnailsPositions = null; @@ -201,6 +205,7 @@ class PDFThumbnailViewer { this.maxCanvasPixels = maxCanvasPixels; this.maxCanvasDim = maxCanvasDim; this.pageColors = pageColors || null; + this.#abortSignal = abortSignal; this.#enableMerge = enableMerge || false; this.#enableSplitMerge = enableSplitMerge || false; this.#statusLabel = statusBar?.viewsManagerStatusActionLabel || null; @@ -315,62 +320,11 @@ class PDFThumbnailViewer { if (this.#enableMerge && addFileComponent) { const { picker, button } = addFileComponent; - picker.addEventListener("change", async () => { + picker.addEventListener("change", () => { const file = picker.files?.[0]; - if (!file) { - return; + if (file) { + this.#mergeFile(file, this._currentPageNumber - 1); } - if (file.type !== "application/pdf") { - const magic = await file.slice(0, 5).text(); - if (magic !== "%PDF-") { - return; - } - } - this.#toggleBar("waiting", "pdfjs-views-manager-waiting-for-file"); - const currentPageIndex = this._currentPageNumber - 1; - const buffer = await file.bytes(); - const pagesCount = this.#pagesMapper.pagesNumber; - const data = this.hasStructuralChanges() - ? this.getStructuralChanges() - : [{ document: null }]; - data.push({ - document: buffer, - insertAfter: currentPageIndex ?? -1, - }); - this.eventBus._on( - "pagesloaded", - () => { - // Clear any pre-merge selection: thumbnails are rebuilt fresh - // (all unchecked), so the old set would cause a label/visual - // mismatch. - this.#selectedPages = null; - this.#updateMenuEntries(); - this.#toggleBar("status"); - const newPagesCount = this.#pagesMapper.pagesNumber; - const insertedPagesCount = newPagesCount - pagesCount; - for ( - let i = currentPageIndex + 1, - ii = currentPageIndex + 1 + insertedPagesCount; - i < ii; - i++ - ) { - this._thumbnails[i].checkbox.checked = true; - this.#selectPage(i + 1, true); - } - if (insertedPagesCount) { - this.#updateCurrentPage( - currentPageIndex + 2, - /* force = */ true - ); - } - }, - { once: true } - ); - this.#reportTelemetry({ action: "merge" }); - this.eventBus.dispatch("saveandload", { - source: this, - data, - }); }); button.addEventListener("click", () => { picker.click(); @@ -397,6 +351,55 @@ class PDFThumbnailViewer { this.renderingQueue.renderHighestPriority(); } + async #mergeFile(file, insertAfter) { + if (file.type !== "application/pdf") { + const magic = await file.slice(0, 5).text(); + if (magic !== "%PDF-") { + return; + } + } + this.#toggleBar("waiting", "pdfjs-views-manager-waiting-for-file"); + const buffer = await file.bytes(); + const pagesCount = this.#pagesMapper.pagesNumber; + const data = this.hasStructuralChanges() + ? this.getStructuralChanges() + : [{ document: null }]; + data.push({ + document: buffer, + insertAfter, + }); + this.eventBus._on( + "pagesloaded", + () => { + // Clear any pre-merge selection: thumbnails are rebuilt fresh + // (all unchecked), so the old set would cause a label/visual + // mismatch. + this.#selectedPages = null; + this.#updateMenuEntries(); + this.#toggleBar("status"); + const newPagesCount = this.#pagesMapper.pagesNumber; + const insertedPagesCount = newPagesCount - pagesCount; + for ( + let i = insertAfter + 1, ii = insertAfter + 1 + insertedPagesCount; + i < ii; + i++ + ) { + this._thumbnails[i].checkbox.checked = true; + this.#selectPage(i + 1, true); + } + if (insertedPagesCount) { + this.#updateCurrentPage(insertAfter + 2, /* force = */ true); + } + }, + { once: true } + ); + this.#reportTelemetry({ action: "merge" }); + this.eventBus.dispatch("saveandload", { + source: this, + data, + }); + } + getThumbnail(index) { return this._thumbnails[index]; } @@ -1185,6 +1188,10 @@ class PDFThumbnailViewer { this.#draggedImageX + this.#draggedImageWidth / 2, this.#draggedImageY + this.#draggedImageHeight / 2 ); + this.#positionDragMarker(positionData); + } + + #positionDragMarker(positionData) { if (!positionData) { return; } @@ -1202,7 +1209,7 @@ class PDFThumbnailViewer { if (index < 0) { if (xPos.length === 1) { y = bbox[1] - SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT; - x = bbox[4]; + x = bbox[0]; width = bbox[2]; } else { y = bbox[1]; @@ -1272,16 +1279,22 @@ class PDFThumbnailViewer { lastRightX ??= cx + w; } } - const space = - positionsX.length > 1 - ? (positionsX[1] - firstRightX) / 2 - : (positionsY[1] - firstBottomY) / 2; + let space; + if (positionsX.length > 1) { + space = (positionsX[1] - firstRightX) / 2; + } else if (positionsY.length > 1) { + space = (positionsY[1] - firstBottomY) / 2; + } else { + space = SPACE_FOR_DRAG_MARKER_WHEN_NO_NEXT_ELEMENT; + } this.#thumbnailsPositions = { x: positionsX, y: positionsY, lastX: positionsLastX, space, - lastSpace: (positionsLastX.at(-1) - lastRightX) / 2, + lastSpace: positionsLastX.length + ? (positionsLastX.at(-1) - lastRightX) / 2 + : space, bbox, }; this.#isOneColumnView = positionsX.length === 1; @@ -1380,6 +1393,7 @@ class PDFThumbnailViewer { this.#goToPage(e); }); this.#addDragListeners(); + this.#addExternalFileDropListeners(); } #selectPage(pageNumber, checked) { @@ -1550,6 +1564,140 @@ class PDFThumbnailViewer { }); } + #addExternalFileDropListeners() { + if (!this.#enableMerge) { + return; + } + const container = this.container; + const signal = this.#abortSignal; + + const hasPdfItem = dataTransfer => { + if (!dataTransfer) { + return false; + } + // The file's bytes aren't readable during dragover, so the MIME type is + // the only available signal. Matches the existing global drop handler + // in app.js. Files with no MIME (e.g. some macOS sources) are rejected + // here to keep the "copy" cursor honest; if needed, drop-time magic-byte + // validation in #mergeFile would still catch a permissive variant. + for (const item of dataTransfer.items) { + if (item.kind === "file" && item.type === "application/pdf") { + return true; + } + } + return false; + }; + const pointerInContainer = ({ clientX, clientY }) => { + const { left, right, top, bottom } = container.getBoundingClientRect(); + return ( + clientX >= left && clientX < right && clientY >= top && clientY < bottom + ); + }; + + container.addEventListener( + "dragenter", + e => { + if ( + this.#externalDragActive || + // A page-move drag is already in progress. + !isNaN(this.#lastDraggedOverIndex) || + !this._thumbnails.length || + !hasPdfItem(e.dataTransfer) + ) { + return; + } + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + this.#externalDragActive = true; + this.container.classList.add("isDraggingFile"); + // Recompute positions in case the layout changed since last time. + this.#thumbnailsPositions = null; + this.#computeThumbnailsPosition(); + // Marker hasn't been positioned yet — first dragover will do it. + this.#lastDraggedOverIndex = NaN; + }, + { signal } + ); + + container.addEventListener( + "dragover", + e => { + if (!this.#externalDragActive) { + return; + } + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "copy"; + if (!this.#thumbnailsPositions) { + return; + } + const rect = container.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const positionData = this.#findClosestThumbnail(x, y); + this.#positionDragMarker(positionData); + }, + { signal } + ); + + container.addEventListener( + "dragleave", + e => { + if (!this.#externalDragActive) { + return; + } + // dragleave fires when crossing into a child element too; only treat + // it as a true leave when the cursor has actually left the container. + if ( + (e.relatedTarget && container.contains(e.relatedTarget)) || + pointerInContainer(e) + ) { + return; + } + this.#endExternalFileDrag(); + }, + { signal } + ); + + container.addEventListener( + "drop", + e => { + if (!this.#externalDragActive) { + return; + } + e.preventDefault(); + e.stopPropagation(); + const file = e.dataTransfer.files?.[0]; + // If no dragover ever ran (e.g. instant drop), compute the index from + // the drop event itself so we don't fall through to a stale fallback. + if (isNaN(this.#lastDraggedOverIndex) && this.#thumbnailsPositions) { + const rect = container.getBoundingClientRect(); + this.#findClosestThumbnail( + e.clientX - rect.left, + e.clientY - rect.top + ); + } + const insertAfter = isNaN(this.#lastDraggedOverIndex) + ? -1 + : this.#lastDraggedOverIndex; + this.#endExternalFileDrag(); + if (file) { + this.#mergeFile(file, insertAfter); + } + }, + { signal } + ); + } + + #endExternalFileDrag() { + this.#externalDragActive = false; + this.container.classList.remove("isDraggingFile"); + this.#dragMarker?.remove(); + this.#dragMarker = null; + this.#lastDraggedOverIndex = NaN; + } + #goToPage(e) { const container = e.target.closest(".thumbnailImageContainer"); if (container) { diff --git a/web/views_manager.css b/web/views_manager.css index 2f3627a2564f3..46b30fe8bb5c9 100644 --- a/web/views_manager.css +++ b/web/views_manager.css @@ -632,14 +632,15 @@ pointer-events: none; } } + } - > .dragMarker { - position: absolute; - top: 0; - left: 0; - border: 2px solid var(--indicator-color); - contain: strict; - } + &.isDragging > .dragMarker, + &.isDraggingFile > .dragMarker { + position: absolute; + top: 0; + left: 0; + border: 2px solid var(--indicator-color); + contain: strict; } &.pasteMode { From 74471651c79efceab7eb8b5aa0cfc2cc6085b531 Mon Sep 17 00:00:00 2001 From: Jonas Jenwald Date: Thu, 21 May 2026 14:03:32 +0200 Subject: [PATCH 6/8] Extend unit-test coverage for the `getPdfFilenameFromUrl` helper function Currently there's a couple of branches, specifically for dealing with corrupt URLs, that are not covered by tests. --- test/unit/display_utils_spec.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/unit/display_utils_spec.js b/test/unit/display_utils_spec.js index 0859107adfb0e..c1b591ec3ef51 100644 --- a/test/unit/display_utils_spec.js +++ b/test/unit/display_utils_spec.js @@ -151,6 +151,9 @@ describe("display_utils", function () { expect(getPdfFilenameFromUrl("/pdfs/%AA.pdf")).toEqual("%AA.pdf"); expect(getPdfFilenameFromUrl("/pdfs/%2F.pdf")).toEqual("%2F.pdf"); + + // A corrupt relative URL. + expect(getPdfFilenameFromUrl("//%%file.pdf")).toEqual("document.pdf"); }); it("gets PDF filename from (some) standard protocols", function () { From 0f909879275e8aacbe5c794aac1c0af2c9e0e1bb Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Wed, 20 May 2026 19:30:22 +0200 Subject: [PATCH 7/8] Fix 'Select all' after #20981 --- src/display/draw_layer.js | 111 ++++++++++++++++------ test/integration/text_layer_spec.mjs | 137 +++++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 31 deletions(-) diff --git a/src/display/draw_layer.js b/src/display/draw_layer.js index 1c1aa84117fef..5cca038927c49 100644 --- a/src/display/draw_layer.js +++ b/src/display/draw_layer.js @@ -203,6 +203,9 @@ class DrawLayer { /** @type {Object | null} */ #pageColors = null; + /** @type {MutationObserver | null} */ + #textLayerObserver = null; + #toUpdate = new Map(); static #id = 0; @@ -248,6 +251,28 @@ class DrawLayer { DrawLayer.#textLayers.set(textLayer, { drawLayer: this }); DrawLayer.#textLayerSet.add(textLayer); this.#textLayer = textLayer; + this.#textLayerObserver = new MutationObserver(records => { + if ( + !this.#parent || + !this.#textLayer?.isConnected || + !DrawLayer.#hasSelection() + ) { + return; + } + for (const { addedNodes } of records) { + for (const node of addedNodes) { + if ( + node.nodeType === Node.ELEMENT_NODE && + node.classList.contains("endOfContent") + ) { + DrawLayer.#selectionChange(); + return; + } + } + } + }); + this.#textLayerObserver.observe(textLayer, { childList: true }); + if (DrawLayer.#selectionChangeAC === null) { DrawLayer.#selectionChangeAC = new AbortController(); const { signal } = DrawLayer.#selectionChangeAC; @@ -288,6 +313,12 @@ class DrawLayer { setParent(parent) { if (!this.#parent) { this.#parent = parent; + // A new text layer just became live (e.g. its page was scrolled into + // view). If a selection already exists, redraw overlays so that the + // selection extends into this newly-rendered text layer. + if (this.#textLayer?.isConnected && DrawLayer.#hasSelection()) { + DrawLayer.#selectionChange(); + } return; } @@ -321,6 +352,25 @@ class DrawLayer { textLayerData.path = null; } + /** + * @returns {boolean} + * Whether there is a non-collapsed document selection. + */ + static #hasSelection() { + const selection = document.getSelection(); + return !!selection && !selection.isCollapsed; + } + + /** + * @returns {Array} + * Connected text layers sorted in document order. + */ + static #getOrderedTextLayers() { + return [...this.#textLayerSet] + .filter(textLayer => textLayer.isConnected) + .sort(compareTextLayers); + } + /** * Handle `selectionchange` to update the selection display for text layers. * We want to display the selection in a separate layer on top of the text @@ -341,6 +391,7 @@ class DrawLayer { } /** @type {WeakMap} */ const rotators = new WeakMap(); + const orderedTextLayers = this.#getOrderedTextLayers(); /** @type {Array<[Range, Element]>} */ const ranges = []; for (let i = 0, ii = selection.rangeCount; i < ii; i++) { @@ -390,10 +441,30 @@ class DrawLayer { } } - if (!startTextLayer || !endTextLayer) { - // Any remaining partial/outside range can be ignored. + const activeTextLayers = orderedTextLayers.filter(textLayer => + range.intersectsNode(textLayer) + ); + if (activeTextLayers.length === 0) { continue; } + + // If a boundary is outside any text layer, use the selected live text + // layers as the range edges. This handles Select All, whose DOM range can + // span ancestors of the text layers. + let boundarySubstituted = false; + if (!startTextLayer) { + startTextLayer = activeTextLayers[0]; + startContainer = startTextLayer; + startOffset = 0; + boundarySubstituted = true; + } + if (!endTextLayer) { + endTextLayer = activeTextLayers.at(-1); + endContainer = endTextLayer; + endOffset = endTextLayer.childNodes.length; + boundarySubstituted = true; + } + if (endContainer.nodeType === Node.ELEMENT_NODE) { if (endContainer.classList.contains("endOfContent")) { const previousNode = endContainer.previousSibling; @@ -435,39 +506,15 @@ class DrawLayer { startOffset = normalizedStart.offset; } - if (startTextLayer === endTextLayer) { + if ( + startTextLayer === endTextLayer && + !boundarySubstituted && + activeTextLayers.includes(startTextLayer) + ) { ranges.push([range, startTextLayer]); continue; } - /** @type {Array} */ - let activeTextLayers = []; - const orderedTextLayers = [...this.#textLayerSet].sort(compareTextLayers); - const startIndex = orderedTextLayers.indexOf(startTextLayer); - const endIndex = orderedTextLayers.indexOf(endTextLayer); - - if (startIndex !== -1 && endIndex !== -1) { - const [minIndex, maxIndex] = - startIndex < endIndex - ? [startIndex, endIndex] - : [endIndex, startIndex]; - activeTextLayers = orderedTextLayers.slice(minIndex, maxIndex + 1); - } else { - // Fallback if a layer is no longer tracked for any reason. - for (const textLayer of this.#textLayerSet) { - if (range.intersectsNode(textLayer)) { - activeTextLayers.push(textLayer); - } - } - if (!activeTextLayers.includes(startTextLayer)) { - activeTextLayers.push(startTextLayer); - } - if (!activeTextLayers.includes(endTextLayer)) { - activeTextLayers.push(endTextLayer); - } - activeTextLayers.sort(compareTextLayers); - } - for (const textLayer of activeTextLayers) { const firstNode = textLayer.firstChild; if (!firstNode) { @@ -793,6 +840,8 @@ class DrawLayer { } this.#mapping.clear(); this.#toUpdate.clear(); + this.#textLayerObserver?.disconnect(); + this.#textLayerObserver = null; if (this.#textLayer) { const data = DrawLayer.#textLayers.get(this.#textLayer); if (data?.drawLayer === this) { diff --git a/test/integration/text_layer_spec.mjs b/test/integration/text_layer_spec.mjs index 61cea04c962f7..102438691f3da 100644 --- a/test/integration/text_layer_spec.mjs +++ b/test/integration/text_layer_spec.mjs @@ -21,6 +21,7 @@ import { closePages, closeSinglePage, getSpanRectFromText, + kbSelectAll, loadAndWait, waitForEvent, } from "./test_utils.mjs"; @@ -1118,6 +1119,142 @@ describe("Text layer", () => { .toHaveRoughlySelected(/frequently .* We call such a s/s); }); }); + + describe("with select-all (Ctrl+A)", () => { + /** @type {Array<[string, Page]>} */ + let pages; + + /** + * Return the set of page numbers that have a non-empty selection + * overlay path in their draw layer. + * + * @param {Page} page + * @returns {Promise>} + */ + async function pagesWithDrawnSelection(page) { + return page.evaluate(() => { + const numbers = new Set(); + for (const path of document.querySelectorAll( + ".page .canvasWrapper .selection svg path" + )) { + if (path.getAttribute("d")?.trim()) { + const n = path.closest(".page")?.dataset.pageNumber; + if (n) { + numbers.add(Number(n)); + } + } + } + return [...numbers].sort((a, b) => a - b); + }); + } + + beforeEach(async () => { + pages = await loadAndWait( + "tracemonkey.pdf", + `.page[data-page-number = "1"] .endOfContent` + ); + }); + + afterEach(async () => { + await closePages(pages); + }); + + it("draws a selection overlay on currently-rendered pages", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + // Wait for at least two text layers to be rendered so the + // overlay can be expected on multiple pages. The number of + // pages rendered up-front at the default zoom can vary + // depending on the viewport size on CI. + await page.waitForFunction( + () => + document.querySelectorAll(".textLayer .endOfContent").length >= + 2 + ); + + await waitForEvent({ + page, + eventName: "selectionchange", + action: () => kbSelectAll(page), + }); + + expect(await hasDrawnSelection(page)) + .withContext(`In ${browserName}`) + .toBeTrue(); + + // Several text layers are rendered at the default zoom and + // each one should now carry a selection overlay. + const drawn = await pagesWithDrawnSelection(page); + expect(drawn.length) + .withContext( + `In ${browserName}, pages with selection overlay: ` + + `${drawn.join(",")}` + ) + .toBeGreaterThan(1); + expect(drawn[0]) + .withContext(`In ${browserName}, first selected page`) + .toBe(1); + }) + ); + }); + + it("extends the overlay onto pages rendered after scroll", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + await waitForEvent({ + page, + eventName: "selectionchange", + action: () => kbSelectAll(page), + }); + + const initial = await pagesWithDrawnSelection(page); + expect(initial.length) + .withContext(`In ${browserName}, initial pages with overlay`) + .toBeGreaterThan(0); + + // Pick the first page that hasn't been rendered with a + // selection overlay yet, scroll to it, and verify the overlay + // gets drawn on it once its draw layer is parented. + const lastInitial = initial.at(-1); + const targetPage = lastInitial + 1; + + await page.evaluate(n => { + const pageDiv = document.querySelector( + `.page[data-page-number="${n}"]` + ); + pageDiv.scrollIntoView({ block: "center" }); + }, targetPage); + + await page.waitForSelector( + `.page[data-page-number="${targetPage}"] .textLayer .endOfContent`, + { timeout: 0 } + ); + + // After the new page is rendered, its draw layer becomes + // "live" (`setParent` is called) and the selection overlay + // must be extended onto it without requiring a new + // `selectionchange` event. + await page.waitForFunction( + n => { + const path = document.querySelector( + `.page[data-page-number="${n}"] .canvasWrapper .selection svg path` + ); + return !!path?.getAttribute("d")?.trim(); + }, + { timeout: 0 }, + targetPage + ); + + const afterScroll = await pagesWithDrawnSelection(page); + expect(afterScroll) + .withContext( + `In ${browserName}, target page ${targetPage} has overlay` + ) + .toContain(targetPage); + }) + ); + }); + }); }); describe("when the browser enforces a minimum font size", () => { From d6a2b91243193cb1efc3cea9fa37ff67d5a5ec07 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Tue, 19 May 2026 21:57:24 +0200 Subject: [PATCH 8/8] Sanitize glyf composite cycles, OS/2 length and maxp version mismatches Prune the back-edge components from cyclic composite glyphs in sanitizeGlyphLocations (leaving non-cyclic siblings intact), reject OS/2 tables whose length is too short for the declared version so a clean table gets regenerated, and upgrade a version 0.5 maxp table to 1.0 for TrueType fonts to silence OTS' "wrong maxp version for glyph data". It fixes #21298. --- src/core/font_renderer.js | 3 + src/core/fonts.js | 43 ++++++++--- src/core/glyf.js | 122 ++++++++++++++++++++++++++++++- test/font/font_glyf_spec.js | 141 ++++++++++++++++++++++++++++++++++++ test/font/jasmine-boot.js | 1 + 5 files changed, 297 insertions(+), 13 deletions(-) create mode 100644 test/font/font_glyf_spec.js diff --git a/src/core/font_renderer.js b/src/core/font_renderer.js index e2bd0f911f1ac..0c0fcdd8ebcec 100644 --- a/src/core/font_renderer.js +++ b/src/core/font_renderer.js @@ -163,6 +163,9 @@ function lookupCmap(ranges, unicode) { } function compileGlyf(code, cmds, font, visitedGlyphs = new Set()) { + if (!code?.length) { + return; + } if (visitedGlyphs.has(code)) { warn("compileGlyf: skipping recursive composite glyph reference."); return; diff --git a/src/core/fonts.js b/src/core/fonts.js index 4f5932d318571..71c5f3eac60e3 100644 --- a/src/core/fonts.js +++ b/src/core/fonts.js @@ -55,13 +55,13 @@ import { getSupplementalGlyphMapForArialBlack, getSupplementalGlyphMapForCalibri, } from "./standard_fonts.js"; +import { GlyfTable, pruneCompositeGlyphCycles } from "./glyf.js"; import { IdentityToUnicodeMap, ToUnicodeMap } from "./to_unicode_map.js"; import { CFFFont } from "./cff_font.js"; import { compileFontInfo } from "./obj_bin_transform_core.js"; import { DataBuilder } from "./data_builder.js"; import { FontRendererFactory } from "./font_renderer.js"; import { getFontBasicMetrics } from "./metrics.js"; -import { GlyfTable } from "./glyf.js"; import { OpenTypeFileBuilder } from "./opentype_file_builder.js"; import { Stream } from "./stream.js"; import { Type1Font } from "./type1_font.js"; @@ -720,6 +720,11 @@ function createCmapTable(glyphs, toUnicodeExtraMap, numGlyphs) { function validateOS2Table(os2, file) { file.pos = (file.start || 0) + os2.offset; const version = file.getUint16(); + // https://learn.microsoft.com/en-us/typography/opentype/spec/os2 + const minLength = [78, 86, 96, 96, 96, 100][version]; + if (minLength === undefined || os2.length < minLength) { + return false; + } // TODO verify all OS/2 tables fields, but currently we validate only those // that give us issues file.skip(60); // skipping type, misc sizes, panose, unicode ranges @@ -2195,18 +2200,25 @@ class Font { last.endOffset = oldGlyfDataLength; } + const droppedGlyphs = pruneCompositeGlyphCycles( + oldGlyfData, + locaEntries, + numGlyphs + ); const missingGlyphs = Object.create(null); let writeOffset = 0; itemEncode(locaData, 0, writeOffset); for (i = 0, j = itemSize; i < numGlyphs; i++, j += itemSize) { - const glyphProfile = sanitizeGlyph( - oldGlyfData, - locaEntries[i].offset, - locaEntries[i].endOffset, - newGlyfData, - writeOffset, - hintsValid - ); + const glyphProfile = droppedGlyphs.has(i) + ? { length: 0, sizeOfInstructions: 0 } + : sanitizeGlyph( + oldGlyfData, + locaEntries[i].offset, + locaEntries[i].endOffset, + newGlyfData, + writeOffset, + hintsValid + ); const newLength = glyphProfile.length; if (newLength === 0) { missingGlyphs[i] = true; @@ -2837,6 +2849,19 @@ class Font { maxFunctionDefs = font.getUint16(); font.pos += 4; maxSizeOfInstructions = font.getUint16(); + } else if (isTrueType && version === 0x00005000) { + const newMaxp = new Uint8Array(32); + writeUint32(newMaxp, 0, 0x00010000); + newMaxp[4] = (numGlyphs >> 8) & 0xff; + newMaxp[5] = numGlyphs & 0xff; + newMaxp.fill(0xff, 6, 14); + newMaxp[15] = 2; + newMaxp[28] = 0xff; + newMaxp[29] = 0xff; + newMaxp[31] = 0x10; + tables.maxp.data = newMaxp; + tables.maxp.length = 32; + version = 0x00010000; } tables.maxp.data[4] = numGlyphsOut >> 8; diff --git a/src/core/glyf.js b/src/core/glyf.js index 06bde6482a45d..29a3e68950797 100644 --- a/src/core/glyf.js +++ b/src/core/glyf.js @@ -34,6 +34,8 @@ const WE_HAVE_INSTRUCTIONS = 1 << 8; // const SCALED_COMPONENT_OFFSET = 1 << 11; // const UNSCALED_COMPONENT_OFFSET = 1 << 12; +const GLYPH_HEADER_SIZE = 10; + /** * GlyfTable object represents a glyf table containing glyph information: * - glyph header (xMin, yMin, xMax, yMax); @@ -218,7 +220,7 @@ class GlyphHeader { static parse(pos, glyf) { return [ - 10, + GLYPH_HEADER_SIZE, new GlyphHeader({ numberOfContours: glyf.getInt16(pos), xMin: glyf.getInt16(pos + 2), @@ -230,7 +232,7 @@ class GlyphHeader { } getSize() { - return 10; + return GLYPH_HEADER_SIZE; } write(pos, buf) { @@ -240,7 +242,7 @@ class GlyphHeader { buf.setInt16(pos + 6, this.xMax); buf.setInt16(pos + 8, this.yMax); - return 10; + return GLYPH_HEADER_SIZE; } scale(x, factor) { @@ -696,4 +698,116 @@ class CompositeGlyph { scale(x, factor) {} } -export { GlyfTable }; +function pruneCompositeGlyphCycles(glyfTable, locaEntries, numGlyphs) { + const glyf = new DataView( + glyfTable.buffer, + glyfTable.byteOffset, + glyfTable.byteLength + ); + const components = new Array(numGlyphs); + for (let i = 0; i < numGlyphs; i++) { + const offset = locaEntries[i].offset; + const endOffset = Math.min(locaEntries[i].endOffset, glyf.byteLength); + if (endOffset - offset <= GLYPH_HEADER_SIZE || glyf.getInt16(offset) >= 0) { + continue; + } + const comps = []; + let p = offset + GLYPH_HEADER_SIZE; + while (p + 4 <= endOffset) { + const flags = glyf.getUint16(p); + const gid = glyf.getUint16(p + 2); + let size = 4 + (flags & ARG_1_AND_2_ARE_WORDS ? 4 : 2); + if (flags & WE_HAVE_A_SCALE) { + size += 2; + } else if (flags & WE_HAVE_AN_X_AND_Y_SCALE) { + size += 4; + } else if (flags & WE_HAVE_A_TWO_BY_TWO) { + size += 8; + } + comps.push({ gid, offset: p, size, flags }); + p += size; + if (!(flags & MORE_COMPONENTS)) { + break; + } + } + if (comps.length) { + components[i] = comps; + } + } + + const WHITE = 0, + GRAY = 1, + BLACK = 2; + const state = new Uint8Array(numGlyphs); + const backEdges = new Map(); + for (let start = 0; start < numGlyphs; start++) { + if (state[start] !== WHITE || !components[start]) { + continue; + } + const stack = [{ node: start, idx: 0 }]; + state[start] = GRAY; + while (stack.length > 0) { + const top = stack.at(-1); + const comps = components[top.node]; + if (!comps || top.idx >= comps.length) { + state[top.node] = BLACK; + stack.pop(); + continue; + } + const compIdx = top.idx++; + const next = comps[compIdx].gid; + if (next >= numGlyphs || state[next] === BLACK) { + continue; + } + if (state[next] === WHITE) { + state[next] = GRAY; + stack.push({ node: next, idx: 0 }); + continue; + } + + let removeSet = backEdges.get(top.node); + if (!removeSet) { + removeSet = new Set(); + backEdges.set(top.node, removeSet); + } + removeSet.add(compIdx); + } + } + + const droppedGlyphs = new Set(); + for (const [gIdx, removeSet] of backEdges) { + const comps = components[gIdx]; + const remaining = []; + for (let ci = 0; ci < comps.length; ci++) { + if (!removeSet.has(ci)) { + remaining.push(comps[ci]); + } + } + if (remaining.length === 0) { + droppedGlyphs.add(gIdx); + continue; + } + const start = locaEntries[gIdx].offset; + const endOffset = Math.min(locaEntries[gIdx].endOffset, glyf.byteLength); + let writePos = start + GLYPH_HEADER_SIZE; + for (let ci = 0; ci < remaining.length; ci++) { + const c = remaining[ci]; + const isLast = ci === remaining.length - 1; + let newFlags = c.flags & ~WE_HAVE_INSTRUCTIONS; + newFlags = isLast + ? newFlags & ~MORE_COMPONENTS + : newFlags | MORE_COMPONENTS; + if (writePos !== c.offset) { + glyfTable.copyWithin(writePos, c.offset, c.offset + c.size); + } + glyf.setUint16(writePos, newFlags); + writePos += c.size; + } + if (writePos < endOffset) { + glyfTable.fill(0, writePos, endOffset); + } + } + return droppedGlyphs; +} + +export { GlyfTable, pruneCompositeGlyphCycles }; diff --git a/test/font/font_glyf_spec.js b/test/font/font_glyf_spec.js new file mode 100644 index 0000000000000..decaedccd96ae --- /dev/null +++ b/test/font/font_glyf_spec.js @@ -0,0 +1,141 @@ +/* Copyright 2026 Mozilla Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ttx, verifyTtxOutput } from "./fontutils.js"; +import { Font } from "../../src/core/fonts.js"; +import { Stream } from "../../src/core/stream.js"; +import { ToUnicodeMap } from "../../src/core/to_unicode_map.js"; + +// Minimal TrueType font: 4 glyphs (.notdef, space, A, B), OS/2 v1 / 86 bytes, +// no hinting tables. +const baseFont = Uint8Array.fromBase64( + "AAEAAAAKAIAAAwAgT1MvMkTeRDYAAAEoAAAAVmNtYXAAdQBcAAABjAAAADxnbHlmmNLJuAAAAdQAAABKaGVhZC3Q8mwAAACsAAAANmhoZWEFFgH2AAAA5AAAACRobXR4AlgAAAAAAYAAAAAKbG9jYQAyACYAAAHIAAAACm1heHAABgAGAAABCAAAACBuYW1lAJlcyAAAAiAAAAA8cG9zdAAuACQAAAJcAAAAKgABAAAAAQAAfM/c718PPPUAAQPoAAAAAOYyVzYAAAAA5jJXNgAAAAACWAMgAAAAAwACAAAAAAAAAAEAAAMg/zgAAAJYAAAAZAH0AAEAAAAAAAAAAAAAAAAAAAABAAEAAAAEAAQAAQAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAQJYAZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAPz8/PwAAACAAQgMg/zgAAAMgAMgAAAAAAAAAAAAAAlgAAAAAAAAAAAAAAAAAAgAAAAMAAAAUAAMAAQAAABQABAAoAAAABgAEAAEAAgAgAEL//wAAACAAQf///+H/wQABAAAAAAAAAAAADQANABkAJQAAAAEAZAAAAlgDIAADAAAzIREhZAH0/gwDIAAAAQAAAAAB9AK8AAMAADEhESEB9P4MArwAAQAAAAAB9AK8AAMAADEhESEB9P4MArwAAAAAAAQANgABAAAAAAABAAEAAAABAAAAAAACAAEAAQADAAEECQABAAIAAgADAAEECQACAAIABFRSAFQAUgACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAADACQAJQAA" +); + +function clone(buf) { + return new Uint8Array(buf); +} + +function readUint16(buf, pos) { + return (buf[pos] << 8) | buf[pos + 1]; +} + +function readUint32(buf, pos) { + return ( + buf[pos] * 0x1000000 + + ((buf[pos + 1] << 16) | (buf[pos + 2] << 8) | buf[pos + 3]) + ); +} + +function getTables(buf) { + const tables = Object.create(null); + const numTables = readUint16(buf, 4); + for (let i = 0; i < numTables; i++) { + const off = 12 + i * 16; + const tag = String.fromCharCode( + buf[off], + buf[off + 1], + buf[off + 2], + buf[off + 3] + ); + tables[tag] = { + offset: readUint32(buf, off + 8), + length: readUint32(buf, off + 12), + }; + } + return tables; +} + +function makeProperties(toUnicode) { + return { + loadedName: "font", + type: "TrueType", + differences: [], + defaultEncoding: [], + toUnicode, + xHeight: 0, + capHeight: 0, + italicAngle: 0, + firstChar: 0, + lastChar: 255, + }; +} + +describe("font_glyf", function () { + describe("Cyclic composite glyph 0", function () { + it("removes a self-referencing composite glyph 0 (issue 21298)", async function () { + const buggy = clone(baseFont); + const tables = getTables(buggy); + const headOff = tables.head.offset; + const indexToLocFormat = readUint16(buggy, headOff + 50); + const locaOff = tables.loca.offset; + const glyf0 = + indexToLocFormat === 0 + ? readUint16(buggy, locaOff) * 2 + : readUint32(buggy, locaOff); + const glyf0End = + indexToLocFormat === 0 + ? readUint16(buggy, locaOff + 2) * 2 + : readUint32(buggy, locaOff + 4); + const pos = tables.glyf.offset + glyf0; + buggy.fill(0, pos, tables.glyf.offset + glyf0End); + buggy[pos] = 0xff; + buggy[pos + 1] = 0xff; + buggy[pos + 11] = 0x02; + + const font = new Font( + "font", + new Stream(buggy), + makeProperties(new ToUnicodeMap([])), + {} + ); + const output = await ttx(font.data); + verifyTtxOutput(output); + const notdef = + /]*name="\.notdef"[^>]*\/>|]*name="\.notdef"[^>]*>([\s\S]*?)<\/TTGlyph>/.exec( + output + ); + expect(notdef).not.toBeNull(); + expect(notdef[1] || "").not.toMatch( + /]*glyphName="\.notdef"/ + ); + }); + }); + + describe("OS/2 table length validation", function () { + it("rewrites the OS/2 table when its length doesn't match the declared version", async function () { + const buggy = clone(baseFont); + const tables = getTables(buggy); + const os2 = tables["OS/2"].offset; + buggy[os2 + 62] = 0x00; + buggy[os2 + 63] = 0x40; + buggy[os2 + 1] = 0x03; + + const font = new Font( + "font", + new Stream(buggy), + makeProperties(new ToUnicodeMap([])), + {} + ); + const output = await ttx(font.data); + verifyTtxOutput(output); + expect( + /\s*(\s*)?/.test(output) + ).toEqual(true); + expect(/