diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index 886cc87b44029..0bd7f9db3fa42 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -220,6 +220,11 @@ "description": "The color is a string as defined in CSS. Its goal is to help improve readability in high contrast mode", "type": "string", "default": "CanvasText" + }, + "enableAutoLinking": { + "description": "Automatically detect URLs in the text and create links for them", + "type": "boolean", + "default": false } } } diff --git a/src/display/annotation_layer.js b/src/display/annotation_layer.js index 1b55f3d51f94c..c93ea4cc495e2 100644 --- a/src/display/annotation_layer.js +++ b/src/display/annotation_layer.js @@ -3264,6 +3264,29 @@ class AnnotationLayer { this.#setAnnotationCanvasMap(); } + /** + * Add link annotations to the annotation layer. + * + * @param {Array} annotations + * @param {IPDFLinkService} linkService + * @memberof AnnotationLayer + */ + async addLinkAnnotations(annotations, linkService) { + const elementParams = { + data: null, + layer: this.div, + linkService, + svgFactory: new DOMSVGFactory(), + parent: this, + }; + for (const data of annotations) { + elementParams.data = data; + const element = AnnotationElementFactory.create(elementParams); + const rendered = element.render(); + await this.#appendElement(rendered, data.id); + } + } + /** * Update the annotation elements on existing annotation layer. * diff --git a/src/pdf.js b/src/pdf.js index 757e97e4ccf11..11569852e6039 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -24,9 +24,11 @@ import { AbortException, + AnnotationBorderStyleType, AnnotationEditorParamsType, AnnotationEditorType, AnnotationMode, + AnnotationType, createValidAbsoluteUrl, FeatureTest, ImageKind, @@ -89,12 +91,14 @@ if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING || GENERIC")) { export { AbortException, + AnnotationBorderStyleType, AnnotationEditorLayer, AnnotationEditorParamsType, AnnotationEditorType, AnnotationEditorUIManager, AnnotationLayer, AnnotationMode, + AnnotationType, build, ColorPicker, createValidAbsoluteUrl, diff --git a/test/integration/autolinker_spec.mjs b/test/integration/autolinker_spec.mjs new file mode 100644 index 0000000000000..a39c18f7931ec --- /dev/null +++ b/test/integration/autolinker_spec.mjs @@ -0,0 +1,91 @@ +/* Copyright 2025 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"; + +describe("autolinker", function () { + describe("bug1019475_2.pdf", function () { + let pages; + + beforeAll(async () => { + pages = await loadAndWait( + "bug1019475_2.pdf", + ".annotationLayer", + null, + null, + { + enableAutoLinking: true, + } + ); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must appropriately add link annotations when relevant", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const url = await page.$$eval( + ".annotationLayer > .linkAnnotation > a", + annotations => annotations.map(a => a.href) + ); + expect(url.length).withContext(`In ${browserName}`).toEqual(1); + expect(url[0]) + .withContext(`In ${browserName}`) + .toEqual("http://www.mozilla.org/"); + }) + ); + }); + }); + + describe("bug1019475_1.pdf", function () { + let pages; + + beforeAll(async () => { + pages = await loadAndWait( + "bug1019475_1.pdf", + ".annotationLayer", + null, + null, + { + enableAutoLinking: true, + } + ); + }); + + afterAll(async () => { + await closePages(pages); + }); + + it("must not add links when unnecessary", async () => { + await Promise.all( + pages.map(async ([browserName, page]) => { + const linkIds = await page.$$eval( + ".annotationLayer > .linkAnnotation > a", + annotations => + annotations.map(a => a.getAttribute("data-element-id")) + ); + expect(linkIds.length).withContext(`In ${browserName}`).toEqual(3); + linkIds.forEach(id => + expect(id) + .withContext(`In ${browserName}`) + .not.toContain("added_link_") + ); + }) + ); + }); + }); +}); diff --git a/test/integration/jasmine-boot.js b/test/integration/jasmine-boot.js index 3dff4d9f0a389..b6d904c84a168 100644 --- a/test/integration/jasmine-boot.js +++ b/test/integration/jasmine-boot.js @@ -28,6 +28,7 @@ async function runTests(results) { spec_files: [ "accessibility_spec.mjs", "annotation_spec.mjs", + "autolinker_spec.mjs", "caret_browsing_spec.mjs", "copy_paste_spec.mjs", "find_spec.mjs", diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index 3001e51b4b03b..14660ecd31060 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -698,3 +698,5 @@ !issue19207.pdf !issue19239.pdf !issue19360.pdf +!bug1019475_1.pdf +!bug1019475_2.pdf diff --git a/test/pdfs/bug1019475_1.pdf b/test/pdfs/bug1019475_1.pdf new file mode 100644 index 0000000000000..54e80411fad1f Binary files /dev/null and b/test/pdfs/bug1019475_1.pdf differ diff --git a/test/pdfs/bug1019475_2.pdf b/test/pdfs/bug1019475_2.pdf new file mode 100644 index 0000000000000..569b5f6ce212b Binary files /dev/null and b/test/pdfs/bug1019475_2.pdf differ diff --git a/test/unit/autolinker_spec.js b/test/unit/autolinker_spec.js new file mode 100644 index 0000000000000..23a30446c54b3 --- /dev/null +++ b/test/unit/autolinker_spec.js @@ -0,0 +1,194 @@ +/* Copyright 2025 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 { Autolinker } from "../../web/autolinker.js"; + +function testLinks(links) { + const matches = Autolinker.findLinks(links.map(link => link[0]).join("\n")); + expect(matches.length).toEqual(links.length); + for (let i = 0; i < links.length; i++) { + expect(matches[i].url).toEqual(links[i][1]); + } +} + +describe("autolinker", function () { + it("should correctly find URLs", function () { + const [matched] = Autolinker.findLinks("http://www.example.com"); + expect(matched.url).toEqual("http://www.example.com/"); + }); + + it("should correctly find simple valid URLs", function () { + testLinks([ + [ + "http://subdomain.example.com/path/to/page?query=param", + "http://subdomain.example.com/path/to/page?query=param", + ], + [ + "www.example.com/path/to/resource", + "http://www.example.com/path/to/resource", + ], + [ + "http://example.com/path?query=value#fragment", + "http://example.com/path?query=value#fragment", + ], + ]); + }); + + it("should correctly find emails", function () { + testLinks([ + ["mailto:username@example.com", "mailto:username@example.com"], + [ + "mailto:someone@subdomain.example.com", + "mailto:someone@subdomain.example.com", + ], + ["peter@abc.de", "mailto:peter@abc.de"], + ["red.teddy.b@abc.com", "mailto:red.teddy.b@abc.com"], + [ + "abc_@gmail.com", // '_' is ok before '@'. + "mailto:abc_@gmail.com", + ], + [ + "dummy-hi@gmail.com", // '-' is ok in user name. + "mailto:dummy-hi@gmail.com", + ], + [ + "a..df@gmail.com", // Stop at consecutive '.'. + "mailto:a..df@gmail.com", + ], + [ + ".john@yahoo.com", // Remove heading '.'. + "mailto:john@yahoo.com", + ], + [ + "abc@xyz.org?/", // Trim ending invalid chars. + "mailto:abc@xyz.org", + ], + [ + "fan{abc@xyz.org", // Trim beginning invalid chars. + "mailto:abc@xyz.org", + ], + [ + "fan@g.com..", // Trim the ending periods. + "mailto:fan@g.com", + ], + [ + "CAP.cap@Gmail.Com", // Keep the original case. + "mailto:CAP.cap@Gmail.Com", + ], + ]); + }); + + it("should correctly handle complex or edge cases", function () { + testLinks([ + [ + "https://example.com/path/to/page?query=param&another=val#section", + "https://example.com/path/to/page?query=param&another=val#section", + ], + [ + "www.example.com/resource/(parentheses)-allowed/", + "http://www.example.com/resource/(parentheses)-allowed/", + ], + [ + "http://example.com/path_with_underscores", + "http://example.com/path_with_underscores", + ], + [ + "http://www.example.com:8080/port/test", + "http://www.example.com:8080/port/test", + ], + [ + "https://example.com/encoded%20spaces%20in%20path", + "https://example.com/encoded%20spaces%20in%20path", + ], + ["mailto:hello+world@example.com", "mailto:hello+world@example.com"], + ["www.a.com/#a=@?q=rr&r=y", "http://www.a.com/#a=@?q=rr&r=y"], + ["http://a.com/1/2/3/4\\5\\6", "http://a.com/1/2/3/4/5/6"], + ["http://www.example.com/foo;bar", "http://www.example.com/foo;bar"], + // ["www.abc.com/#%%^&&*(", "http://www.abc.com/#%%^&&*("], TODO: Patch the regex to accept the whole URL. + ]); + }); + + it("shouldn't find false positives", function () { + const matches = Autolinker.findLinks( + [ + "not a valid URL", + "htp://misspelled-protocol.com", + "example.com (missing protocol)", + "https://[::1] (IPv6 loopback)", + "http:// (just protocol)", + "", // Blank. + "http", // No colon. + "www.", // Missing domain. + "https-and-www", // Dash not colon. + "http:/abc.com", // Missing slash. + "http://((()),", // Only invalid chars in host name. + "ftp://example.com", // Ftp scheme is not supported. + "http:example.com", // Missing slashes. + "http//[example.com", // Invalid IPv6 address. + "http//[00:00:00:00:00:00", // Invalid IPv6 address. + "http//[]", // Empty IPv6 address. + "abc.example.com", // URL without scheme. + ].join("\n") + ); + expect(matches.length).toEqual(0); + }); + + it("should correctly find links among mixed content", function () { + const matches = Autolinker.findLinks( + [ + "Here's a URL: https://example.com and an email: mailto:test@example.com", + "www.example.com and more text", + "Check this: http://example.com/path?query=1 and this mailto:info@domain.com", + ].join("\n") + ); + expect(matches.length).toEqual(5); + expect(matches[0].url).toEqual("https://example.com/"); + expect(matches[1].url).toEqual("mailto:test@example.com"); + expect(matches[2].url).toEqual("http://www.example.com/"); + expect(matches[3].url).toEqual("http://example.com/path?query=1"); + expect(matches[4].url).toEqual("mailto:info@domain.com"); + }); + + it("should correctly work with special characters", function () { + testLinks([ + [ + "https://example.com/path/to/page?query=value&symbol=£", + "https://example.com/path/to/page?query=value&symbol=%C2%A3", + ], + [ + "mailto:user.name+alias@example-domain.com", + "mailto:user.name+alias@example-domain.com", + ], + ["http://example.com/@user", "http://example.com/@user"], + ["https://example.com/path#@anchor", "https://example.com/path#@anchor"], + ["www.测试.net", "http://www.xn--0zwm56d.net/"], + ["www.测试.net;", "http://www.xn--0zwm56d.net/"], + // [ "www.测试。net。", "http://www.xn--0zwm56d.net/" ] TODO: Patch `createValidAbsoluteUrl` to accept this. + ]); + }); + + it("should correctly find links with dashes and newlines between numbers", function () { + const matches = Autolinker.findLinks("http://abcd.efg/test1-\n2/test.html"); + expect(matches.length).toEqual(1); + expect(matches[0].url).toEqual("http://abcd.efg/test1-2/test.html"); + }); + + it("should correctly identify emails with special prefixes", function () { + testLinks([ + ["wwwtest@email.com", "mailto:wwwtest@email.com"], + ["httptest@email.com", "mailto:httptest@email.com"], + ]); + }); +}); diff --git a/test/unit/clitests.json b/test/unit/clitests.json index 492687985c2c3..1328b612461bc 100644 --- a/test/unit/clitests.json +++ b/test/unit/clitests.json @@ -8,6 +8,7 @@ "annotation_storage_spec.js", "api_spec.js", "app_options_spec.js", + "autolinker_spec.js", "bidi_spec.js", "canvas_factory_spec.js", "cff_parser_spec.js", diff --git a/test/unit/pdf_spec.js b/test/unit/pdf_spec.js index e5f0caceb5e59..377de2d0664e3 100644 --- a/test/unit/pdf_spec.js +++ b/test/unit/pdf_spec.js @@ -15,9 +15,11 @@ import { AbortException, + AnnotationBorderStyleType, AnnotationEditorParamsType, AnnotationEditorType, AnnotationMode, + AnnotationType, createValidAbsoluteUrl, FeatureTest, ImageKind, @@ -66,12 +68,14 @@ import { XfaLayer } from "../../src/display/xfa_layer.js"; const expectedAPI = Object.freeze({ AbortException, + AnnotationBorderStyleType, AnnotationEditorLayer, AnnotationEditorParamsType, AnnotationEditorType, AnnotationEditorUIManager, AnnotationLayer, AnnotationMode, + AnnotationType, build, ColorPicker, createValidAbsoluteUrl, diff --git a/web/annotation_layer_builder.js b/web/annotation_layer_builder.js index b6e31404c20a8..effe83bf6413f 100644 --- a/web/annotation_layer_builder.js +++ b/web/annotation_layer_builder.js @@ -27,7 +27,12 @@ // eslint-disable-next-line max-len /** @typedef {import("../src/display/editor/tools.js").AnnotationEditorUIManager} AnnotationEditorUIManager */ -import { AnnotationLayer } from "pdfjs-lib"; +import { + AnnotationLayer, + AnnotationType, + setLayerDimensions, + Util, +} from "pdfjs-lib"; import { PresentationModeState } from "./ui_utils.js"; /** @@ -56,7 +61,18 @@ import { PresentationModeState } from "./ui_utils.js"; * @property {StructTreeLayerBuilder} [structTreeLayer] */ +/** + * @typedef {Object} InjectLinkAnnotationsOptions + * @property {Array} inferredLinks + * @property {PageViewport} viewport + * @property {StructTreeLayerBuilder} [structTreeLayer] + */ + class AnnotationLayerBuilder { + #annotations = null; + + #externalHide = false; + #onAppend = null; #eventAbortController = null; @@ -133,19 +149,13 @@ class AnnotationLayerBuilder { this.#onAppend?.(div); if (annotations.length === 0) { - this.hide(); + this.#annotations = annotations; + + this.hide(/* internal = */ true); return; } - this.annotationLayer = new AnnotationLayer({ - div, - accessibilityManager: this._accessibilityManager, - annotationCanvasMap: this._annotationCanvasMap, - annotationEditorUIManager: this._annotationEditorUIManager, - page: this.pdfPage, - viewport: viewport.clone({ dontFlip: true }), - structTreeLayer, - }); + this.#initAnnotationLayer(viewport, structTreeLayer); await this.annotationLayer.render({ annotations, @@ -159,6 +169,8 @@ class AnnotationLayerBuilder { fieldObjects, }); + this.#annotations = annotations; + // Ensure that interactive form elements in the annotationLayer are // disabled while PresentationMode is active (see issue 12232). if (this.linkService.isInPresentationMode) { @@ -177,6 +189,18 @@ class AnnotationLayerBuilder { } } + #initAnnotationLayer(viewport, structTreeLayer) { + this.annotationLayer = new AnnotationLayer({ + div: this.div, + accessibilityManager: this._accessibilityManager, + annotationCanvasMap: this._annotationCanvasMap, + annotationEditorUIManager: this._annotationEditorUIManager, + page: this.pdfPage, + viewport: viewport.clone({ dontFlip: true }), + structTreeLayer, + }); + } + cancel() { this._cancelled = true; @@ -184,7 +208,8 @@ class AnnotationLayerBuilder { this.#eventAbortController = null; } - hide() { + hide(internal = false) { + this.#externalHide = !internal; if (!this.div) { return; } @@ -195,6 +220,45 @@ class AnnotationLayerBuilder { return !!this.annotationLayer?.hasEditableAnnotations(); } + /** + * @param {InjectLinkAnnotationsOptions} options + * @returns {Promise} A promise that is resolved when the inferred links + * are added to the annotation layer. + */ + async injectLinkAnnotations({ + inferredLinks, + viewport, + structTreeLayer = null, + }) { + if (this.#annotations === null) { + throw new Error( + "`render` method must be called before `injectLinkAnnotations`." + ); + } + if (this._cancelled) { + return; + } + + const newLinks = this.#annotations.length + ? this.#checkInferredLinks(inferredLinks) + : inferredLinks; + + if (!newLinks.length) { + return; + } + + if (!this.annotationLayer) { + this.#initAnnotationLayer(viewport, structTreeLayer); + setLayerDimensions(this.div, viewport); + } + + await this.annotationLayer.addLinkAnnotations(newLinks, this.linkService); + // Don't show the annotation layer if it was explicitly hidden previously. + if (!this.#externalHide) { + this.div.hidden = false; + } + } + #updatePresentationModeState(state) { if (!this.div) { return; @@ -217,6 +281,75 @@ class AnnotationLayerBuilder { section.inert = disableFormElements; } } + + #checkInferredLinks(inferredLinks) { + function annotationRects(annot) { + if (!annot.quadPoints) { + return [annot.rect]; + } + const rects = []; + for (let i = 2, ii = annot.quadPoints.length; i < ii; i += 8) { + const trX = annot.quadPoints[i]; + const trY = annot.quadPoints[i + 1]; + const blX = annot.quadPoints[i + 2]; + const blY = annot.quadPoints[i + 3]; + rects.push([blX, blY, trX, trY]); + } + return rects; + } + + function intersectAnnotations(annot1, annot2) { + const intersections = []; + const annot1Rects = annotationRects(annot1); + const annot2Rects = annotationRects(annot2); + for (const rect1 of annot1Rects) { + for (const rect2 of annot2Rects) { + const intersection = Util.intersect(rect1, rect2); + if (intersection) { + intersections.push(intersection); + } + } + } + return intersections; + } + + function areaRects(rects) { + let totalArea = 0; + for (const rect of rects) { + totalArea += Math.abs((rect[2] - rect[0]) * (rect[3] - rect[1])); + } + return totalArea; + } + + return inferredLinks.filter(link => { + let linkAreaRects; + + for (const annotation of this.#annotations) { + if ( + annotation.annotationType !== AnnotationType.LINK || + annotation.url !== link.url + ) { + continue; + } + // TODO: Add a test case to verify that we can find the intersection + // between two annotations with quadPoints properly. + const intersections = intersectAnnotations(annotation, link); + + if (intersections.length === 0) { + continue; + } + linkAreaRects ??= areaRects(annotationRects(link)); + + if ( + areaRects(intersections) / linkAreaRects > + 0.5 /* If the overlap is more than 50%. */ + ) { + return false; + } + } + return true; + }); + } } export { AnnotationLayerBuilder }; diff --git a/web/app.js b/web/app.js index a935e8eb1f658..f92ac821127c7 100644 --- a/web/app.js +++ b/web/app.js @@ -353,6 +353,7 @@ const PDFViewerApplication = { if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) { Object.assign(opts, { enableAltText: x => x === "true", + enableAutoLinking: x => x === "true", enableFakeMLManager: x => x === "true", enableGuessAltText: x => x === "true", enableUpdatedAddImage: x => x === "true", @@ -492,6 +493,7 @@ const PDFViewerApplication = { abortSignal: this._globalAbortController.signal, enableHWA, supportsPinchToZoom: this.supportsPinchToZoom, + enableAutoLinking: AppOptions.get("enableAutoLinking"), }); this.pdfViewer = pdfViewer; diff --git a/web/app_options.js b/web/app_options.js index 31e47c40f13db..3df12837fabf3 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -195,6 +195,11 @@ const defaultOptions = { value: true, kind: OptionKind.VIEWER + OptionKind.PREFERENCE + OptionKind.EVENT_DISPATCH, }, + enableAutoLinking: { + /** @type {boolean} */ + value: false, + kind: OptionKind.VIEWER + OptionKind.PREFERENCE, + }, enableGuessAltText: { /** @type {boolean} */ value: true, diff --git a/web/autolinker.js b/web/autolinker.js new file mode 100644 index 0000000000000..09200f46a96a1 --- /dev/null +++ b/web/autolinker.js @@ -0,0 +1,147 @@ +/* Copyright 2025 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 { + AnnotationBorderStyleType, + AnnotationType, + createValidAbsoluteUrl, + Util, +} from "pdfjs-lib"; +import { getOriginalIndex, normalize } from "./pdf_find_controller.js"; + +function DOMRectToPDF({ width, height, left, top }, pdfPageView) { + if (width === 0 || height === 0) { + return null; + } + + const pageBox = pdfPageView.textLayer.div.getBoundingClientRect(); + const bottomLeft = pdfPageView.getPagePoint( + left - pageBox.left, + top - pageBox.top + ); + const topRight = pdfPageView.getPagePoint( + left - pageBox.left + width, + top - pageBox.top + height + ); + + return Util.normalizeRect([ + bottomLeft[0], + bottomLeft[1], + topRight[0], + topRight[1], + ]); +} + +function calculateLinkPosition(range, pdfPageView) { + const rangeRects = range.getClientRects(); + if (rangeRects.length === 1) { + return { rect: DOMRectToPDF(rangeRects[0], pdfPageView) }; + } + + const rect = [Infinity, Infinity, -Infinity, -Infinity]; + const quadPoints = []; + let i = 0; + for (const domRect of rangeRects) { + const normalized = DOMRectToPDF(domRect, pdfPageView); + if (normalized === null) { + continue; + } + + quadPoints[i] = quadPoints[i + 4] = normalized[0]; + quadPoints[i + 1] = quadPoints[i + 3] = normalized[3]; + quadPoints[i + 2] = quadPoints[i + 6] = normalized[2]; + quadPoints[i + 5] = quadPoints[i + 7] = normalized[1]; + + rect[0] = Math.min(rect[0], normalized[0]); + rect[1] = Math.min(rect[1], normalized[1]); + rect[2] = Math.max(rect[2], normalized[2]); + rect[3] = Math.max(rect[3], normalized[3]); + + i += 8; + } + return { quadPoints, rect }; +} + +function createLinkAnnotation({ url, index, length }, pdfPageView, id) { + const highlighter = pdfPageView._textHighlighter; + const [{ begin, end }] = highlighter._convertMatches([index], [length]); + + const range = new Range(); + range.setStart(highlighter.textDivs[begin.divIdx].firstChild, begin.offset); + range.setEnd(highlighter.textDivs[end.divIdx].firstChild, end.offset); + + return { + id: `added_link_${id}`, + unsafeUrl: url, + url, + annotationType: AnnotationType.LINK, + rotation: 0, + ...calculateLinkPosition(range, pdfPageView), + // This is just the default for AnnotationBorderStyle. + borderStyle: { + width: 1, + rawWidth: 1, + style: AnnotationBorderStyleType.SOLID, + dashArray: [3], + horizontalCornerRadius: 0, + verticalCornerRadius: 0, + }, + }; +} + +class Autolinker { + static #index = 0; + + static #regex; + + static findLinks(text) { + // Regex can be tested and verified at https://regex101.com/r/zgDwPE/1. + this.#regex ??= + /\b(?:https?:\/\/|mailto:|www\.)(?:[[\S--\[]--\p{P}]|\/|[\p{P}--\[]+[[\S--\[]--\p{P}])+|\b[[\S--@]--\{]+@[\S--.]+\.[[\S--\[]--\p{P}]{2,}/gmv; + + const [normalizedText, diffs] = normalize(text); + const matches = normalizedText.matchAll(this.#regex); + const links = []; + for (const match of matches) { + const raw = + match[0].startsWith("www.") || + match[0].startsWith("mailto:") || + match[0].startsWith("http://") || + match[0].startsWith("https://") + ? match[0] + : `mailto:${match[0]}`; + const url = createValidAbsoluteUrl(raw, null, { + addDefaultProtocol: true, + }); + if (url) { + const [index, length] = getOriginalIndex( + diffs, + match.index, + match[0].length + ); + links.push({ url: url.href, index, length }); + } + } + return links; + } + + static processLinks(pdfPageView) { + return this.findLinks( + pdfPageView._textHighlighter.textContentItemsStr.join("\n") + ).map(link => createLinkAnnotation(link, pdfPageView, this.#index++)); + } +} + +export { Autolinker }; diff --git a/web/pdf_find_controller.js b/web/pdf_find_controller.js index 0b46117e9f986..a9fa441a13728 100644 --- a/web/pdf_find_controller.js +++ b/web/pdf_find_controller.js @@ -1185,4 +1185,4 @@ class PDFFindController { } } -export { FindState, PDFFindController }; +export { FindState, getOriginalIndex, normalize, PDFFindController }; diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index fd33e3b762e52..9be250b2bc0e6 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -43,6 +43,7 @@ import { import { AnnotationEditorLayerBuilder } from "./annotation_editor_layer_builder.js"; import { AnnotationLayerBuilder } from "./annotation_layer_builder.js"; import { AppOptions } from "./app_options.js"; +import { Autolinker } from "./autolinker.js"; import { DrawLayerBuilder } from "./draw_layer_builder.js"; import { GenericL10n } from "web-null_l10n"; import { SimpleLinkService } from "./pdf_link_service.js"; @@ -84,6 +85,8 @@ import { XfaLayerBuilder } from "./xfa_layer_builder.js"; * the necessary layer-properties. * @property {boolean} [enableHWA] - Enables hardware acceleration for * rendering. The default value is `false`. + * @property {boolean} [enableAutoLinking] - Enable creation of hyperlinks from + * text that look like URLs. The default value is `false`. */ const DEFAULT_LAYER_PROPERTIES = @@ -120,6 +123,8 @@ class PDFPageView { #enableHWA = false; + #enableAutoLinking = false; + #hasRestrictedScaling = false; #isEditing = false; @@ -177,6 +182,7 @@ class PDFPageView { options.maxCanvasPixels ?? AppOptions.get("maxCanvasPixels"); this.pageColors = options.pageColors || null; this.#enableHWA = options.enableHWA || false; + this.#enableAutoLinking = options.enableAutoLinking || false; this.eventBus = options.eventBus; this.renderingQueue = options.renderingQueue; @@ -1100,10 +1106,18 @@ class PDFPageView { viewport.rawDims ); - this.#renderTextLayer(); + const textLayerPromise = this.#renderTextLayer(); if (this.annotationLayer) { await this.#renderAnnotationLayer(); + if (this.#enableAutoLinking) { + await textLayerPromise; + this.annotationLayer.injectLinkAnnotations({ + inferredLinks: Autolinker.processLinks(this), + viewport: this.viewport, + structTreeLayer: this.structTreeLayer, + }); + } } const { annotationEditorUIManager } = this.#layerProperties; diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 986cfa71236ac..0880a40146611 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -128,6 +128,8 @@ function isValidAnnotationEditorMode(mode) { * rendering. The default value is `false`. * @property {boolean} [supportsPinchToZoom] - Enable zooming on pinch gesture. * The default value is `true`. + * @property {boolean} [enableAutoLinking] - Enable creation of hyperlinks from + * text that look like URLs. The default value is `false`. */ class PDFPageViewBuffer { @@ -228,6 +230,8 @@ class PDFViewer { #enableNewAltTextWhenAddingImage = false; + #enableAutoLinking = false; + #eventAbortController = null; #mlManager = null; @@ -321,6 +325,7 @@ class PDFViewer { this.#mlManager = options.mlManager || null; this.#enableHWA = options.enableHWA || false; this.#supportsPinchToZoom = options.supportsPinchToZoom !== false; + this.#enableAutoLinking = options.enableAutoLinking || false; this.defaultRenderingQueue = !options.renderingQueue; if ( @@ -990,6 +995,7 @@ class PDFViewer { l10n: this.l10n, layerProperties: this._layerProperties, enableHWA: this.#enableHWA, + enableAutoLinking: this.#enableAutoLinking, }); this._pages.push(pageView); } diff --git a/web/pdfjs.js b/web/pdfjs.js index 7ac129513be5a..ac5d6432cf6ae 100644 --- a/web/pdfjs.js +++ b/web/pdfjs.js @@ -15,12 +15,14 @@ const { AbortException, + AnnotationBorderStyleType, AnnotationEditorLayer, AnnotationEditorParamsType, AnnotationEditorType, AnnotationEditorUIManager, AnnotationLayer, AnnotationMode, + AnnotationType, build, ColorPicker, createValidAbsoluteUrl, @@ -62,12 +64,14 @@ const { export { AbortException, + AnnotationBorderStyleType, AnnotationEditorLayer, AnnotationEditorParamsType, AnnotationEditorType, AnnotationEditorUIManager, AnnotationLayer, AnnotationMode, + AnnotationType, build, ColorPicker, createValidAbsoluteUrl,