1
0
Fork 0
mirror of https://github.com/mozilla/pdf.js.git synced 2025-04-20 15:18:08 +02:00

[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:
Calixte Denizet 2023-11-17 18:54:26 +01:00
parent d8424a43ba
commit 31d9b9f574
11 changed files with 777 additions and 12 deletions

39
test/draw_layer_test.css Normal file
View 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;
}
}

View file

@ -17,8 +17,10 @@
const {
AnnotationLayer,
AnnotationMode,
DrawLayer,
getDocument,
GlobalWorkerOptions,
Outliner,
PixelsPerInch,
PromiseCapability,
renderTextLayer,
@ -181,6 +183,11 @@ class Rasterize {
return shadow(this, "textStylePromise", loadStyles(styles));
}
static get drawLayerStylePromise() {
const styles = [VIEWER_CSS, "./draw_layer_test.css"];
return shadow(this, "drawLayerStylePromise", loadStyles(styles));
}
static get xfaStylePromise() {
const styles = [VIEWER_CSS, "./xfa_layer_builder_overrides.css"];
return shadow(this, "xfaStylePromise", loadStyles(styles));
@ -292,6 +299,7 @@ class Rasterize {
});
await task.promise;
svg.append(foreignObject);
await writeSVG(svg, ctx);
@ -300,6 +308,93 @@ class Rasterize {
}
}
static async highlightLayer(ctx, viewport, textContent) {
try {
const { svg, foreignObject, style, div } = this.createContainer(viewport);
const dummyParent = document.createElement("div");
// Items are transformed to have 1px font size.
svg.setAttribute("font-size", 1);
const [common, overrides] = await this.drawLayerStylePromise;
style.textContent =
`${common}\n${overrides}` +
`:root { --scale-factor: ${viewport.scale} }`;
// Rendering text layer as HTML.
const task = renderTextLayer({
textContentSource: textContent,
container: dummyParent,
viewport,
});
await task.promise;
const { _pageWidth, _pageHeight, _textContentSource, _textDivs } = task;
const boxes = [];
let posRegex;
for (
let i = 0, j = 0, ii = _textContentSource.items.length;
i < ii;
i++
) {
const { width, height, type } = _textContentSource.items[i];
if (type) {
continue;
}
const { top, left } = _textDivs[j++].style;
let x = parseFloat(left) / 100;
let y = parseFloat(top) / 100;
if (isNaN(x)) {
posRegex ||= /^calc\(var\(--scale-factor\)\*(.*)px\)$/;
// The element is tagged so we've to extract the position from the
// string, e.g. `calc(var(--scale-factor)*66.32px)`.
let match = left.match(posRegex);
if (match) {
x = parseFloat(match[1]) / _pageWidth;
}
match = top.match(posRegex);
if (match) {
y = parseFloat(match[1]) / _pageHeight;
}
}
if (width === 0 || height === 0) {
continue;
}
boxes.push({
x,
y,
width: width / _pageWidth,
height: height / _pageHeight,
});
}
// We set the borderWidth to 0.001 to slighly increase the size of the
// boxes so that they can be merged together.
const outliner = new Outliner(boxes, /* borderWidth = */ 0.001);
// We set the borderWidth to 0.0025 in order to have an outline which is
// slightly bigger than the highlight itself.
// We must add an inner margin to avoid to have a partial outline.
const outlinerForOutline = new Outliner(
boxes,
/* borderWidth = */ 0.0025,
/* innerMargin = */ 0.001
);
const drawLayer = new DrawLayer({ pageIndex: 0 });
drawLayer.setParent(div);
drawLayer.highlight(outliner.getOutlines(), "orange", 0.4);
drawLayer.highlightOutline(outlinerForOutline.getOutlines());
svg.append(foreignObject);
await writeSVG(svg, ctx);
drawLayer.destroy();
} catch (reason) {
throw new Error(`Rasterize.textLayer: "${reason?.message}".`);
}
}
static async xfaLayer(
ctx,
viewport,
@ -737,7 +832,7 @@ class Driver {
let textLayerCanvas, annotationLayerCanvas, annotationLayerContext;
let initPromise;
if (task.type === "text") {
if (task.type === "text" || task.type === "highlight") {
// Using a dummy canvas for PDF context drawing operations
textLayerCanvas = this.textLayerCanvas;
if (!textLayerCanvas) {
@ -761,11 +856,17 @@ class Driver {
disableNormalization: true,
})
.then(function (textContent) {
return Rasterize.textLayer(
textLayerContext,
viewport,
textContent
);
return task.type === "text"
? Rasterize.textLayer(
textLayerContext,
viewport,
textContent
)
: Rasterize.highlightLayer(
textLayerContext,
viewport,
textContent
);
});
} else {
textLayerCanvas = null;
@ -840,12 +941,19 @@ class Driver {
const completeRender = error => {
// if text layer is present, compose it on top of the page
if (textLayerCanvas) {
ctx.save();
ctx.globalCompositeOperation = "screen";
ctx.fillStyle = "rgb(128, 255, 128)"; // making it green
ctx.fillRect(0, 0, pixelWidth, pixelHeight);
ctx.restore();
ctx.drawImage(textLayerCanvas, 0, 0);
if (task.type === "text") {
ctx.save();
ctx.globalCompositeOperation = "screen";
ctx.fillStyle = "rgb(128, 255, 128)"; // making it green
ctx.fillRect(0, 0, pixelWidth, pixelHeight);
ctx.restore();
ctx.drawImage(textLayerCanvas, 0, 0);
} else if (task.type === "highlight") {
ctx.save();
ctx.globalCompositeOperation = "multiply";
ctx.drawImage(textLayerCanvas, 0, 0);
ctx.restore();
}
}
// If we have annotation layer, compose it on top of the page.
if (annotationLayerCanvas) {

View file

@ -670,6 +670,7 @@ function checkRefTestResults(browser, id, results) {
switch (task.type) {
case "eq":
case "text":
case "highlight":
checkEq(task, results, browser, session.masterMode);
break;
case "fbf":

View file

@ -8214,5 +8214,19 @@
"rounds": 1,
"link": true,
"type": "eq"
},
{
"id": "tracemonkey-highlight",
"file": "pdfs/tracemonkey.pdf",
"md5": "9a192d8b1a7dc652a19835f6f08098bd",
"rounds": 1,
"type": "highlight"
},
{
"id": "160F-2019-highlight",
"file": "pdfs/160F-2019.pdf",
"md5": "71591f11ee717e12887f529c84d5ae89",
"rounds": 1,
"type": "highlight"
}
]

View file

@ -63,7 +63,9 @@ import {
import { AnnotationEditorLayer } from "../../src/display/editor/annotation_editor_layer.js";
import { AnnotationEditorUIManager } from "../../src/display/editor/tools.js";
import { AnnotationLayer } from "../../src/display/annotation_layer.js";
import { DrawLayer } from "../../src/display/draw_layer.js";
import { GlobalWorkerOptions } from "../../src/display/worker_options.js";
import { Outliner } from "../../src/display/editor/outliner.js";
import { XfaLayer } from "../../src/display/xfa_layer.js";
const expectedAPI = Object.freeze({
@ -78,6 +80,7 @@ const expectedAPI = Object.freeze({
CMapCompressionType,
createValidAbsoluteUrl,
DOMSVGFactory,
DrawLayer,
FeatureTest,
fetchData,
getDocument,
@ -93,6 +96,7 @@ const expectedAPI = Object.freeze({
noContextMenu,
normalizeUnicode,
OPS,
Outliner,
PasswordResponses,
PDFDataRangeTransport,
PDFDateString,