From 31d9b9f574d61cd8f275f86628dfd9459a281769 Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Fri, 17 Nov 2023 18:54:26 +0100 Subject: [PATCH] [Editor] Add a way to extract the outlines of a union of rectangles The goal is to be able to get these outlines to fill the shape corresponding to a text selection in order to highlight some text contents. The outlines will be used either to show selected/hovered highlights. --- src/display/draw_layer.js | 201 +++++++++++++++++++++++++ src/display/editor/outliner.js | 262 +++++++++++++++++++++++++++++++++ src/pdf.js | 4 + test/draw_layer_test.css | 39 +++++ test/driver.js | 132 +++++++++++++++-- test/test.mjs | 1 + test/test_manifest.json | 14 ++ test/unit/pdf_spec.js | 4 + web/draw_layer_builder.css | 70 +++++++++ web/draw_layer_builder.js | 58 ++++++++ web/pdfjs.js | 4 + 11 files changed, 777 insertions(+), 12 deletions(-) create mode 100644 src/display/draw_layer.js create mode 100644 src/display/editor/outliner.js create mode 100644 test/draw_layer_test.css create mode 100644 web/draw_layer_builder.css create mode 100644 web/draw_layer_builder.js diff --git a/src/display/draw_layer.js b/src/display/draw_layer.js new file mode 100644 index 0000000000000..ed024ff2eb81a --- /dev/null +++ b/src/display/draw_layer.js @@ -0,0 +1,201 @@ +/* Copyright 2023 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 { DOMSVGFactory } from "./display_utils.js"; +import { shadow } from "../shared/util.js"; + +/** + * Manage the SVGs drawn on top of the page canvas. + * It's important to have them directly on top of the canvas because we want to + * be able to use mix-blend-mode for some of them. + */ +class DrawLayer { + #parent = null; + + #id = 0; + + #mapping = new Map(); + + constructor({ pageIndex }) { + this.pageIndex = pageIndex; + } + + setParent(parent) { + if (!this.#parent) { + this.#parent = parent; + return; + } + + if (this.#parent !== parent) { + if (this.#mapping.size > 0) { + for (const root of this.#mapping.values()) { + root.remove(); + parent.append(root); + } + } + this.#parent = parent; + } + } + + static get _svgFactory() { + return shadow(this, "_svgFactory", new DOMSVGFactory()); + } + + static #setBox(element, { x, y, width, height }) { + const { style } = element; + style.top = `${100 * y}%`; + style.left = `${100 * x}%`; + style.width = `${100 * width}%`; + style.height = `${100 * height}%`; + } + + #createSVG(box) { + const svg = DrawLayer._svgFactory.create(1, 1, /* skipDimensions = */ true); + this.#parent.append(svg); + DrawLayer.#setBox(svg, box); + + return svg; + } + + highlight({ outlines, box }, color, opacity) { + const id = this.#id++; + const root = this.#createSVG(box); + root.classList.add("highlight"); + const defs = DrawLayer._svgFactory.createElement("defs"); + root.append(defs); + const path = DrawLayer._svgFactory.createElement("path"); + defs.append(path); + const pathId = `path_p${this.pageIndex}_${id}`; + path.setAttribute("id", pathId); + path.setAttribute( + "d", + DrawLayer.#extractPathFromHighlightOutlines(outlines) + ); + + // Create the clipping path for the editor div. + const clipPath = DrawLayer._svgFactory.createElement("clipPath"); + defs.append(clipPath); + const clipPathId = `clip_${pathId}`; + clipPath.setAttribute("id", clipPathId); + clipPath.setAttribute("clipPathUnits", "objectBoundingBox"); + const clipPathUse = DrawLayer._svgFactory.createElement("use"); + clipPath.append(clipPathUse); + clipPathUse.setAttribute("href", `#${pathId}`); + clipPathUse.classList.add("clip"); + + const use = DrawLayer._svgFactory.createElement("use"); + root.append(use); + root.setAttribute("fill", color); + root.setAttribute("fill-opacity", opacity); + use.setAttribute("href", `#${pathId}`); + + this.#mapping.set(id, root); + + return { id, clipPathId: `url(#${clipPathId})` }; + } + + highlightOutline({ outlines, box }) { + // We cannot draw the outline directly in the SVG for highlights because + // it composes with its parent with mix-blend-mode: multiply. + // But the outline has a different mix-blend-mode, so we need to draw it in + // its own SVG. + const id = this.#id++; + const root = this.#createSVG(box); + root.classList.add("highlightOutline"); + const defs = DrawLayer._svgFactory.createElement("defs"); + root.append(defs); + const path = DrawLayer._svgFactory.createElement("path"); + defs.append(path); + const pathId = `path_p${this.pageIndex}_${id}`; + path.setAttribute("id", pathId); + path.setAttribute( + "d", + DrawLayer.#extractPathFromHighlightOutlines(outlines) + ); + path.setAttribute("vector-effect", "non-scaling-stroke"); + + const use1 = DrawLayer._svgFactory.createElement("use"); + root.append(use1); + use1.setAttribute("href", `#${pathId}`); + const use2 = use1.cloneNode(); + root.append(use2); + use1.classList.add("mainOutline"); + use2.classList.add("secondaryOutline"); + + this.#mapping.set(id, root); + + return id; + } + + static #extractPathFromHighlightOutlines(polygons) { + const buffer = []; + for (const polygon of polygons) { + let [prevX, prevY] = polygon; + buffer.push(`M${prevX} ${prevY}`); + for (let i = 2; i < polygon.length; i += 2) { + const x = polygon[i]; + const y = polygon[i + 1]; + if (x === prevX) { + buffer.push(`V${y}`); + prevY = y; + } else if (y === prevY) { + buffer.push(`H${x}`); + prevX = x; + } + } + buffer.push("Z"); + } + return buffer.join(" "); + } + + updateBox(id, box) { + DrawLayer.#setBox(this.#mapping.get(id), box); + } + + rotate(id, angle) { + this.#mapping.get(id).setAttribute("data-main-rotation", angle); + } + + changeColor(id, color) { + this.#mapping.get(id).setAttribute("fill", color); + } + + changeOpacity(id, opacity) { + this.#mapping.get(id).setAttribute("fill-opacity", opacity); + } + + addClass(id, className) { + this.#mapping.get(id).classList.add(className); + } + + removeClass(id, className) { + this.#mapping.get(id).classList.remove(className); + } + + remove(id) { + this.#mapping.get(id).remove(); + this.#mapping.delete(id); + } + + destroy() { + this.#parent = null; + for (const root of this.#mapping.values()) { + root.remove(); + } + this.#mapping.clear(); + } +} + +export { DrawLayer }; diff --git a/src/display/editor/outliner.js b/src/display/editor/outliner.js new file mode 100644 index 0000000000000..9a40cd4ce4b24 --- /dev/null +++ b/src/display/editor/outliner.js @@ -0,0 +1,262 @@ +/* Copyright 2023 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. + */ + +class Outliner { + #box; + + #verticalEdges = []; + + #intervals = []; + + /** + * Construct an outliner. + * @param {Array} boxes - An array of axis-aligned rectangles. + * @param {number} borderWidth - The width of the border of the boxes, it + * allows to make the boxes bigger (or smaller). + * @param {number} innerMargin - The margin between the boxes and the + * outlines. It's important to not have a null innerMargin when we want to + * draw the outline else the stroked outline could be clipped because of its + * width. + * @param {boolean} isLTR - true if we're in LTR mode. It's used to determine + * the last point of the boxes. + */ + constructor(boxes, borderWidth = 0, innerMargin = 0, isLTR = true) { + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + + // We round the coordinates to slightly reduce the number of edges in the + // final outlines. + const NUMBER_OF_DIGITS = 4; + const EPSILON = 10 ** -NUMBER_OF_DIGITS; + + // The coordinates of the boxes are in the page coordinate system. + for (const { x, y, width, height } of boxes) { + const x1 = Math.floor((x - borderWidth) / EPSILON) * EPSILON; + const x2 = Math.ceil((x + width + borderWidth) / EPSILON) * EPSILON; + const y1 = Math.floor((y - borderWidth) / EPSILON) * EPSILON; + const y2 = Math.ceil((y + height + borderWidth) / EPSILON) * EPSILON; + const left = [x1, y1, y2, true]; + const right = [x2, y1, y2, false]; + this.#verticalEdges.push(left, right); + + minX = Math.min(minX, x1); + maxX = Math.max(maxX, x2); + minY = Math.min(minY, y1); + maxY = Math.max(maxY, y2); + } + + const bboxWidth = maxX - minX + 2 * innerMargin; + const bboxHeight = maxY - minY + 2 * innerMargin; + const shiftedMinX = minX - innerMargin; + const shiftedMinY = minY - innerMargin; + const lastEdge = this.#verticalEdges.at(isLTR ? -1 : -2); + const lastPoint = [lastEdge[0], lastEdge[2]]; + + // Convert the coordinates of the edges into box coordinates. + for (const edge of this.#verticalEdges) { + const [x, y1, y2] = edge; + edge[0] = (x - shiftedMinX) / bboxWidth; + edge[1] = (y1 - shiftedMinY) / bboxHeight; + edge[2] = (y2 - shiftedMinY) / bboxHeight; + } + + this.#box = { + x: shiftedMinX, + y: shiftedMinY, + width: bboxWidth, + height: bboxHeight, + lastPoint, + }; + } + + getOutlines() { + // We begin to sort lexicographically the vertical edges by their abscissa, + // and then by their ordinate. + this.#verticalEdges.sort( + (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2] + ); + + // We're now using a sweep line algorithm to find the outlines. + // We start with the leftmost vertical edge, and we're going to iterate + // over all the vertical edges from left to right. + // Each time we encounter a left edge, we're going to insert the interval + // [y1, y2] in the set of intervals. + // This set of intervals is used to break the vertical edges into chunks: + // we only take the part of the vertical edge that isn't in the union of + // the intervals. + const outlineVerticalEdges = []; + for (const edge of this.#verticalEdges) { + if (edge[3]) { + // Left edge. + outlineVerticalEdges.push(...this.#breakEdge(edge)); + this.#insert(edge); + } else { + // Right edge. + this.#remove(edge); + outlineVerticalEdges.push(...this.#breakEdge(edge)); + } + } + return this.#getOutlines(outlineVerticalEdges); + } + + #getOutlines(outlineVerticalEdges) { + const edges = []; + const allEdges = new Set(); + + for (const edge of outlineVerticalEdges) { + const [x, y1, y2] = edge; + edges.push([x, y1, edge], [x, y2, edge]); + } + + // We sort lexicographically the vertices of each edge by their ordinate and + // by their abscissa. + // Every pair (v_2i, v_{2i + 1}) of vertices defines a horizontal edge. + // So for every vertical edge, we're going to add the two vertical edges + // which are connected to it through a horizontal edge. + edges.sort((a, b) => a[1] - b[1] || a[0] - b[0]); + for (let i = 0, ii = edges.length; i < ii; i += 2) { + const edge1 = edges[i][2]; + const edge2 = edges[i + 1][2]; + edge1.push(edge2); + edge2.push(edge1); + allEdges.add(edge1); + allEdges.add(edge2); + } + const outlines = []; + let outline; + + while (allEdges.size > 0) { + const edge = allEdges.values().next().value; + let [x, y1, y2, edge1, edge2] = edge; + allEdges.delete(edge); + let lastPointX = x; + let lastPointY = y1; + + outline = [x, y2]; + outlines.push(outline); + + while (true) { + let e; + if (allEdges.has(edge1)) { + e = edge1; + } else if (allEdges.has(edge2)) { + e = edge2; + } else { + break; + } + + allEdges.delete(e); + [x, y1, y2, edge1, edge2] = e; + + if (lastPointX !== x) { + outline.push(lastPointX, lastPointY, x, lastPointY === y1 ? y1 : y2); + lastPointX = x; + } + lastPointY = lastPointY === y1 ? y2 : y1; + } + outline.push(lastPointX, lastPointY); + } + return { outlines, box: this.#box }; + } + + #binarySearch(y) { + const array = this.#intervals; + let start = 0; + let end = array.length - 1; + + while (start <= end) { + const middle = (start + end) >> 1; + const y1 = array[middle][0]; + if (y1 === y) { + return middle; + } + if (y1 < y) { + start = middle + 1; + } else { + end = middle - 1; + } + } + return end + 1; + } + + #insert([, y1, y2]) { + const index = this.#binarySearch(y1); + this.#intervals.splice(index, 0, [y1, y2]); + } + + #remove([, y1, y2]) { + const index = this.#binarySearch(y1); + for (let i = index; i < this.#intervals.length; i++) { + const [start, end] = this.#intervals[i]; + if (start !== y1) { + break; + } + if (start === y1 && end === y2) { + this.#intervals.splice(i, 1); + return; + } + } + for (let i = index - 1; i >= 0; i--) { + const [start, end] = this.#intervals[i]; + if (start !== y1) { + break; + } + if (start === y1 && end === y2) { + this.#intervals.splice(i, 1); + return; + } + } + } + + #breakEdge(edge) { + const [x, y1, y2] = edge; + const results = [[x, y1, y2]]; + const index = this.#binarySearch(y2); + for (let i = 0; i < index; i++) { + const [start, end] = this.#intervals[i]; + for (let j = 0, jj = results.length; j < jj; j++) { + const [, y3, y4] = results[j]; + if (end <= y3 || y4 <= start) { + // There is no intersection between the interval and the edge, hence + // we keep it as is. + continue; + } + if (y3 >= start) { + if (y4 > end) { + results[j][1] = end; + } else { + if (jj === 1) { + return []; + } + // The edge is included in the interval, hence we remove it. + results.splice(j, 1); + j--; + jj--; + } + continue; + } + results[j][2] = start; + if (y4 > end) { + results.push([x, end, y4]); + } + } + } + return results; + } +} + +export { Outliner }; diff --git a/src/pdf.js b/src/pdf.js index 6c88794e8ad16..12031ac46ed41 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -70,7 +70,9 @@ import { renderTextLayer, updateTextLayer } from "./display/text_layer.js"; import { AnnotationEditorLayer } from "./display/editor/annotation_editor_layer.js"; import { AnnotationEditorUIManager } from "./display/editor/tools.js"; import { AnnotationLayer } from "./display/annotation_layer.js"; +import { DrawLayer } from "./display/draw_layer.js"; import { GlobalWorkerOptions } from "./display/worker_options.js"; +import { Outliner } from "./display/editor/outliner.js"; import { XfaLayer } from "./display/xfa_layer.js"; /* eslint-disable-next-line no-unused-vars */ @@ -92,6 +94,7 @@ export { CMapCompressionType, createValidAbsoluteUrl, DOMSVGFactory, + DrawLayer, FeatureTest, fetchData, getDocument, @@ -107,6 +110,7 @@ export { noContextMenu, normalizeUnicode, OPS, + Outliner, PasswordResponses, PDFDataRangeTransport, PDFDateString, diff --git a/test/draw_layer_test.css b/test/draw_layer_test.css new file mode 100644 index 0000000000000..729618fbdcd73 --- /dev/null +++ b/test/draw_layer_test.css @@ -0,0 +1,39 @@ +/* Copyright 2023 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. + */ + +/* Used in 'highlight' tests */ + +.highlight { + position: absolute; + mix-blend-mode: multiply; + fill-rule: evenodd; +} + +.highlightOutline { + position: absolute; + mix-blend-mode: normal; + fill-rule: evenodd; + fill: none; + + .mainOutline { + stroke: white; + stroke-width: 4px; + } + + .secondaryOutline { + stroke: blue; + stroke-width: 2px; + } +} diff --git a/test/driver.js b/test/driver.js index e91ee9cc9d882..45a48560d6529 100644 --- a/test/driver.js +++ b/test/driver.js @@ -17,8 +17,10 @@ const { AnnotationLayer, AnnotationMode, + DrawLayer, getDocument, GlobalWorkerOptions, + Outliner, PixelsPerInch, PromiseCapability, renderTextLayer, @@ -181,6 +183,11 @@ class Rasterize { return shadow(this, "textStylePromise", loadStyles(styles)); } + static get drawLayerStylePromise() { + const styles = [VIEWER_CSS, "./draw_layer_test.css"]; + return shadow(this, "drawLayerStylePromise", loadStyles(styles)); + } + static get xfaStylePromise() { const styles = [VIEWER_CSS, "./xfa_layer_builder_overrides.css"]; return shadow(this, "xfaStylePromise", loadStyles(styles)); @@ -292,6 +299,7 @@ class Rasterize { }); await task.promise; + svg.append(foreignObject); await writeSVG(svg, ctx); @@ -300,6 +308,93 @@ class Rasterize { } } + static async highlightLayer(ctx, viewport, textContent) { + try { + const { svg, foreignObject, style, div } = this.createContainer(viewport); + const dummyParent = document.createElement("div"); + + // Items are transformed to have 1px font size. + svg.setAttribute("font-size", 1); + + const [common, overrides] = await this.drawLayerStylePromise; + style.textContent = + `${common}\n${overrides}` + + `:root { --scale-factor: ${viewport.scale} }`; + + // Rendering text layer as HTML. + const task = renderTextLayer({ + textContentSource: textContent, + container: dummyParent, + viewport, + }); + + await task.promise; + + const { _pageWidth, _pageHeight, _textContentSource, _textDivs } = task; + const boxes = []; + let posRegex; + for ( + let i = 0, j = 0, ii = _textContentSource.items.length; + i < ii; + i++ + ) { + const { width, height, type } = _textContentSource.items[i]; + if (type) { + continue; + } + const { top, left } = _textDivs[j++].style; + let x = parseFloat(left) / 100; + let y = parseFloat(top) / 100; + if (isNaN(x)) { + posRegex ||= /^calc\(var\(--scale-factor\)\*(.*)px\)$/; + // The element is tagged so we've to extract the position from the + // string, e.g. `calc(var(--scale-factor)*66.32px)`. + let match = left.match(posRegex); + if (match) { + x = parseFloat(match[1]) / _pageWidth; + } + + match = top.match(posRegex); + if (match) { + y = parseFloat(match[1]) / _pageHeight; + } + } + if (width === 0 || height === 0) { + continue; + } + boxes.push({ + x, + y, + width: width / _pageWidth, + height: height / _pageHeight, + }); + } + // We set the borderWidth to 0.001 to slighly increase the size of the + // boxes so that they can be merged together. + const outliner = new Outliner(boxes, /* borderWidth = */ 0.001); + // We set the borderWidth to 0.0025 in order to have an outline which is + // slightly bigger than the highlight itself. + // We must add an inner margin to avoid to have a partial outline. + const outlinerForOutline = new Outliner( + boxes, + /* borderWidth = */ 0.0025, + /* innerMargin = */ 0.001 + ); + const drawLayer = new DrawLayer({ pageIndex: 0 }); + drawLayer.setParent(div); + drawLayer.highlight(outliner.getOutlines(), "orange", 0.4); + drawLayer.highlightOutline(outlinerForOutline.getOutlines()); + + svg.append(foreignObject); + + await writeSVG(svg, ctx); + + drawLayer.destroy(); + } catch (reason) { + throw new Error(`Rasterize.textLayer: "${reason?.message}".`); + } + } + static async xfaLayer( ctx, viewport, @@ -737,7 +832,7 @@ class Driver { let textLayerCanvas, annotationLayerCanvas, annotationLayerContext; let initPromise; - if (task.type === "text") { + if (task.type === "text" || task.type === "highlight") { // Using a dummy canvas for PDF context drawing operations textLayerCanvas = this.textLayerCanvas; if (!textLayerCanvas) { @@ -761,11 +856,17 @@ class Driver { disableNormalization: true, }) .then(function (textContent) { - return Rasterize.textLayer( - textLayerContext, - viewport, - textContent - ); + return task.type === "text" + ? Rasterize.textLayer( + textLayerContext, + viewport, + textContent + ) + : Rasterize.highlightLayer( + textLayerContext, + viewport, + textContent + ); }); } else { textLayerCanvas = null; @@ -840,12 +941,19 @@ class Driver { const completeRender = error => { // if text layer is present, compose it on top of the page if (textLayerCanvas) { - ctx.save(); - ctx.globalCompositeOperation = "screen"; - ctx.fillStyle = "rgb(128, 255, 128)"; // making it green - ctx.fillRect(0, 0, pixelWidth, pixelHeight); - ctx.restore(); - ctx.drawImage(textLayerCanvas, 0, 0); + if (task.type === "text") { + ctx.save(); + ctx.globalCompositeOperation = "screen"; + ctx.fillStyle = "rgb(128, 255, 128)"; // making it green + ctx.fillRect(0, 0, pixelWidth, pixelHeight); + ctx.restore(); + ctx.drawImage(textLayerCanvas, 0, 0); + } else if (task.type === "highlight") { + ctx.save(); + ctx.globalCompositeOperation = "multiply"; + ctx.drawImage(textLayerCanvas, 0, 0); + ctx.restore(); + } } // If we have annotation layer, compose it on top of the page. if (annotationLayerCanvas) { diff --git a/test/test.mjs b/test/test.mjs index 01b85a1022268..c24d6e9c37bbc 100644 --- a/test/test.mjs +++ b/test/test.mjs @@ -670,6 +670,7 @@ function checkRefTestResults(browser, id, results) { switch (task.type) { case "eq": case "text": + case "highlight": checkEq(task, results, browser, session.masterMode); break; case "fbf": diff --git a/test/test_manifest.json b/test/test_manifest.json index 56c8ce8d33d42..99e412808d03c 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -8214,5 +8214,19 @@ "rounds": 1, "link": true, "type": "eq" + }, + { + "id": "tracemonkey-highlight", + "file": "pdfs/tracemonkey.pdf", + "md5": "9a192d8b1a7dc652a19835f6f08098bd", + "rounds": 1, + "type": "highlight" + }, + { + "id": "160F-2019-highlight", + "file": "pdfs/160F-2019.pdf", + "md5": "71591f11ee717e12887f529c84d5ae89", + "rounds": 1, + "type": "highlight" } ] diff --git a/test/unit/pdf_spec.js b/test/unit/pdf_spec.js index c7fd117cd4eb6..4c98af5e72802 100644 --- a/test/unit/pdf_spec.js +++ b/test/unit/pdf_spec.js @@ -63,7 +63,9 @@ import { import { AnnotationEditorLayer } from "../../src/display/editor/annotation_editor_layer.js"; import { AnnotationEditorUIManager } from "../../src/display/editor/tools.js"; import { AnnotationLayer } from "../../src/display/annotation_layer.js"; +import { DrawLayer } from "../../src/display/draw_layer.js"; import { GlobalWorkerOptions } from "../../src/display/worker_options.js"; +import { Outliner } from "../../src/display/editor/outliner.js"; import { XfaLayer } from "../../src/display/xfa_layer.js"; const expectedAPI = Object.freeze({ @@ -78,6 +80,7 @@ const expectedAPI = Object.freeze({ CMapCompressionType, createValidAbsoluteUrl, DOMSVGFactory, + DrawLayer, FeatureTest, fetchData, getDocument, @@ -93,6 +96,7 @@ const expectedAPI = Object.freeze({ noContextMenu, normalizeUnicode, OPS, + Outliner, PasswordResponses, PDFDataRangeTransport, PDFDateString, diff --git a/web/draw_layer_builder.css b/web/draw_layer_builder.css new file mode 100644 index 0000000000000..4b59c8d962133 --- /dev/null +++ b/web/draw_layer_builder.css @@ -0,0 +1,70 @@ +/* Copyright 2014 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. + */ + +.canvasWrapper { + svg { + transform: none; + + &[data-main-rotation="90"] { + use:not(.clip) { + transform: matrix(0, 1, -1, 0, 1, 0); + } + } + + &[data-main-rotation="180"] { + use:not(.clip) { + transform: matrix(-1, 0, 0, -1, 1, 1); + } + } + + &[data-main-rotation="270"] { + use:not(.clip) { + transform: matrix(0, -1, 1, 0, 0, 1); + } + } + + &.highlight { + position: absolute; + mix-blend-mode: multiply; + fill-rule: evenodd; + } + + &.highlightOutline { + position: absolute; + mix-blend-mode: normal; + fill-rule: evenodd; + fill: none; + + &.hovered { + stroke: var(--hover-outline-color); + stroke-width: var(--outline-width); + } + + &.selected { + .mainOutline { + stroke: var(--outline-around-color); + stroke-width: calc( + var(--outline-width) + 2 * var(--outline-around-width) + ); + } + + .secondaryOutline { + stroke: var(--outline-color); + stroke-width: var(--outline-width); + } + } + } + } +} diff --git a/web/draw_layer_builder.js b/web/draw_layer_builder.js new file mode 100644 index 0000000000000..693f15a3ba742 --- /dev/null +++ b/web/draw_layer_builder.js @@ -0,0 +1,58 @@ +/* Copyright 2022 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. + */ + +/** @typedef {import("../src/display/draw_layer.js").DrawLayer} DrawLayer */ + +import { DrawLayer } from "pdfjs-lib"; + +/** + * @typedef {Object} DrawLayerBuilderOptions + * @property {DrawLayer} [drawLayer] + */ + +class DrawLayerBuilder { + #drawLayer = null; + + /** + * @param {DrawLayerBuilderOptions} options + */ + constructor(options) { + this.pageIndex = options.pageIndex; + } + + /** + * @param {string} intent (default value is 'display') + */ + async render(intent = "display") { + if (intent !== "display" || this.#drawLayer || this._cancelled) { + return; + } + this.#drawLayer = new DrawLayer({ + pageIndex: this.pageIndex, + }); + } + + cancel() { + this._cancelled = true; + + if (!this.#drawLayer) { + return; + } + this.#drawLayer.destroy(); + this.#drawLayer = null; + } +} + +export { DrawLayerBuilder }; diff --git a/web/pdfjs.js b/web/pdfjs.js index a8b8b215bb350..58fcbbea4e503 100644 --- a/web/pdfjs.js +++ b/web/pdfjs.js @@ -34,6 +34,7 @@ const { CMapCompressionType, createValidAbsoluteUrl, DOMSVGFactory, + DrawLayer, FeatureTest, fetchData, getDocument, @@ -49,6 +50,7 @@ const { noContextMenu, normalizeUnicode, OPS, + Outliner, PasswordResponses, PDFDataRangeTransport, PDFDateString, @@ -80,6 +82,7 @@ export { CMapCompressionType, createValidAbsoluteUrl, DOMSVGFactory, + DrawLayer, FeatureTest, fetchData, getDocument, @@ -95,6 +98,7 @@ export { noContextMenu, normalizeUnicode, OPS, + Outliner, PasswordResponses, PDFDataRangeTransport, PDFDateString,