[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.
This commit is contained in:
parent
d8424a43ba
commit
31d9b9f574
201
src/display/draw_layer.js
Normal file
201
src/display/draw_layer.js
Normal file
@ -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 };
|
262
src/display/editor/outliner.js
Normal file
262
src/display/editor/outliner.js
Normal file
@ -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<Object>} 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 };
|
@ -70,7 +70,9 @@ import { renderTextLayer, updateTextLayer } from "./display/text_layer.js";
|
|||||||
import { AnnotationEditorLayer } from "./display/editor/annotation_editor_layer.js";
|
import { AnnotationEditorLayer } from "./display/editor/annotation_editor_layer.js";
|
||||||
import { AnnotationEditorUIManager } from "./display/editor/tools.js";
|
import { AnnotationEditorUIManager } from "./display/editor/tools.js";
|
||||||
import { AnnotationLayer } from "./display/annotation_layer.js";
|
import { AnnotationLayer } from "./display/annotation_layer.js";
|
||||||
|
import { DrawLayer } from "./display/draw_layer.js";
|
||||||
import { GlobalWorkerOptions } from "./display/worker_options.js";
|
import { GlobalWorkerOptions } from "./display/worker_options.js";
|
||||||
|
import { Outliner } from "./display/editor/outliner.js";
|
||||||
import { XfaLayer } from "./display/xfa_layer.js";
|
import { XfaLayer } from "./display/xfa_layer.js";
|
||||||
|
|
||||||
/* eslint-disable-next-line no-unused-vars */
|
/* eslint-disable-next-line no-unused-vars */
|
||||||
@ -92,6 +94,7 @@ export {
|
|||||||
CMapCompressionType,
|
CMapCompressionType,
|
||||||
createValidAbsoluteUrl,
|
createValidAbsoluteUrl,
|
||||||
DOMSVGFactory,
|
DOMSVGFactory,
|
||||||
|
DrawLayer,
|
||||||
FeatureTest,
|
FeatureTest,
|
||||||
fetchData,
|
fetchData,
|
||||||
getDocument,
|
getDocument,
|
||||||
@ -107,6 +110,7 @@ export {
|
|||||||
noContextMenu,
|
noContextMenu,
|
||||||
normalizeUnicode,
|
normalizeUnicode,
|
||||||
OPS,
|
OPS,
|
||||||
|
Outliner,
|
||||||
PasswordResponses,
|
PasswordResponses,
|
||||||
PDFDataRangeTransport,
|
PDFDataRangeTransport,
|
||||||
PDFDateString,
|
PDFDateString,
|
||||||
|
39
test/draw_layer_test.css
Normal file
39
test/draw_layer_test.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
132
test/driver.js
132
test/driver.js
@ -17,8 +17,10 @@
|
|||||||
const {
|
const {
|
||||||
AnnotationLayer,
|
AnnotationLayer,
|
||||||
AnnotationMode,
|
AnnotationMode,
|
||||||
|
DrawLayer,
|
||||||
getDocument,
|
getDocument,
|
||||||
GlobalWorkerOptions,
|
GlobalWorkerOptions,
|
||||||
|
Outliner,
|
||||||
PixelsPerInch,
|
PixelsPerInch,
|
||||||
PromiseCapability,
|
PromiseCapability,
|
||||||
renderTextLayer,
|
renderTextLayer,
|
||||||
@ -181,6 +183,11 @@ class Rasterize {
|
|||||||
return shadow(this, "textStylePromise", loadStyles(styles));
|
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() {
|
static get xfaStylePromise() {
|
||||||
const styles = [VIEWER_CSS, "./xfa_layer_builder_overrides.css"];
|
const styles = [VIEWER_CSS, "./xfa_layer_builder_overrides.css"];
|
||||||
return shadow(this, "xfaStylePromise", loadStyles(styles));
|
return shadow(this, "xfaStylePromise", loadStyles(styles));
|
||||||
@ -292,6 +299,7 @@ class Rasterize {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await task.promise;
|
await task.promise;
|
||||||
|
|
||||||
svg.append(foreignObject);
|
svg.append(foreignObject);
|
||||||
|
|
||||||
await writeSVG(svg, ctx);
|
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(
|
static async xfaLayer(
|
||||||
ctx,
|
ctx,
|
||||||
viewport,
|
viewport,
|
||||||
@ -737,7 +832,7 @@ class Driver {
|
|||||||
|
|
||||||
let textLayerCanvas, annotationLayerCanvas, annotationLayerContext;
|
let textLayerCanvas, annotationLayerCanvas, annotationLayerContext;
|
||||||
let initPromise;
|
let initPromise;
|
||||||
if (task.type === "text") {
|
if (task.type === "text" || task.type === "highlight") {
|
||||||
// Using a dummy canvas for PDF context drawing operations
|
// Using a dummy canvas for PDF context drawing operations
|
||||||
textLayerCanvas = this.textLayerCanvas;
|
textLayerCanvas = this.textLayerCanvas;
|
||||||
if (!textLayerCanvas) {
|
if (!textLayerCanvas) {
|
||||||
@ -761,11 +856,17 @@ class Driver {
|
|||||||
disableNormalization: true,
|
disableNormalization: true,
|
||||||
})
|
})
|
||||||
.then(function (textContent) {
|
.then(function (textContent) {
|
||||||
return Rasterize.textLayer(
|
return task.type === "text"
|
||||||
textLayerContext,
|
? Rasterize.textLayer(
|
||||||
viewport,
|
textLayerContext,
|
||||||
textContent
|
viewport,
|
||||||
);
|
textContent
|
||||||
|
)
|
||||||
|
: Rasterize.highlightLayer(
|
||||||
|
textLayerContext,
|
||||||
|
viewport,
|
||||||
|
textContent
|
||||||
|
);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
textLayerCanvas = null;
|
textLayerCanvas = null;
|
||||||
@ -840,12 +941,19 @@ class Driver {
|
|||||||
const completeRender = error => {
|
const completeRender = error => {
|
||||||
// if text layer is present, compose it on top of the page
|
// if text layer is present, compose it on top of the page
|
||||||
if (textLayerCanvas) {
|
if (textLayerCanvas) {
|
||||||
ctx.save();
|
if (task.type === "text") {
|
||||||
ctx.globalCompositeOperation = "screen";
|
ctx.save();
|
||||||
ctx.fillStyle = "rgb(128, 255, 128)"; // making it green
|
ctx.globalCompositeOperation = "screen";
|
||||||
ctx.fillRect(0, 0, pixelWidth, pixelHeight);
|
ctx.fillStyle = "rgb(128, 255, 128)"; // making it green
|
||||||
ctx.restore();
|
ctx.fillRect(0, 0, pixelWidth, pixelHeight);
|
||||||
ctx.drawImage(textLayerCanvas, 0, 0);
|
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 we have annotation layer, compose it on top of the page.
|
||||||
if (annotationLayerCanvas) {
|
if (annotationLayerCanvas) {
|
||||||
|
@ -670,6 +670,7 @@ function checkRefTestResults(browser, id, results) {
|
|||||||
switch (task.type) {
|
switch (task.type) {
|
||||||
case "eq":
|
case "eq":
|
||||||
case "text":
|
case "text":
|
||||||
|
case "highlight":
|
||||||
checkEq(task, results, browser, session.masterMode);
|
checkEq(task, results, browser, session.masterMode);
|
||||||
break;
|
break;
|
||||||
case "fbf":
|
case "fbf":
|
||||||
|
@ -8214,5 +8214,19 @@
|
|||||||
"rounds": 1,
|
"rounds": 1,
|
||||||
"link": true,
|
"link": true,
|
||||||
"type": "eq"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -63,7 +63,9 @@ import {
|
|||||||
import { AnnotationEditorLayer } from "../../src/display/editor/annotation_editor_layer.js";
|
import { AnnotationEditorLayer } from "../../src/display/editor/annotation_editor_layer.js";
|
||||||
import { AnnotationEditorUIManager } from "../../src/display/editor/tools.js";
|
import { AnnotationEditorUIManager } from "../../src/display/editor/tools.js";
|
||||||
import { AnnotationLayer } from "../../src/display/annotation_layer.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 { GlobalWorkerOptions } from "../../src/display/worker_options.js";
|
||||||
|
import { Outliner } from "../../src/display/editor/outliner.js";
|
||||||
import { XfaLayer } from "../../src/display/xfa_layer.js";
|
import { XfaLayer } from "../../src/display/xfa_layer.js";
|
||||||
|
|
||||||
const expectedAPI = Object.freeze({
|
const expectedAPI = Object.freeze({
|
||||||
@ -78,6 +80,7 @@ const expectedAPI = Object.freeze({
|
|||||||
CMapCompressionType,
|
CMapCompressionType,
|
||||||
createValidAbsoluteUrl,
|
createValidAbsoluteUrl,
|
||||||
DOMSVGFactory,
|
DOMSVGFactory,
|
||||||
|
DrawLayer,
|
||||||
FeatureTest,
|
FeatureTest,
|
||||||
fetchData,
|
fetchData,
|
||||||
getDocument,
|
getDocument,
|
||||||
@ -93,6 +96,7 @@ const expectedAPI = Object.freeze({
|
|||||||
noContextMenu,
|
noContextMenu,
|
||||||
normalizeUnicode,
|
normalizeUnicode,
|
||||||
OPS,
|
OPS,
|
||||||
|
Outliner,
|
||||||
PasswordResponses,
|
PasswordResponses,
|
||||||
PDFDataRangeTransport,
|
PDFDataRangeTransport,
|
||||||
PDFDateString,
|
PDFDateString,
|
||||||
|
70
web/draw_layer_builder.css
Normal file
70
web/draw_layer_builder.css
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
58
web/draw_layer_builder.js
Normal file
58
web/draw_layer_builder.js
Normal file
@ -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 };
|
@ -34,6 +34,7 @@ const {
|
|||||||
CMapCompressionType,
|
CMapCompressionType,
|
||||||
createValidAbsoluteUrl,
|
createValidAbsoluteUrl,
|
||||||
DOMSVGFactory,
|
DOMSVGFactory,
|
||||||
|
DrawLayer,
|
||||||
FeatureTest,
|
FeatureTest,
|
||||||
fetchData,
|
fetchData,
|
||||||
getDocument,
|
getDocument,
|
||||||
@ -49,6 +50,7 @@ const {
|
|||||||
noContextMenu,
|
noContextMenu,
|
||||||
normalizeUnicode,
|
normalizeUnicode,
|
||||||
OPS,
|
OPS,
|
||||||
|
Outliner,
|
||||||
PasswordResponses,
|
PasswordResponses,
|
||||||
PDFDataRangeTransport,
|
PDFDataRangeTransport,
|
||||||
PDFDateString,
|
PDFDateString,
|
||||||
@ -80,6 +82,7 @@ export {
|
|||||||
CMapCompressionType,
|
CMapCompressionType,
|
||||||
createValidAbsoluteUrl,
|
createValidAbsoluteUrl,
|
||||||
DOMSVGFactory,
|
DOMSVGFactory,
|
||||||
|
DrawLayer,
|
||||||
FeatureTest,
|
FeatureTest,
|
||||||
fetchData,
|
fetchData,
|
||||||
getDocument,
|
getDocument,
|
||||||
@ -95,6 +98,7 @@ export {
|
|||||||
noContextMenu,
|
noContextMenu,
|
||||||
normalizeUnicode,
|
normalizeUnicode,
|
||||||
OPS,
|
OPS,
|
||||||
|
Outliner,
|
||||||
PasswordResponses,
|
PasswordResponses,
|
||||||
PDFDataRangeTransport,
|
PDFDataRangeTransport,
|
||||||
PDFDateString,
|
PDFDateString,
|
||||||
|
Loading…
Reference in New Issue
Block a user