diff --git a/src/display/draw_layer.js b/src/display/draw_layer.js index 0b114bbe1d279..34e4c02053d41 100644 --- a/src/display/draw_layer.js +++ b/src/display/draw_layer.js @@ -86,13 +86,11 @@ class DrawLayer { return clipPathId; } - highlight(outlines, color, opacity, isPathUpdatable = false) { + draw(outlines, color, opacity, isPathUpdatable = false) { const id = this.#id++; const root = this.#createSVG(outlines.box); - root.classList.add("highlight"); - if (outlines.free) { - root.classList.add("free"); - } + root.classList.add(...outlines.classNamesForDrawing); + const defs = DrawLayer._svgFactory.createElement("defs"); root.append(defs); const path = DrawLayer._svgFactory.createElement("path"); @@ -119,14 +117,14 @@ class DrawLayer { return { id, clipPathId: `url(#${clipPathId})` }; } - highlightOutline(outlines) { + drawOutline(outlines) { // 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(outlines.box); - root.classList.add("highlightOutline"); + root.classList.add(...outlines.classNamesForOutlining); const defs = DrawLayer._svgFactory.createElement("defs"); root.append(defs); const path = DrawLayer._svgFactory.createElement("path"); @@ -137,8 +135,7 @@ class DrawLayer { path.setAttribute("vector-effect", "non-scaling-stroke"); let maskId; - if (outlines.free) { - root.classList.add("free"); + if (outlines.mustRemoveSelfIntersections) { const mask = DrawLayer._svgFactory.createElement("mask"); defs.append(mask); maskId = `mask_p${this.pageIndex}_${id}`; @@ -188,11 +185,6 @@ class DrawLayer { path.setAttribute("d", line.toSVGPath()); } - removeFreeHighlight(id) { - this.remove(id); - this.#toUpdate.delete(id); - } - updatePath(id, line) { this.#toUpdate.get(id).setAttribute("d", line.toSVGPath()); } @@ -230,6 +222,7 @@ class DrawLayer { } remove(id) { + this.#toUpdate.delete(id); if (this.#parent === null) { return; } @@ -243,6 +236,7 @@ class DrawLayer { root.remove(); } this.#mapping.clear(); + this.#toUpdate.clear(); } } diff --git a/src/display/editor/outliner.js b/src/display/editor/drawers/freedraw.js similarity index 56% rename from src/display/editor/outliner.js rename to src/display/editor/drawers/freedraw.js index 8d52eeeac0ee5..38ded7ff81e7f 100644 --- a/src/display/editor/outliner.js +++ b/src/display/editor/drawers/freedraw.js @@ -1,4 +1,4 @@ -/* Copyright 2023 Mozilla Foundation +/* Copyright 2024 Mozilla Foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,337 +13,10 @@ * limitations under the License. */ -import { Util } from "../../shared/util.js"; +import { Outline } from "./outline.js"; +import { Util } from "../../../shared/util.js"; -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 new HighlightOutline(outlines, 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; - } -} - -class Outline { - /** - * @returns {string} The SVG path of the outline. - */ - toSVGPath() { - throw new Error("Abstract method `toSVGPath` must be implemented."); - } - - /** - * @type {Object|null} The bounding box of the outline. - */ - get box() { - throw new Error("Abstract getter `box` must be implemented."); - } - - serialize(_bbox, _rotation) { - throw new Error("Abstract method `serialize` must be implemented."); - } - - get free() { - return this instanceof FreeHighlightOutline; - } -} - -class HighlightOutline extends Outline { - #box; - - #outlines; - - constructor(outlines, box) { - super(); - this.#outlines = outlines; - this.#box = box; - } - - toSVGPath() { - const buffer = []; - for (const polygon of this.#outlines) { - 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(" "); - } - - /** - * Serialize the outlines into the PDF page coordinate system. - * @param {Array} _bbox - the bounding box of the annotation. - * @param {number} _rotation - the rotation of the annotation. - * @returns {Array>} - */ - serialize([blX, blY, trX, trY], _rotation) { - const outlines = []; - const width = trX - blX; - const height = trY - blY; - for (const outline of this.#outlines) { - const points = new Array(outline.length); - for (let i = 0; i < outline.length; i += 2) { - points[i] = blX + outline[i] * width; - points[i + 1] = trY - outline[i + 1] * height; - } - outlines.push(points); - } - return outlines; - } - - get box() { - return this.#box; - } -} - -class FreeOutliner { +class FreeDrawOutliner { #box; #bottom = []; @@ -381,7 +54,7 @@ class FreeOutliner { static #MIN_DIFF = 2; - static #MIN = FreeOutliner.#MIN_DIST + FreeOutliner.#MIN_DIFF; + static #MIN = FreeDrawOutliner.#MIN_DIST + FreeDrawOutliner.#MIN_DIFF; constructor({ x, y }, box, scaleFactor, thickness, isLTR, innerMargin = 0) { this.#box = box; @@ -389,16 +62,12 @@ class FreeOutliner { this.#isLTR = isLTR; this.#last.set([NaN, NaN, NaN, NaN, x, y], 6); this.#innerMargin = innerMargin; - this.#min_dist = FreeOutliner.#MIN_DIST * scaleFactor; - this.#min = FreeOutliner.#MIN * scaleFactor; + this.#min_dist = FreeDrawOutliner.#MIN_DIST * scaleFactor; + this.#min = FreeDrawOutliner.#MIN * scaleFactor; this.#scaleFactor = scaleFactor; this.#points.push(x, y); } - get free() { - return true; - } - isEmpty() { // When we add a second point then this.#last.slice(6) will be something // like [NaN, NaN, firstX, firstY, secondX, secondY,...] so having a NaN @@ -544,21 +213,10 @@ class FreeOutliner { } const top = this.#top; const bottom = this.#bottom; - const lastTop = this.#last.subarray(4, 6); - const lastBottom = this.#last.subarray(16, 18); - const [x, y, width, height] = this.#box; - const [lastTopX, lastTopY, lastBottomX, lastBottomY] = - this.#getLastCoords(); if (isNaN(this.#last[6]) && !this.isEmpty()) { // We've only two points. - return `M${(this.#last[2] - x) / width} ${ - (this.#last[3] - y) / height - } L${(this.#last[4] - x) / width} ${(this.#last[5] - y) / height} L${lastTopX} ${lastTopY} L${lastBottomX} ${lastBottomY} L${ - (this.#last[16] - x) / width - } ${(this.#last[17] - y) / height} L${(this.#last[14] - x) / width} ${ - (this.#last[15] - y) / height - } Z`; + return this.#toSVGPathTwoPoints(); } const buffer = []; @@ -575,11 +233,8 @@ class FreeOutliner { } } - buffer.push( - `L${(lastTop[0] - x) / width} ${(lastTop[1] - y) / height} L${lastTopX} ${lastTopY} L${lastBottomX} ${lastBottomY} L${ - (lastBottom[0] - x) / width - } ${(lastBottom[1] - y) / height}` - ); + this.#toSVGPathEnd(buffer); + for (let i = bottom.length - 6; i >= 6; i -= 6) { if (isNaN(bottom[i])) { buffer.push(`L${bottom[i + 4]} ${bottom[i + 5]}`); @@ -591,17 +246,60 @@ class FreeOutliner { ); } } - buffer.push(`L${bottom[4]} ${bottom[5]} Z`); + + this.#toSVGPathStart(buffer); return buffer.join(" "); } + #toSVGPathTwoPoints() { + const [x, y, width, height] = this.#box; + const [lastTopX, lastTopY, lastBottomX, lastBottomY] = + this.#getLastCoords(); + + return `M${(this.#last[2] - x) / width} ${ + (this.#last[3] - y) / height + } L${(this.#last[4] - x) / width} ${(this.#last[5] - y) / height} L${lastTopX} ${lastTopY} L${lastBottomX} ${lastBottomY} L${ + (this.#last[16] - x) / width + } ${(this.#last[17] - y) / height} L${(this.#last[14] - x) / width} ${ + (this.#last[15] - y) / height + } Z`; + } + + #toSVGPathStart(buffer) { + const bottom = this.#bottom; + buffer.push(`L${bottom[4]} ${bottom[5]} Z`); + } + + #toSVGPathEnd(buffer) { + const [x, y, width, height] = this.#box; + const lastTop = this.#last.subarray(4, 6); + const lastBottom = this.#last.subarray(16, 18); + const [lastTopX, lastTopY, lastBottomX, lastBottomY] = + this.#getLastCoords(); + + buffer.push( + `L${(lastTop[0] - x) / width} ${(lastTop[1] - y) / height} L${lastTopX} ${lastTopY} L${lastBottomX} ${lastBottomY} L${ + (lastBottom[0] - x) / width + } ${(lastBottom[1] - y) / height}` + ); + } + + newFreeDrawOutline(outline, points, box, scaleFactor, innerMargin, isLTR) { + return new FreeDrawOutline( + outline, + points, + box, + scaleFactor, + innerMargin, + isLTR + ); + } + getOutlines() { const top = this.#top; const bottom = this.#bottom; const last = this.#last; - const lastTop = last.subarray(4, 6); - const lastBottom = last.subarray(16, 18); const [layerX, layerY, layerWidth, layerHeight] = this.#box; const points = new Float64Array((this.#points?.length ?? 0) + 2); @@ -611,61 +309,10 @@ class FreeOutliner { } points[points.length - 2] = (this.#lastX - layerX) / layerWidth; points[points.length - 1] = (this.#lastY - layerY) / layerHeight; - const [lastTopX, lastTopY, lastBottomX, lastBottomY] = - this.#getLastCoords(); if (isNaN(last[6]) && !this.isEmpty()) { // We've only two points. - const outline = new Float64Array(36); - outline.set( - [ - NaN, - NaN, - NaN, - NaN, - (last[2] - layerX) / layerWidth, - (last[3] - layerY) / layerHeight, - NaN, - NaN, - NaN, - NaN, - (last[4] - layerX) / layerWidth, - (last[5] - layerY) / layerHeight, - NaN, - NaN, - NaN, - NaN, - lastTopX, - lastTopY, - NaN, - NaN, - NaN, - NaN, - lastBottomX, - lastBottomY, - NaN, - NaN, - NaN, - NaN, - (last[16] - layerX) / layerWidth, - (last[17] - layerY) / layerHeight, - NaN, - NaN, - NaN, - NaN, - (last[14] - layerX) / layerWidth, - (last[15] - layerY) / layerHeight, - ], - 0 - ); - return new FreeHighlightOutline( - outline, - points, - this.#box, - this.#scaleFactor, - this.#innerMargin, - this.#isLTR - ); + return this.#getOutlineTwoPoints(points); } const outline = new Float64Array( @@ -681,14 +328,53 @@ class FreeOutliner { outline[i + 1] = top[i + 1]; } + N = this.#getOutlineEnd(outline, N); + + for (let i = bottom.length - 6; i >= 6; i -= 6) { + for (let j = 0; j < 6; j += 2) { + if (isNaN(bottom[i + j])) { + outline[N] = outline[N + 1] = NaN; + N += 2; + continue; + } + outline[N] = bottom[i + j]; + outline[N + 1] = bottom[i + j + 1]; + N += 2; + } + } + + this.#getOutlineStart(outline, N); + + return this.newFreeDrawOutline( + outline, + points, + this.#box, + this.#scaleFactor, + this.#innerMargin, + this.#isLTR + ); + } + + #getOutlineTwoPoints(points) { + const last = this.#last; + const [layerX, layerY, layerWidth, layerHeight] = this.#box; + const [lastTopX, lastTopY, lastBottomX, lastBottomY] = + this.#getLastCoords(); + const outline = new Float64Array(36); outline.set( [ NaN, NaN, NaN, NaN, - (lastTop[0] - layerX) / layerWidth, - (lastTop[1] - layerY) / layerHeight, + (last[2] - layerX) / layerWidth, + (last[3] - layerY) / layerHeight, + NaN, + NaN, + NaN, + NaN, + (last[4] - layerX) / layerWidth, + (last[5] - layerY) / layerHeight, NaN, NaN, NaN, @@ -705,27 +391,18 @@ class FreeOutliner { NaN, NaN, NaN, - (lastBottom[0] - layerX) / layerWidth, - (lastBottom[1] - layerY) / layerHeight, + (last[16] - layerX) / layerWidth, + (last[17] - layerY) / layerHeight, + NaN, + NaN, + NaN, + NaN, + (last[14] - layerX) / layerWidth, + (last[15] - layerY) / layerHeight, ], - N + 0 ); - N += 24; - - for (let i = bottom.length - 6; i >= 6; i -= 6) { - for (let j = 0; j < 6; j += 2) { - if (isNaN(bottom[i + j])) { - outline[N] = outline[N + 1] = NaN; - N += 2; - continue; - } - outline[N] = bottom[i + j]; - outline[N + 1] = bottom[i + j + 1]; - N += 2; - } - } - outline.set([NaN, NaN, NaN, NaN, bottom[4], bottom[5]], N); - return new FreeHighlightOutline( + return this.newFreeDrawOutline( outline, points, this.#box, @@ -734,9 +411,53 @@ class FreeOutliner { this.#isLTR ); } + + #getOutlineStart(outline, pos) { + const bottom = this.#bottom; + outline.set([NaN, NaN, NaN, NaN, bottom[4], bottom[5]], pos); + return (pos += 6); + } + + #getOutlineEnd(outline, pos) { + const lastTop = this.#last.subarray(4, 6); + const lastBottom = this.#last.subarray(16, 18); + const [layerX, layerY, layerWidth, layerHeight] = this.#box; + const [lastTopX, lastTopY, lastBottomX, lastBottomY] = + this.#getLastCoords(); + outline.set( + [ + NaN, + NaN, + NaN, + NaN, + (lastTop[0] - layerX) / layerWidth, + (lastTop[1] - layerY) / layerHeight, + NaN, + NaN, + NaN, + NaN, + lastTopX, + lastTopY, + NaN, + NaN, + NaN, + NaN, + lastBottomX, + lastBottomY, + NaN, + NaN, + NaN, + NaN, + (lastBottom[0] - layerX) / layerWidth, + (lastBottom[1] - layerY) / layerHeight, + ], + pos + ); + return (pos += 24); + } } -class FreeHighlightOutline extends Outline { +class FreeDrawOutline extends Outline { #box; #bbox = null; @@ -895,6 +616,17 @@ class FreeHighlightOutline extends Outline { return this.#bbox; } + newOutliner(point, box, scaleFactor, thickness, isLTR, innerMargin = 0) { + return new FreeDrawOutliner( + point, + box, + scaleFactor, + thickness, + isLTR, + innerMargin + ); + } + getNewOutline(thickness, innerMargin) { // Build the outline of the highlight to use as the focus outline. const { x, y, width, height } = this.#bbox; @@ -903,7 +635,7 @@ class FreeHighlightOutline extends Outline { const sy = height * layerHeight; const tx = x * layerWidth + layerX; const ty = y * layerHeight + layerY; - const outliner = new FreeOutliner( + const outliner = this.newOutliner( { x: this.#points[0] * sx + tx, y: this.#points[1] * sy + ty, @@ -922,6 +654,10 @@ class FreeHighlightOutline extends Outline { } return outliner.getOutlines(); } + + get mustRemoveSelfIntersections() { + return true; + } } -export { FreeOutliner, Outliner }; +export { FreeDrawOutline, FreeDrawOutliner }; diff --git a/src/display/editor/drawers/highlight.js b/src/display/editor/drawers/highlight.js new file mode 100644 index 0000000000000..2e01e5c274219 --- /dev/null +++ b/src/display/editor/drawers/highlight.js @@ -0,0 +1,369 @@ +/* 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 { FreeDrawOutline, FreeDrawOutliner } from "./freedraw.js"; +import { Outline } from "./outline.js"; + +class HighlightOutliner { + #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 new HighlightOutline(outlines, 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; + } +} + +class HighlightOutline extends Outline { + #box; + + #outlines; + + constructor(outlines, box) { + super(); + this.#outlines = outlines; + this.#box = box; + } + + toSVGPath() { + const buffer = []; + for (const polygon of this.#outlines) { + 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(" "); + } + + /** + * Serialize the outlines into the PDF page coordinate system. + * @param {Array} _bbox - the bounding box of the annotation. + * @param {number} _rotation - the rotation of the annotation. + * @returns {Array>} + */ + serialize([blX, blY, trX, trY], _rotation) { + const outlines = []; + const width = trX - blX; + const height = trY - blY; + for (const outline of this.#outlines) { + const points = new Array(outline.length); + for (let i = 0; i < outline.length; i += 2) { + points[i] = blX + outline[i] * width; + points[i + 1] = trY - outline[i + 1] * height; + } + outlines.push(points); + } + return outlines; + } + + get box() { + return this.#box; + } + + get classNamesForDrawing() { + return ["highlight"]; + } + + get classNamesForOutlining() { + return ["highlightOutline"]; + } +} + +class FreeHighlightOutliner extends FreeDrawOutliner { + newFreeDrawOutline(outline, points, box, scaleFactor, innerMargin, isLTR) { + return new FreeHighlightOutline( + outline, + points, + box, + scaleFactor, + innerMargin, + isLTR + ); + } + + get classNamesForDrawing() { + return ["highlight", "free"]; + } +} + +class FreeHighlightOutline extends FreeDrawOutline { + get classNamesForDrawing() { + return ["highlight", "free"]; + } + + get classNamesForOutlining() { + return ["highlightOutline", "free"]; + } + + newOutliner(point, box, scaleFactor, thickness, isLTR, innerMargin = 0) { + return new FreeHighlightOutliner( + point, + box, + scaleFactor, + thickness, + isLTR, + innerMargin + ); + } +} + +export { FreeHighlightOutliner, HighlightOutliner }; diff --git a/src/display/editor/drawers/outline.js b/src/display/editor/drawers/outline.js new file mode 100644 index 0000000000000..d272526f3a553 --- /dev/null +++ b/src/display/editor/drawers/outline.js @@ -0,0 +1,55 @@ +/* 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 { unreachable } from "../../../shared/util.js"; + +class Outline { + /** + * @returns {string} The SVG path of the outline. + */ + toSVGPath() { + unreachable("Abstract method `toSVGPath` must be implemented."); + } + + /** + * @type {Object|null} The bounding box of the outline. + */ + // eslint-disable-next-line getter-return + get box() { + unreachable("Abstract getter `box` must be implemented."); + } + + serialize(_bbox, _rotation) { + unreachable("Abstract method `serialize` must be implemented."); + } + + // eslint-disable-next-line getter-return + get classNamesForDrawing() { + unreachable("Abstract getter `classNamesForDrawing` must be implemented."); + } + + // eslint-disable-next-line getter-return + get classNamesForOutlining() { + unreachable( + "Abstract getter `classNamesForOutlining` must be implemented." + ); + } + + get mustRemoveSelfIntersections() { + return false; + } +} + +export { Outline }; diff --git a/src/display/editor/highlight.js b/src/display/editor/highlight.js index a8ef49a8bae6f..345b6b0d43cc9 100644 --- a/src/display/editor/highlight.js +++ b/src/display/editor/highlight.js @@ -20,7 +20,10 @@ import { Util, } from "../../shared/util.js"; import { bindEvents, KeyboardManager } from "./tools.js"; -import { FreeOutliner, Outliner } from "./outliner.js"; +import { + FreeHighlightOutliner, + HighlightOutliner, +} from "./drawers/highlight.js"; import { HighlightAnnotationElement, InkAnnotationElement, @@ -149,7 +152,10 @@ class HighlightEditor extends AnnotationEditor { } #createOutlines() { - const outliner = new Outliner(this.#boxes, /* borderWidth = */ 0.001); + const outliner = new HighlightOutliner( + this.#boxes, + /* borderWidth = */ 0.001 + ); this.#highlightOutlines = outliner.getOutlines(); ({ x: this.x, @@ -158,7 +164,7 @@ class HighlightEditor extends AnnotationEditor { height: this.height, } = this.#highlightOutlines.box); - const outlinerForOutline = new Outliner( + const outlinerForOutline = new HighlightOutliner( this.#boxes, /* borderWidth = */ 0.0025, /* innerMargin = */ 0.001, @@ -190,9 +196,7 @@ class HighlightEditor extends AnnotationEditor { // We need to redraw the highlight because we change the coordinates to be // in the box coordinate system. this.parent.drawLayer.finalizeLine(highlightId, highlightOutlines); - this.#outlineId = this.parent.drawLayer.highlightOutline( - this.#focusOutlines - ); + this.#outlineId = this.parent.drawLayer.drawOutline(this.#focusOutlines); } else if (this.parent) { const angle = this.parent.viewport.rotation; this.parent.drawLayer.updateLine(this.#id, highlightOutlines); @@ -498,13 +502,12 @@ class HighlightEditor extends AnnotationEditor { if (this.#id !== null) { return; } - ({ id: this.#id, clipPathId: this.#clipPathId } = - parent.drawLayer.highlight( - this.#highlightOutlines, - this.color, - this.#opacity - )); - this.#outlineId = parent.drawLayer.highlightOutline(this.#focusOutlines); + ({ id: this.#id, clipPathId: this.#clipPathId } = parent.drawLayer.draw( + this.#highlightOutlines, + this.color, + this.#opacity + )); + this.#outlineId = parent.drawLayer.drawOutline(this.#focusOutlines); if (this.#highlightDiv) { this.#highlightDiv.style.clipPath = this.#clipPathId; } @@ -742,7 +745,7 @@ class HighlightEditor extends AnnotationEditor { this.#highlightMove.bind(this, parent), { signal } ); - this._freeHighlight = new FreeOutliner( + this._freeHighlight = new FreeHighlightOutliner( { x, y }, [layerX, layerY, parentWidth, parentHeight], parent.scale, @@ -751,7 +754,7 @@ class HighlightEditor extends AnnotationEditor { /* innerMargin = */ 0.001 ); ({ id: this._freeHighlightId, clipPathId: this._freeHighlightClipId } = - parent.drawLayer.highlight( + parent.drawLayer.draw( this._freeHighlight, this._defaultColor, this._defaultOpacity, @@ -775,7 +778,7 @@ class HighlightEditor extends AnnotationEditor { methodOfCreation: "main_toolbar", }); } else { - parent.drawLayer.removeFreeHighlight(this._freeHighlightId); + parent.drawLayer.remove(this._freeHighlightId); } this._freeHighlightId = -1; this._freeHighlight = null; @@ -869,7 +872,7 @@ class HighlightEditor extends AnnotationEditor { x: points[0] - pageX, y: pageHeight - (points[1] - pageY), }; - const outliner = new FreeOutliner( + const outliner = new FreeHighlightOutliner( point, [0, 0, pageWidth, pageHeight], 1, @@ -882,7 +885,7 @@ class HighlightEditor extends AnnotationEditor { point.y = pageHeight - (points[i + 1] - pageY); outliner.add(point); } - const { id, clipPathId } = parent.drawLayer.highlight( + const { id, clipPathId } = parent.drawLayer.draw( outliner, editor.color, editor._defaultOpacity, diff --git a/src/pdf.js b/src/pdf.js index c78d27e14524d..1267cf9c3ff2e 100644 --- a/src/pdf.js +++ b/src/pdf.js @@ -69,7 +69,7 @@ import { AnnotationLayer } from "./display/annotation_layer.js"; import { ColorPicker } from "./display/editor/color_picker.js"; import { DrawLayer } from "./display/draw_layer.js"; import { GlobalWorkerOptions } from "./display/worker_options.js"; -import { Outliner } from "./display/editor/outliner.js"; +import { HighlightOutliner } from "./display/editor/drawers/highlight.js"; import { TextLayer } from "./display/text_layer.js"; import { XfaLayer } from "./display/xfa_layer.js"; @@ -82,7 +82,7 @@ const pdfjsBuild = if (typeof PDFJSDev !== "undefined" && PDFJSDev.test("TESTING")) { globalThis.pdfjsTestingUtils = { - Outliner, + HighlightOutliner, }; } diff --git a/test/driver.js b/test/driver.js index 8ba30b0bfd3ce..cb27734934b5f 100644 --- a/test/driver.js +++ b/test/driver.js @@ -25,7 +25,7 @@ const { TextLayer, XfaLayer, } = pdfjsLib; -const { Outliner } = pdfjsTestingUtils; +const { HighlightOutliner } = pdfjsTestingUtils; const { GenericL10n, parseQueryString, SimpleLinkService } = pdfjsViewer; const WAITING_TIME = 100; // ms @@ -370,19 +370,19 @@ class Rasterize { } // 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); + const outliner = new HighlightOutliner(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( + const outlinerForOutline = new HighlightOutliner( 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()); + drawLayer.draw(outliner.getOutlines(), "orange", 0.4); + drawLayer.drawOutline(outlinerForOutline.getOutlines()); svg.append(foreignObject);