From 5a9607b2adda2cc55a75ee63b69aa99938bd7e3b Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Mon, 28 Oct 2024 20:29:50 +0100 Subject: [PATCH] [Editor] Refactor the free highlight stuff in order to be able to use the code for more general drawing One goal is to make the code for drawing with the Ink tool similar to the one to free highlighting: it doesn't really make sense to have so different ways to do almost the same thing. When the zoom level is high, it'll avoid to create a too big canvas covering all the page which consume more memory, makes the drawing very slow and the overall user xp pretty bad. A second goal is to be able to easily implement more drawing tools where we would just have to implement how to draw from the pointer coordinates. --- src/display/draw_layer.js | 22 +- .../{outliner.js => drawers/freedraw.js} | 604 +++++------------- src/display/editor/drawers/highlight.js | 369 +++++++++++ src/display/editor/drawers/outline.js | 55 ++ src/display/editor/highlight.js | 39 +- src/pdf.js | 4 +- test/driver.js | 10 +- 7 files changed, 630 insertions(+), 473 deletions(-) rename src/display/editor/{outliner.js => drawers/freedraw.js} (56%) create mode 100644 src/display/editor/drawers/highlight.js create mode 100644 src/display/editor/drawers/outline.js 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);