Merge pull request #19043 from nicolo-ribaudo/compute-bounding-boxes
Add logic to track rendering area of various PDF ops
This commit is contained in:
commit
673f19bc2b
@ -238,6 +238,11 @@
|
||||
"description": "Enable creation of comment annotations.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"enableOptimizedPartialRendering": {
|
||||
"description": "Enable tracking of PDF operations to optimize partial rendering.",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,6 +60,7 @@ import {
|
||||
NodeStandardFontDataFactory,
|
||||
NodeWasmFactory,
|
||||
} from "display-node_utils";
|
||||
import { CanvasDependencyTracker } from "./canvas_dependency_tracker.js";
|
||||
import { CanvasGraphics } from "./canvas.js";
|
||||
import { DOMCanvasFactory } from "./canvas_factory.js";
|
||||
import { DOMCMapReaderFactory } from "display-cmap_reader_factory";
|
||||
@ -1239,6 +1240,10 @@ class PDFDocumentProxy {
|
||||
* annotation ids with canvases used to render them.
|
||||
* @property {PrintAnnotationStorage} [printAnnotationStorage]
|
||||
* @property {boolean} [isEditing] - Render the page in editing mode.
|
||||
* @property {boolean} [recordOperations] - Record the dependencies and bounding
|
||||
* boxes of all PDF operations that render onto the canvas.
|
||||
* @property {Set<number>} [filteredOperationIndexes] - If provided, only run
|
||||
* the PDF operations that are included in this set.
|
||||
*/
|
||||
|
||||
/**
|
||||
@ -1309,6 +1314,7 @@ class PDFPageProxy {
|
||||
|
||||
this._intentStates = new Map();
|
||||
this.destroyed = false;
|
||||
this.recordedGroups = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1433,6 +1439,8 @@ class PDFPageProxy {
|
||||
pageColors = null,
|
||||
printAnnotationStorage = null,
|
||||
isEditing = false,
|
||||
recordOperations = false,
|
||||
filteredOperationIndexes = null,
|
||||
}) {
|
||||
this._stats?.time("Overall");
|
||||
|
||||
@ -1479,9 +1487,26 @@ class PDFPageProxy {
|
||||
this._pumpOperatorList(intentArgs);
|
||||
}
|
||||
|
||||
const shouldRecordOperations =
|
||||
!this.recordedGroups &&
|
||||
(recordOperations ||
|
||||
(this._pdfBug && globalThis.StepperManager?.enabled));
|
||||
|
||||
const complete = error => {
|
||||
intentState.renderTasks.delete(internalRenderTask);
|
||||
|
||||
if (shouldRecordOperations) {
|
||||
const recordedGroups = internalRenderTask.gfx?.dependencyTracker.take();
|
||||
if (recordedGroups) {
|
||||
internalRenderTask.stepper?.setOperatorGroups(recordedGroups);
|
||||
if (recordOperations) {
|
||||
this.recordedGroups = recordedGroups;
|
||||
}
|
||||
} else if (recordOperations) {
|
||||
this.recordedGroups = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt to reduce memory usage during *printing*, by always running
|
||||
// cleanup immediately once rendering has finished.
|
||||
if (intentPrint) {
|
||||
@ -1516,6 +1541,9 @@ class PDFPageProxy {
|
||||
params: {
|
||||
canvas,
|
||||
canvasContext,
|
||||
dependencyTracker: shouldRecordOperations
|
||||
? new CanvasDependencyTracker(canvas)
|
||||
: null,
|
||||
viewport,
|
||||
transform,
|
||||
background,
|
||||
@ -1531,6 +1559,7 @@ class PDFPageProxy {
|
||||
pdfBug: this._pdfBug,
|
||||
pageColors,
|
||||
enableHWA: this._transport.enableHWA,
|
||||
filteredOperationIndexes,
|
||||
});
|
||||
|
||||
(intentState.renderTasks ||= new Set()).add(internalRenderTask);
|
||||
@ -3140,6 +3169,7 @@ class InternalRenderTask {
|
||||
pdfBug = false,
|
||||
pageColors = null,
|
||||
enableHWA = false,
|
||||
filteredOperationIndexes = null,
|
||||
}) {
|
||||
this.callback = callback;
|
||||
this.params = params;
|
||||
@ -3170,6 +3200,8 @@ class InternalRenderTask {
|
||||
this._canvas = params.canvas;
|
||||
this._canvasContext = params.canvas ? null : params.canvasContext;
|
||||
this._enableHWA = enableHWA;
|
||||
this._dependencyTracker = params.dependencyTracker;
|
||||
this._filteredOperationIndexes = filteredOperationIndexes;
|
||||
}
|
||||
|
||||
get completed() {
|
||||
@ -3199,7 +3231,7 @@ class InternalRenderTask {
|
||||
this.stepper.init(this.operatorList);
|
||||
this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint();
|
||||
}
|
||||
const { viewport, transform, background } = this.params;
|
||||
const { viewport, transform, background, dependencyTracker } = this.params;
|
||||
|
||||
// When printing in Firefox, we get a specific context in mozPrintCallback
|
||||
// which cannot be created from the canvas itself.
|
||||
@ -3218,7 +3250,8 @@ class InternalRenderTask {
|
||||
this.filterFactory,
|
||||
{ optionalContentConfig },
|
||||
this.annotationCanvasMap,
|
||||
this.pageColors
|
||||
this.pageColors,
|
||||
dependencyTracker
|
||||
);
|
||||
this.gfx.beginDrawing({
|
||||
transform,
|
||||
@ -3294,7 +3327,8 @@ class InternalRenderTask {
|
||||
this.operatorList,
|
||||
this.operatorListIdx,
|
||||
this._continueBound,
|
||||
this.stepper
|
||||
this.stepper,
|
||||
this._filteredOperationIndexes
|
||||
);
|
||||
if (this.operatorListIdx === this.operatorList.argsArray.length) {
|
||||
this.running = false;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
775
src/display/canvas_dependency_tracker.js
Normal file
775
src/display/canvas_dependency_tracker.js
Normal file
@ -0,0 +1,775 @@
|
||||
import { Util } from "../shared/util.js";
|
||||
|
||||
const FORCED_DEPENDENCY_LABEL = "__forcedDependency";
|
||||
|
||||
/**
|
||||
* @typedef {"lineWidth" | "lineCap" | "lineJoin" | "miterLimit" | "dash" |
|
||||
* "strokeAlpha" | "fillColor" | "fillAlpha" | "globalCompositeOperation" |
|
||||
* "path" | "filter"} SimpleDependency
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {"transform" | "moveText" | "sameLineText"} IncrementalDependency
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {IncrementalDependency |
|
||||
* typeof FORCED_DEPENDENCY_LABEL} InternalIncrementalDependency
|
||||
*/
|
||||
class CanvasDependencyTracker {
|
||||
/** @type {Record<SimpleDependency, number>} */
|
||||
#simple = { __proto__: null };
|
||||
|
||||
/** @type {Record<InternalIncrementalDependency , number[]>} */
|
||||
#incremental = {
|
||||
__proto__: null,
|
||||
transform: [],
|
||||
moveText: [],
|
||||
sameLineText: [],
|
||||
[FORCED_DEPENDENCY_LABEL]: [],
|
||||
};
|
||||
|
||||
#namedDependencies = new Map();
|
||||
|
||||
#savesStack = [];
|
||||
|
||||
#markedContentStack = [];
|
||||
|
||||
#baseTransformStack = [[1, 0, 0, 1, 0, 0]];
|
||||
|
||||
#clipBox = [-Infinity, -Infinity, Infinity, Infinity];
|
||||
|
||||
// Float32Array<minX, minY, maxX, maxY>
|
||||
#pendingBBox = new Float64Array([Infinity, Infinity, -Infinity, -Infinity]);
|
||||
|
||||
#pendingBBoxIdx = -1;
|
||||
|
||||
#pendingDependencies = new Set();
|
||||
|
||||
#operations = new Map();
|
||||
|
||||
#fontBBoxTrustworthy = new Map();
|
||||
|
||||
#canvasWidth;
|
||||
|
||||
#canvasHeight;
|
||||
|
||||
constructor(canvas) {
|
||||
this.#canvasWidth = canvas.width;
|
||||
this.#canvasHeight = canvas.height;
|
||||
}
|
||||
|
||||
save(opIdx) {
|
||||
this.#simple = { __proto__: this.#simple };
|
||||
this.#incremental = {
|
||||
__proto__: this.#incremental,
|
||||
transform: { __proto__: this.#incremental.transform },
|
||||
moveText: { __proto__: this.#incremental.moveText },
|
||||
sameLineText: { __proto__: this.#incremental.sameLineText },
|
||||
[FORCED_DEPENDENCY_LABEL]: {
|
||||
__proto__: this.#incremental[FORCED_DEPENDENCY_LABEL],
|
||||
},
|
||||
};
|
||||
this.#clipBox = { __proto__: this.#clipBox };
|
||||
this.#savesStack.push([opIdx, null]);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
restore(opIdx) {
|
||||
const previous = Object.getPrototypeOf(this.#simple);
|
||||
if (previous === null) {
|
||||
// Sometimes we call more .restore() than .save(), for
|
||||
// example when using CanvasGraphics' #restoreInitialState()
|
||||
return this;
|
||||
}
|
||||
this.#simple = previous;
|
||||
this.#incremental = Object.getPrototypeOf(this.#incremental);
|
||||
this.#clipBox = Object.getPrototypeOf(this.#clipBox);
|
||||
|
||||
const lastPair = this.#savesStack.pop();
|
||||
if (lastPair !== undefined) {
|
||||
lastPair[1] = opIdx;
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} idx
|
||||
*/
|
||||
recordOpenMarker(idx) {
|
||||
this.#savesStack.push([idx, null]);
|
||||
return this;
|
||||
}
|
||||
|
||||
getOpenMarker() {
|
||||
if (this.#savesStack.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return this.#savesStack.at(-1)[0];
|
||||
}
|
||||
|
||||
recordCloseMarker(idx) {
|
||||
const lastPair = this.#savesStack.pop();
|
||||
if (lastPair !== undefined) {
|
||||
lastPair[1] = idx;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
// Marked content needs a separate stack from save/restore, because they
|
||||
// form two independent trees.
|
||||
beginMarkedContent(opIdx) {
|
||||
this.#markedContentStack.push([opIdx, null]);
|
||||
return this;
|
||||
}
|
||||
|
||||
endMarkedContent(opIdx) {
|
||||
const lastPair = this.#markedContentStack.pop();
|
||||
if (lastPair !== undefined) {
|
||||
lastPair[1] = opIdx;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
pushBaseTransform(ctx) {
|
||||
this.#baseTransformStack.push(
|
||||
Util.multiplyByDOMMatrix(
|
||||
this.#baseTransformStack.at(-1),
|
||||
ctx.getTransform()
|
||||
)
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
popBaseTransform() {
|
||||
if (this.#baseTransformStack.length > 1) {
|
||||
this.#baseTransformStack.pop();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SimpleDependency} name
|
||||
* @param {number} idx
|
||||
*/
|
||||
recordSimpleData(name, idx) {
|
||||
this.#simple[name] = idx;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IncrementalDependency} name
|
||||
* @param {number} idx
|
||||
*/
|
||||
recordIncrementalData(name, idx) {
|
||||
this.#incremental[name].push(idx);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IncrementalDependency} name
|
||||
* @param {number} idx
|
||||
*/
|
||||
resetIncrementalData(name, idx) {
|
||||
this.#incremental[name].length = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
recordNamedData(name, idx) {
|
||||
this.#namedDependencies.set(name, idx);
|
||||
return this;
|
||||
}
|
||||
|
||||
// All next operations, until the next .restore(), will depend on this
|
||||
recordFutureForcedDependency(name, idx) {
|
||||
this.recordIncrementalData(FORCED_DEPENDENCY_LABEL, idx);
|
||||
return this;
|
||||
}
|
||||
|
||||
// All next operations, until the next .restore(), will depend on all
|
||||
// the already recorded data with the given names.
|
||||
inheritSimpleDataAsFutureForcedDependencies(names) {
|
||||
for (const name of names) {
|
||||
if (name in this.#simple) {
|
||||
this.recordFutureForcedDependency(name, this.#simple[name]);
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
inheritPendingDependenciesAsFutureForcedDependencies() {
|
||||
for (const dep of this.#pendingDependencies) {
|
||||
this.recordFutureForcedDependency(FORCED_DEPENDENCY_LABEL, dep);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
resetBBox(idx) {
|
||||
this.#pendingBBoxIdx = idx;
|
||||
this.#pendingBBox[0] = Infinity;
|
||||
this.#pendingBBox[1] = Infinity;
|
||||
this.#pendingBBox[2] = -Infinity;
|
||||
this.#pendingBBox[3] = -Infinity;
|
||||
return this;
|
||||
}
|
||||
|
||||
get hasPendingBBox() {
|
||||
return this.#pendingBBoxIdx !== -1;
|
||||
}
|
||||
|
||||
recordClipBox(idx, ctx, minX, maxX, minY, maxY) {
|
||||
const transform = Util.multiplyByDOMMatrix(
|
||||
this.#baseTransformStack.at(-1),
|
||||
ctx.getTransform()
|
||||
);
|
||||
const clipBox = [Infinity, Infinity, -Infinity, -Infinity];
|
||||
Util.axialAlignedBoundingBox([minX, minY, maxX, maxY], transform, clipBox);
|
||||
const intersection = Util.intersect(this.#clipBox, clipBox);
|
||||
if (intersection) {
|
||||
this.#clipBox[0] = intersection[0];
|
||||
this.#clipBox[1] = intersection[1];
|
||||
this.#clipBox[2] = intersection[2];
|
||||
this.#clipBox[3] = intersection[3];
|
||||
} else {
|
||||
this.#clipBox[0] = this.#clipBox[1] = Infinity;
|
||||
this.#clipBox[2] = this.#clipBox[3] = -Infinity;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
recordBBox(idx, ctx, minX, maxX, minY, maxY) {
|
||||
const clipBox = this.#clipBox;
|
||||
if (clipBox[0] === Infinity) {
|
||||
return this;
|
||||
}
|
||||
|
||||
const transform = Util.multiplyByDOMMatrix(
|
||||
this.#baseTransformStack.at(-1),
|
||||
ctx.getTransform()
|
||||
);
|
||||
if (clipBox[0] === -Infinity) {
|
||||
Util.axialAlignedBoundingBox(
|
||||
[minX, minY, maxX, maxY],
|
||||
transform,
|
||||
this.#pendingBBox
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
const bbox = [Infinity, Infinity, -Infinity, -Infinity];
|
||||
Util.axialAlignedBoundingBox([minX, minY, maxX, maxY], transform, bbox);
|
||||
this.#pendingBBox[0] = Math.min(
|
||||
this.#pendingBBox[0],
|
||||
Math.max(bbox[0], clipBox[0])
|
||||
);
|
||||
this.#pendingBBox[1] = Math.min(
|
||||
this.#pendingBBox[1],
|
||||
Math.max(bbox[1], clipBox[1])
|
||||
);
|
||||
this.#pendingBBox[2] = Math.max(
|
||||
this.#pendingBBox[2],
|
||||
Math.min(bbox[2], clipBox[2])
|
||||
);
|
||||
this.#pendingBBox[3] = Math.max(
|
||||
this.#pendingBBox[3],
|
||||
Math.min(bbox[3], clipBox[3])
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
recordCharacterBBox(idx, ctx, font, scale = 1, x = 0, y = 0, getMeasure) {
|
||||
const fontBBox = font.bbox;
|
||||
let isBBoxTrustworthy;
|
||||
let computedBBox;
|
||||
|
||||
if (fontBBox) {
|
||||
isBBoxTrustworthy =
|
||||
// Only use the bounding box defined by the font if it
|
||||
// has a non-empty area.
|
||||
fontBBox[2] !== fontBBox[0] &&
|
||||
fontBBox[3] !== fontBBox[1] &&
|
||||
this.#fontBBoxTrustworthy.get(font);
|
||||
|
||||
if (isBBoxTrustworthy !== false) {
|
||||
computedBBox = [0, 0, 0, 0];
|
||||
Util.axialAlignedBoundingBox(fontBBox, font.fontMatrix, computedBBox);
|
||||
if (scale !== 1 || x !== 0 || y !== 0) {
|
||||
Util.scaleMinMax([scale, 0, 0, -scale, x, y], computedBBox);
|
||||
}
|
||||
|
||||
if (isBBoxTrustworthy) {
|
||||
return this.recordBBox(
|
||||
idx,
|
||||
ctx,
|
||||
computedBBox[0],
|
||||
computedBBox[2],
|
||||
computedBBox[1],
|
||||
computedBBox[3]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!getMeasure) {
|
||||
// We have no way of telling how big this character actually is, record
|
||||
// a full page bounding box.
|
||||
return this.recordFullPageBBox(idx);
|
||||
}
|
||||
|
||||
const measure = getMeasure();
|
||||
|
||||
if (fontBBox && computedBBox && isBBoxTrustworthy === undefined) {
|
||||
// If it's the first time we can compare the font bbox with the actual
|
||||
// bbox measured when drawing it, check if the one recorded in the font
|
||||
// is large enough to cover the actual bbox. If it is, we assume that the
|
||||
// font is well-formed and we can use the declared bbox without having to
|
||||
// measure it again for every character.
|
||||
isBBoxTrustworthy =
|
||||
computedBBox[0] <= x - measure.actualBoundingBoxLeft &&
|
||||
computedBBox[2] >= x + measure.actualBoundingBoxRight &&
|
||||
computedBBox[1] <= y - measure.actualBoundingBoxAscent &&
|
||||
computedBBox[3] >= y + measure.actualBoundingBoxDescent;
|
||||
this.#fontBBoxTrustworthy.set(font, isBBoxTrustworthy);
|
||||
if (isBBoxTrustworthy) {
|
||||
return this.recordBBox(
|
||||
idx,
|
||||
ctx,
|
||||
computedBBox[0],
|
||||
computedBBox[2],
|
||||
computedBBox[1],
|
||||
computedBBox[3]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// The font has no bbox or it is not trustworthy, so we need to
|
||||
// return the bounding box based on .measureText().
|
||||
return this.recordBBox(
|
||||
idx,
|
||||
ctx,
|
||||
x - measure.actualBoundingBoxLeft,
|
||||
x + measure.actualBoundingBoxRight,
|
||||
y - measure.actualBoundingBoxAscent,
|
||||
y + measure.actualBoundingBoxDescent
|
||||
);
|
||||
}
|
||||
|
||||
recordFullPageBBox(idx) {
|
||||
this.#pendingBBox[0] = Math.max(0, this.#clipBox[0]);
|
||||
this.#pendingBBox[1] = Math.max(0, this.#clipBox[1]);
|
||||
this.#pendingBBox[2] = Math.min(this.#canvasWidth, this.#clipBox[2]);
|
||||
this.#pendingBBox[3] = Math.min(this.#canvasHeight, this.#clipBox[3]);
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getSimpleIndex(dependencyName) {
|
||||
return this.#simple[dependencyName];
|
||||
}
|
||||
|
||||
recordDependencies(idx, dependencyNames) {
|
||||
const pendingDependencies = this.#pendingDependencies;
|
||||
const simple = this.#simple;
|
||||
const incremental = this.#incremental;
|
||||
for (const name of dependencyNames) {
|
||||
if (name in this.#simple) {
|
||||
pendingDependencies.add(simple[name]);
|
||||
} else if (name in incremental) {
|
||||
incremental[name].forEach(pendingDependencies.add, pendingDependencies);
|
||||
}
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
copyDependenciesFromIncrementalOperation(idx, name) {
|
||||
const operations = this.#operations;
|
||||
const pendingDependencies = this.#pendingDependencies;
|
||||
for (const depIdx of this.#incremental[name]) {
|
||||
operations
|
||||
.get(depIdx)
|
||||
.dependencies.forEach(
|
||||
pendingDependencies.add,
|
||||
pendingDependencies.add(depIdx)
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
recordNamedDependency(idx, name) {
|
||||
if (this.#namedDependencies.has(name)) {
|
||||
this.#pendingDependencies.add(this.#namedDependencies.get(name));
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} idx
|
||||
*/
|
||||
recordOperation(idx, preserveBbox = false) {
|
||||
this.recordDependencies(idx, [FORCED_DEPENDENCY_LABEL]);
|
||||
const dependencies = new Set(this.#pendingDependencies);
|
||||
const pairs = this.#savesStack.concat(this.#markedContentStack);
|
||||
const bbox =
|
||||
this.#pendingBBoxIdx === idx
|
||||
? {
|
||||
minX: this.#pendingBBox[0],
|
||||
minY: this.#pendingBBox[1],
|
||||
maxX: this.#pendingBBox[2],
|
||||
maxY: this.#pendingBBox[3],
|
||||
}
|
||||
: null;
|
||||
this.#operations.set(idx, { bbox, pairs, dependencies });
|
||||
if (!preserveBbox) {
|
||||
this.#pendingBBoxIdx = -1;
|
||||
}
|
||||
this.#pendingDependencies.clear();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
bboxToClipBoxDropOperation(idx) {
|
||||
if (this.#pendingBBoxIdx !== -1) {
|
||||
this.#pendingBBoxIdx = -1;
|
||||
|
||||
this.#clipBox[0] = Math.max(this.#clipBox[0], this.#pendingBBox[0]);
|
||||
this.#clipBox[1] = Math.max(this.#clipBox[1], this.#pendingBBox[1]);
|
||||
this.#clipBox[2] = Math.min(this.#clipBox[2], this.#pendingBBox[2]);
|
||||
this.#clipBox[3] = Math.min(this.#clipBox[3], this.#pendingBBox[3]);
|
||||
}
|
||||
this.#pendingDependencies.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
_takePendingDependencies() {
|
||||
const pendingDependencies = this.#pendingDependencies;
|
||||
this.#pendingDependencies = new Set();
|
||||
return pendingDependencies;
|
||||
}
|
||||
|
||||
_extractOperation(idx) {
|
||||
const operation = this.#operations.get(idx);
|
||||
this.#operations.delete(idx);
|
||||
return operation;
|
||||
}
|
||||
|
||||
_pushPendingDependencies(dependencies) {
|
||||
for (const dep of dependencies) {
|
||||
this.#pendingDependencies.add(dep);
|
||||
}
|
||||
}
|
||||
|
||||
take() {
|
||||
this.#fontBBoxTrustworthy.clear();
|
||||
return Array.from(
|
||||
this.#operations,
|
||||
([idx, { bbox, pairs, dependencies }]) => {
|
||||
pairs.forEach(pair => pair.forEach(dependencies.add, dependencies));
|
||||
dependencies.delete(idx);
|
||||
return {
|
||||
minX: (bbox?.minX ?? 0) / this.#canvasWidth,
|
||||
maxX: (bbox?.maxX ?? this.#canvasWidth) / this.#canvasWidth,
|
||||
minY: (bbox?.minY ?? 0) / this.#canvasHeight,
|
||||
maxY: (bbox?.maxY ?? this.#canvasHeight) / this.#canvasHeight,
|
||||
dependencies: Array.from(dependencies).sort((a, b) => a - b),
|
||||
idx,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to track dependencies of nested operations list, that
|
||||
* should actually all map to the index of the operation that
|
||||
* contains the nested list.
|
||||
*
|
||||
* @implements {CanvasDependencyTracker}
|
||||
*/
|
||||
class CanvasNestedDependencyTracker {
|
||||
/** @type {CanvasDependencyTracker} */
|
||||
#dependencyTracker;
|
||||
|
||||
/** @type {number} */
|
||||
#opIdx;
|
||||
|
||||
#nestingLevel = 0;
|
||||
|
||||
#outerDependencies;
|
||||
|
||||
#savesLevel = 0;
|
||||
|
||||
constructor(dependencyTracker, opIdx) {
|
||||
if (dependencyTracker instanceof CanvasNestedDependencyTracker) {
|
||||
// The goal of CanvasNestedDependencyTracker is to collapse all operations
|
||||
// into a single one. If we are already in a
|
||||
// CanvasNestedDependencyTracker, that is already happening.
|
||||
return dependencyTracker;
|
||||
}
|
||||
|
||||
this.#dependencyTracker = dependencyTracker;
|
||||
this.#outerDependencies = dependencyTracker._takePendingDependencies();
|
||||
this.#opIdx = opIdx;
|
||||
}
|
||||
|
||||
save(opIdx) {
|
||||
this.#savesLevel++;
|
||||
this.#dependencyTracker.save(this.#opIdx);
|
||||
return this;
|
||||
}
|
||||
|
||||
restore(opIdx) {
|
||||
if (this.#savesLevel > 0) {
|
||||
this.#dependencyTracker.restore(this.#opIdx);
|
||||
this.#savesLevel--;
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
recordOpenMarker(idx) {
|
||||
this.#nestingLevel++;
|
||||
return this;
|
||||
}
|
||||
|
||||
getOpenMarker() {
|
||||
return this.#nestingLevel > 0
|
||||
? this.#opIdx
|
||||
: this.#dependencyTracker.getOpenMarker();
|
||||
}
|
||||
|
||||
recordCloseMarker(idx) {
|
||||
this.#nestingLevel--;
|
||||
return this;
|
||||
}
|
||||
|
||||
beginMarkedContent(opIdx) {
|
||||
return this;
|
||||
}
|
||||
|
||||
endMarkedContent(opIdx) {
|
||||
return this;
|
||||
}
|
||||
|
||||
pushBaseTransform(ctx) {
|
||||
this.#dependencyTracker.pushBaseTransform(ctx);
|
||||
return this;
|
||||
}
|
||||
|
||||
popBaseTransform() {
|
||||
this.#dependencyTracker.popBaseTransform();
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SimpleDependency} name
|
||||
* @param {number} idx
|
||||
*/
|
||||
recordSimpleData(name, idx) {
|
||||
this.#dependencyTracker.recordSimpleData(name, this.#opIdx);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IncrementalDependency} name
|
||||
* @param {number} idx
|
||||
*/
|
||||
recordIncrementalData(name, idx) {
|
||||
this.#dependencyTracker.recordIncrementalData(name, this.#opIdx);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {IncrementalDependency} name
|
||||
* @param {number} idx
|
||||
*/
|
||||
resetIncrementalData(name, idx) {
|
||||
this.#dependencyTracker.resetIncrementalData(name, this.#opIdx);
|
||||
return this;
|
||||
}
|
||||
|
||||
recordNamedData(name, idx) {
|
||||
// Nested dependencies are not visible to the outside.
|
||||
return this;
|
||||
}
|
||||
|
||||
// All next operations, until the next .restore(), will depend on this
|
||||
recordFutureForcedDependency(name, idx) {
|
||||
this.#dependencyTracker.recordFutureForcedDependency(name, this.#opIdx);
|
||||
return this;
|
||||
}
|
||||
|
||||
// All next operations, until the next .restore(), will depend on all
|
||||
// the already recorded data with the given names.
|
||||
inheritSimpleDataAsFutureForcedDependencies(names) {
|
||||
this.#dependencyTracker.inheritSimpleDataAsFutureForcedDependencies(names);
|
||||
return this;
|
||||
}
|
||||
|
||||
inheritPendingDependenciesAsFutureForcedDependencies() {
|
||||
this.#dependencyTracker.inheritPendingDependenciesAsFutureForcedDependencies();
|
||||
return this;
|
||||
}
|
||||
|
||||
resetBBox(idx) {
|
||||
if (!this.#dependencyTracker.hasPendingBBox) {
|
||||
this.#dependencyTracker.resetBBox(this.#opIdx);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
get hasPendingBBox() {
|
||||
return this.#dependencyTracker.hasPendingBBox;
|
||||
}
|
||||
|
||||
recordClipBox(idx, ctx, minX, maxX, minY, maxY) {
|
||||
this.#dependencyTracker.recordClipBox(
|
||||
this.#opIdx,
|
||||
ctx,
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
recordBBox(idx, ctx, minX, maxX, minY, maxY) {
|
||||
this.#dependencyTracker.recordBBox(
|
||||
this.#opIdx,
|
||||
ctx,
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
recordCharacterBBox(idx, ctx, font, scale, x, y, getMeasure) {
|
||||
this.#dependencyTracker.recordCharacterBBox(
|
||||
this.#opIdx,
|
||||
ctx,
|
||||
font,
|
||||
scale,
|
||||
x,
|
||||
y,
|
||||
getMeasure
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
recordFullPageBBox(idx) {
|
||||
this.#dependencyTracker.recordFullPageBBox(this.#opIdx);
|
||||
return this;
|
||||
}
|
||||
|
||||
getSimpleIndex(dependencyName) {
|
||||
return this.#dependencyTracker.getSimpleIndex(dependencyName);
|
||||
}
|
||||
|
||||
recordDependencies(idx, dependencyNames) {
|
||||
this.#dependencyTracker.recordDependencies(this.#opIdx, dependencyNames);
|
||||
return this;
|
||||
}
|
||||
|
||||
copyDependenciesFromIncrementalOperation(idx, name) {
|
||||
this.#dependencyTracker.copyDependenciesFromIncrementalOperation(
|
||||
this.#opIdx,
|
||||
name
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
recordNamedDependency(idx, name) {
|
||||
this.#dependencyTracker.recordNamedDependency(this.#opIdx, name);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} idx
|
||||
* @param {SimpleDependency[]} dependencyNames
|
||||
*/
|
||||
recordOperation(idx) {
|
||||
this.#dependencyTracker.recordOperation(this.#opIdx, true);
|
||||
const operation = this.#dependencyTracker._extractOperation(this.#opIdx);
|
||||
for (const depIdx of operation.dependencies) {
|
||||
this.#outerDependencies.add(depIdx);
|
||||
}
|
||||
this.#outerDependencies.delete(this.#opIdx);
|
||||
this.#outerDependencies.delete(null);
|
||||
return this;
|
||||
}
|
||||
|
||||
bboxToClipBoxDropOperation(idx) {
|
||||
this.#dependencyTracker.bboxToClipBoxDropOperation(this.#opIdx);
|
||||
return this;
|
||||
}
|
||||
|
||||
recordNestedDependencies() {
|
||||
this.#dependencyTracker._pushPendingDependencies(this.#outerDependencies);
|
||||
}
|
||||
|
||||
take() {
|
||||
throw new Error("Unreachable");
|
||||
}
|
||||
}
|
||||
|
||||
/** @satisfies {Record<string, SimpleDependency | IncrementalDependency>} */
|
||||
const Dependencies = {
|
||||
stroke: [
|
||||
"path",
|
||||
"transform",
|
||||
"filter",
|
||||
"strokeColor",
|
||||
"strokeAlpha",
|
||||
"lineWidth",
|
||||
"lineCap",
|
||||
"lineJoin",
|
||||
"miterLimit",
|
||||
"dash",
|
||||
],
|
||||
fill: [
|
||||
"path",
|
||||
"transform",
|
||||
"filter",
|
||||
"fillColor",
|
||||
"fillAlpha",
|
||||
"globalCompositeOperation",
|
||||
"SMask",
|
||||
],
|
||||
imageXObject: [
|
||||
"transform",
|
||||
"SMask",
|
||||
"filter",
|
||||
"fillAlpha",
|
||||
"strokeAlpha",
|
||||
"globalCompositeOperation",
|
||||
],
|
||||
rawFillPath: ["filter", "fillColor", "fillAlpha"],
|
||||
showText: [
|
||||
"transform",
|
||||
"leading",
|
||||
"charSpacing",
|
||||
"wordSpacing",
|
||||
"hScale",
|
||||
"textRise",
|
||||
"moveText",
|
||||
"textMatrix",
|
||||
"font",
|
||||
"filter",
|
||||
"fillColor",
|
||||
"textRenderingMode",
|
||||
"SMask",
|
||||
"fillAlpha",
|
||||
"strokeAlpha",
|
||||
"globalCompositeOperation",
|
||||
// TODO: More
|
||||
],
|
||||
transform: ["transform"],
|
||||
transformAndFill: ["transform", "fillColor"],
|
||||
};
|
||||
|
||||
export { CanvasDependencyTracker, CanvasNestedDependencyTracker, Dependencies };
|
||||
@ -573,11 +573,24 @@ class TilingPattern {
|
||||
this.setFillAndStrokeStyleToContext(graphics, paintType, color);
|
||||
|
||||
tmpCtx.translate(-dimx.scale * x0, -dimy.scale * y0);
|
||||
graphics.transform(dimx.scale, 0, 0, dimy.scale, 0, 0);
|
||||
graphics.transform(
|
||||
// We pass 0 as the 'opIdx' argument, but the value is irrelevant.
|
||||
// We know that we are in a 'CanvasNestedDependencyTracker' that captures
|
||||
// all the sub-operations needed to create this pattern canvas and uses
|
||||
// the top-level operation index as their index.
|
||||
0,
|
||||
dimx.scale,
|
||||
0,
|
||||
0,
|
||||
dimy.scale,
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
// To match CanvasGraphics beginDrawing we must save the context here or
|
||||
// else we end up with unbalanced save/restores.
|
||||
tmpCtx.save();
|
||||
graphics.dependencyTracker?.save();
|
||||
|
||||
this.clipBbox(graphics, x0, y0, x1, y1);
|
||||
|
||||
@ -587,6 +600,7 @@ class TilingPattern {
|
||||
|
||||
graphics.endDrawing();
|
||||
|
||||
graphics.dependencyTracker?.restore().recordNestedDependencies?.();
|
||||
tmpCtx.restore();
|
||||
|
||||
if (redrawHorizontally || redrawVertically) {
|
||||
|
||||
@ -674,6 +674,10 @@ class Util {
|
||||
return `#${hexNumbers[r]}${hexNumbers[g]}${hexNumbers[b]}`;
|
||||
}
|
||||
|
||||
static domMatrixToTransform(dm) {
|
||||
return [dm.a, dm.b, dm.c, dm.d, dm.e, dm.f];
|
||||
}
|
||||
|
||||
// Apply a scaling matrix to some min/max values.
|
||||
// If a scaling factor is negative then min and max must be
|
||||
// swapped.
|
||||
@ -737,6 +741,18 @@ class Util {
|
||||
];
|
||||
}
|
||||
|
||||
// Multiplies m (an array-based transform) by md (a DOMMatrix transform).
|
||||
static multiplyByDOMMatrix(m, md) {
|
||||
return [
|
||||
m[0] * md.a + m[2] * md.b,
|
||||
m[1] * md.a + m[3] * md.b,
|
||||
m[0] * md.c + m[2] * md.d,
|
||||
m[1] * md.c + m[3] * md.d,
|
||||
m[0] * md.e + m[2] * md.f + m[4],
|
||||
m[1] * md.e + m[3] * md.f + m[5],
|
||||
];
|
||||
}
|
||||
|
||||
// For 2d affine transforms
|
||||
static applyTransform(p, m, pos = 0) {
|
||||
const p0 = p[pos];
|
||||
|
||||
124
test/driver.js
124
test/driver.js
@ -920,7 +920,8 @@ class Driver {
|
||||
renderPrint = false,
|
||||
renderXfa = false,
|
||||
annotationCanvasMap = null,
|
||||
pageColors = null;
|
||||
pageColors = null,
|
||||
partialCrop = null;
|
||||
|
||||
if (task.annotationStorage) {
|
||||
task.pdfDoc.annotationStorage._setValues(task.annotationStorage);
|
||||
@ -968,10 +969,14 @@ class Driver {
|
||||
textLayerCanvas = null;
|
||||
// We fetch the `eq` specific test subtypes here, to avoid
|
||||
// accidentally changing the behaviour for other types of tests.
|
||||
|
||||
partialCrop = task.partial;
|
||||
if (!partialCrop) {
|
||||
renderAnnotations = !!task.annotations;
|
||||
renderForms = !!task.forms;
|
||||
renderPrint = !!task.print;
|
||||
renderXfa = !!task.enableXfa;
|
||||
}
|
||||
pageColors = task.pageColors || null;
|
||||
|
||||
// Render the annotation layer if necessary.
|
||||
@ -1031,6 +1036,9 @@ class Driver {
|
||||
}
|
||||
renderContext.intent = "print";
|
||||
}
|
||||
if (partialCrop) {
|
||||
renderContext.recordOperations = true;
|
||||
}
|
||||
|
||||
const completeRender = error => {
|
||||
// if text layer is present, compose it on top of the page
|
||||
@ -1061,7 +1069,7 @@ class Driver {
|
||||
this._snapshot(task, error);
|
||||
};
|
||||
initPromise
|
||||
.then(data => {
|
||||
.then(async data => {
|
||||
const renderTask = page.render(renderContext);
|
||||
|
||||
if (task.renderTaskOnContinue) {
|
||||
@ -1070,7 +1078,104 @@ class Driver {
|
||||
setTimeout(cont, RENDER_TASK_ON_CONTINUE_DELAY);
|
||||
};
|
||||
}
|
||||
return renderTask.promise.then(() => {
|
||||
await renderTask.promise;
|
||||
|
||||
if (partialCrop) {
|
||||
const clearOutsidePartial = () => {
|
||||
const { width, height } = ctx.canvas;
|
||||
// Everything above the partial area
|
||||
ctx.clearRect(
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
Math.ceil(partialCrop.minY * height)
|
||||
);
|
||||
// Everything below the partial area
|
||||
ctx.clearRect(
|
||||
0,
|
||||
Math.floor(partialCrop.maxY * height),
|
||||
width,
|
||||
height
|
||||
);
|
||||
// Everything to the left of the partial area
|
||||
ctx.clearRect(
|
||||
0,
|
||||
0,
|
||||
Math.ceil(partialCrop.minX * width),
|
||||
height
|
||||
);
|
||||
// Everything to the right of the partial area
|
||||
ctx.clearRect(
|
||||
Math.floor(partialCrop.maxX * width),
|
||||
0,
|
||||
width,
|
||||
height
|
||||
);
|
||||
};
|
||||
|
||||
clearOutsidePartial();
|
||||
const baseline = ctx.canvas.toDataURL("image/png");
|
||||
this._clearCanvas();
|
||||
|
||||
const filteredIndexes = new Set();
|
||||
|
||||
// TODO: This logic is copy-psated from PDFPageDetailView.
|
||||
// We should export it instead, because even though it's
|
||||
// not the core logic of partial rendering it is still
|
||||
// relevant
|
||||
const recordedGroups = page.recordedGroups;
|
||||
for (let i = 0, ii = recordedGroups.length; i < ii; i++) {
|
||||
const group = recordedGroups[i];
|
||||
if (
|
||||
group.minX <= partialCrop.maxX &&
|
||||
group.maxX >= partialCrop.minX &&
|
||||
group.minY <= partialCrop.maxY &&
|
||||
group.maxY >= partialCrop.minY
|
||||
) {
|
||||
filteredIndexes.add(group.idx);
|
||||
group.dependencies.forEach(
|
||||
filteredIndexes.add,
|
||||
filteredIndexes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const partialRenderContext = {
|
||||
canvasContext: ctx,
|
||||
viewport,
|
||||
optionalContentConfigPromise:
|
||||
task.optionalContentConfigPromise,
|
||||
annotationCanvasMap,
|
||||
pageColors,
|
||||
transform,
|
||||
recordOperations: false,
|
||||
filteredOperationIndexes: filteredIndexes,
|
||||
};
|
||||
|
||||
const partialRenderTask = page.render(partialRenderContext);
|
||||
await partialRenderTask.promise;
|
||||
|
||||
clearOutsidePartial();
|
||||
|
||||
if (page.stats) {
|
||||
// Get the page stats *before* running cleanup.
|
||||
task.stats = page.stats;
|
||||
}
|
||||
page.cleanup(/* resetStats = */ true);
|
||||
this._snapshot(
|
||||
task,
|
||||
false,
|
||||
// Sometimes the optimized version does not match the
|
||||
// baseline. Tests marked as "knownPartialMismatch" have
|
||||
// been manually verified to be good enough (e.g. there is
|
||||
// one pixel of a very slightly different shade), so we
|
||||
// avoid compating them to the non-optimized version and
|
||||
// instead use the optimized version also for makeref.
|
||||
task.knownPartialMismatch ? null : baseline
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (annotationCanvasMap) {
|
||||
Rasterize.annotationLayer(
|
||||
annotationLayerContext,
|
||||
@ -1089,7 +1194,6 @@ class Driver {
|
||||
} else {
|
||||
completeRender(false);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(function (error) {
|
||||
completeRender("render : " + error);
|
||||
@ -1112,11 +1216,16 @@ class Driver {
|
||||
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
_snapshot(task, failure) {
|
||||
_snapshot(task, failure, baselineDataUrl = null) {
|
||||
this._log("Snapshotting... ");
|
||||
|
||||
const dataUrl = this.canvas.toDataURL("image/png");
|
||||
this._sendResult(dataUrl, task, failure).then(() => {
|
||||
|
||||
if (baselineDataUrl && baselineDataUrl !== dataUrl) {
|
||||
failure ||= "Optimized rendering differs from full rendering.";
|
||||
}
|
||||
|
||||
this._sendResult(dataUrl, task, failure, baselineDataUrl).then(() => {
|
||||
this._log(
|
||||
"done" + (failure ? " (failed !: " + failure + ")" : "") + "\n"
|
||||
);
|
||||
@ -1170,7 +1279,7 @@ class Driver {
|
||||
}
|
||||
}
|
||||
|
||||
_sendResult(snapshot, task, failure) {
|
||||
_sendResult(snapshot, task, failure, baselineSnapshot = null) {
|
||||
const result = JSON.stringify({
|
||||
browser: this.browser,
|
||||
id: task.id,
|
||||
@ -1181,6 +1290,7 @@ class Driver {
|
||||
round: task.round,
|
||||
page: task.pageNum,
|
||||
snapshot,
|
||||
baselineSnapshot,
|
||||
stats: task.stats.times,
|
||||
viewportWidth: task.viewportWidth,
|
||||
viewportHeight: task.viewportHeight,
|
||||
|
||||
@ -460,6 +460,13 @@ function checkEq(task, results, browser, masterMode) {
|
||||
} else {
|
||||
console.error("Valid snapshot was not found.");
|
||||
}
|
||||
let unoptimizedSnapshot = pageResult.baselineSnapshot;
|
||||
if (unoptimizedSnapshot?.startsWith("data:image/png;base64,")) {
|
||||
unoptimizedSnapshot = Buffer.from(
|
||||
unoptimizedSnapshot.substring(22),
|
||||
"base64"
|
||||
);
|
||||
}
|
||||
|
||||
var refSnapshot = null;
|
||||
var eq = false;
|
||||
@ -526,7 +533,7 @@ function checkEq(task, results, browser, masterMode) {
|
||||
ensureDirSync(tmpSnapshotDir);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpSnapshotDir, page + 1 + ".png"),
|
||||
testSnapshot
|
||||
unoptimizedSnapshot ?? testSnapshot
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -616,7 +623,14 @@ function checkRefTestResults(browser, id, results) {
|
||||
return; // no results
|
||||
}
|
||||
if (pageResult.failure) {
|
||||
// If the test failes due to a difference between the optimized and
|
||||
// unoptimized rendering, we don't set `failed` to true so that we will
|
||||
// still compute the differences between them. In master mode, this
|
||||
// means that we will save the reference image from the unoptimized
|
||||
// rendering even if the optimized rendering is wrong.
|
||||
if (!pageResult.failure.includes("Optimized rendering differs")) {
|
||||
failed = true;
|
||||
}
|
||||
if (fs.existsSync(task.file + ".error")) {
|
||||
console.log(
|
||||
"TEST-SKIPPED | PDF was not downloaded " +
|
||||
@ -631,7 +645,9 @@ function checkRefTestResults(browser, id, results) {
|
||||
pageResult.failure
|
||||
);
|
||||
} else {
|
||||
if (failed) {
|
||||
session.numErrors++;
|
||||
}
|
||||
console.log(
|
||||
"TEST-UNEXPECTED-FAIL | test failed " +
|
||||
id +
|
||||
@ -653,6 +669,7 @@ function checkRefTestResults(browser, id, results) {
|
||||
}
|
||||
switch (task.type) {
|
||||
case "eq":
|
||||
case "partial":
|
||||
case "text":
|
||||
case "highlight":
|
||||
checkEq(task, results, browser, session.masterMode);
|
||||
@ -712,6 +729,7 @@ function refTestPostHandler(parsedUrl, req, res) {
|
||||
var page = data.page - 1;
|
||||
var failure = data.failure;
|
||||
var snapshot = data.snapshot;
|
||||
var baselineSnapshot = data.baselineSnapshot;
|
||||
var lastPageNum = data.lastPageNum;
|
||||
|
||||
session = getSession(browser);
|
||||
@ -740,6 +758,7 @@ function refTestPostHandler(parsedUrl, req, res) {
|
||||
taskResults[round][page] = {
|
||||
failure,
|
||||
snapshot,
|
||||
baselineSnapshot,
|
||||
viewportWidth: data.viewportWidth,
|
||||
viewportHeight: data.viewportHeight,
|
||||
outputScale: data.outputScale,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -534,6 +534,9 @@ const PDFViewerApplication = {
|
||||
capCanvasAreaFactor,
|
||||
enableDetailCanvas: AppOptions.get("enableDetailCanvas"),
|
||||
enablePermissions: AppOptions.get("enablePermissions"),
|
||||
enableOptimizedPartialRendering: AppOptions.get(
|
||||
"enableOptimizedPartialRendering"
|
||||
),
|
||||
pageColors,
|
||||
mlManager,
|
||||
abortSignal,
|
||||
|
||||
@ -246,6 +246,11 @@ const defaultOptions = {
|
||||
value: true,
|
||||
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
|
||||
},
|
||||
enableOptimizedPartialRendering: {
|
||||
/** @type {boolean} */
|
||||
value: false,
|
||||
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
|
||||
},
|
||||
enablePermissions: {
|
||||
/** @type {boolean} */
|
||||
value: false,
|
||||
|
||||
@ -36,12 +36,16 @@ class BasePDFPageView {
|
||||
/** @type {null | HTMLDivElement} */
|
||||
div = null;
|
||||
|
||||
enableOptimizedPartialRendering = false;
|
||||
|
||||
eventBus = null;
|
||||
|
||||
id = null;
|
||||
|
||||
pageColors = null;
|
||||
|
||||
recordedGroups = null;
|
||||
|
||||
renderingQueue = null;
|
||||
|
||||
renderTask = null;
|
||||
@ -53,6 +57,8 @@ class BasePDFPageView {
|
||||
this.id = options.id;
|
||||
this.pageColors = options.pageColors || null;
|
||||
this.renderingQueue = options.renderingQueue;
|
||||
this.enableOptimizedPartialRendering =
|
||||
options.enableOptimizedPartialRendering ?? false;
|
||||
this.#minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500;
|
||||
}
|
||||
|
||||
@ -228,6 +234,9 @@ class BasePDFPageView {
|
||||
// triggering this callback.
|
||||
if (renderTask === this.renderTask) {
|
||||
this.renderTask = null;
|
||||
if (this.enableOptimizedPartialRendering) {
|
||||
this.recordedGroups ??= renderTask.recordedGroups;
|
||||
}
|
||||
}
|
||||
}
|
||||
this.renderingState = RenderingStates.FINISHED;
|
||||
|
||||
@ -112,3 +112,34 @@
|
||||
background-color: rgb(255 255 255 / 0.6);
|
||||
color: rgb(0 0 0);
|
||||
}
|
||||
|
||||
.pdfBugGroupsLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
|
||||
> * {
|
||||
position: absolute;
|
||||
outline-color: red;
|
||||
outline-width: 2px;
|
||||
|
||||
--hover-outline-style: solid !important;
|
||||
--hover-background-color: rgb(255 0 0 / 0.2);
|
||||
|
||||
&:hover {
|
||||
outline-style: var(--hover-outline-style);
|
||||
background-color: var(--hover-background-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.showDebugBoxes & {
|
||||
outline-style: dashed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.showDebugBoxes {
|
||||
.pdfBugGroupsLayer {
|
||||
pointer-events: all;
|
||||
}
|
||||
}
|
||||
|
||||
146
web/debugger.mjs
146
web/debugger.mjs
@ -200,6 +200,10 @@ const StepperManager = (function StepperManagerClosure() {
|
||||
active: false,
|
||||
// Stepper specific functions.
|
||||
create(pageIndex) {
|
||||
const pageContainer = document.querySelector(
|
||||
`#viewer div[data-page-number="${pageIndex + 1}"]`
|
||||
);
|
||||
|
||||
const debug = document.createElement("div");
|
||||
debug.id = "stepper" + pageIndex;
|
||||
debug.hidden = true;
|
||||
@ -210,7 +214,12 @@ const StepperManager = (function StepperManagerClosure() {
|
||||
b.value = pageIndex;
|
||||
stepperChooser.append(b);
|
||||
const initBreakPoints = breakPoints[pageIndex] || [];
|
||||
const stepper = new Stepper(debug, pageIndex, initBreakPoints);
|
||||
const stepper = new Stepper(
|
||||
debug,
|
||||
pageIndex,
|
||||
initBreakPoints,
|
||||
pageContainer
|
||||
);
|
||||
steppers.push(stepper);
|
||||
if (steppers.length === 1) {
|
||||
this.selectStepper(pageIndex, false);
|
||||
@ -277,7 +286,7 @@ class Stepper {
|
||||
return simpleObj;
|
||||
}
|
||||
|
||||
constructor(panel, pageIndex, initialBreakPoints) {
|
||||
constructor(panel, pageIndex, initialBreakPoints, pageContainer) {
|
||||
this.panel = panel;
|
||||
this.breakPoint = 0;
|
||||
this.nextBreakPoint = null;
|
||||
@ -286,11 +295,20 @@ class Stepper {
|
||||
this.currentIdx = -1;
|
||||
this.operatorListIdx = 0;
|
||||
this.indentLevel = 0;
|
||||
this.operatorGroups = null;
|
||||
this.pageContainer = pageContainer;
|
||||
}
|
||||
|
||||
init(operatorList) {
|
||||
const panel = this.panel;
|
||||
const content = this.#c("div", "c=continue, s=step");
|
||||
|
||||
const showBoxesToggle = this.#c("label", "Show bounding boxes");
|
||||
const showBoxesCheckbox = this.#c("input");
|
||||
showBoxesCheckbox.type = "checkbox";
|
||||
showBoxesToggle.prepend(showBoxesCheckbox);
|
||||
content.append(this.#c("br"), showBoxesToggle);
|
||||
|
||||
const table = this.#c("table");
|
||||
content.append(table);
|
||||
table.cellSpacing = 0;
|
||||
@ -305,6 +323,22 @@ class Stepper {
|
||||
panel.append(content);
|
||||
this.table = table;
|
||||
this.updateOperatorList(operatorList);
|
||||
|
||||
const hoverStyle = this.#c("style");
|
||||
this.hoverStyle = hoverStyle;
|
||||
content.prepend(hoverStyle);
|
||||
table.addEventListener("mouseover", this.#handleStepHover.bind(this));
|
||||
table.addEventListener("mouseleave", e => {
|
||||
hoverStyle.innerText = "";
|
||||
});
|
||||
|
||||
showBoxesCheckbox.addEventListener("change", () => {
|
||||
if (showBoxesCheckbox.checked) {
|
||||
this.pageContainer.classList.add("showDebugBoxes");
|
||||
} else {
|
||||
this.pageContainer.classList.remove("showDebugBoxes");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateOperatorList(operatorList) {
|
||||
@ -405,6 +439,114 @@ class Stepper {
|
||||
this.table.append(chunk);
|
||||
}
|
||||
|
||||
setOperatorGroups(groups) {
|
||||
let boxesContainer = this.pageContainer.querySelector(".pdfBugGroupsLayer");
|
||||
if (!boxesContainer) {
|
||||
boxesContainer = this.#c("div");
|
||||
boxesContainer.classList.add("pdfBugGroupsLayer");
|
||||
this.pageContainer.append(boxesContainer);
|
||||
|
||||
boxesContainer.addEventListener(
|
||||
"click",
|
||||
this.#handleDebugBoxClick.bind(this)
|
||||
);
|
||||
boxesContainer.addEventListener(
|
||||
"mouseover",
|
||||
this.#handleDebugBoxHover.bind(this)
|
||||
);
|
||||
}
|
||||
boxesContainer.innerHTML = "";
|
||||
|
||||
groups = groups.toSorted((a, b) => {
|
||||
const diffs = [
|
||||
a.minX - b.minX,
|
||||
a.minY - b.minY,
|
||||
b.maxX - a.maxX,
|
||||
b.maxY - a.maxY,
|
||||
];
|
||||
for (const diff of diffs) {
|
||||
if (Math.abs(diff) > 0.01) {
|
||||
return Math.sign(diff);
|
||||
}
|
||||
}
|
||||
for (const diff of diffs) {
|
||||
if (Math.abs(diff) > 0.0001) {
|
||||
return Math.sign(diff);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
this.operatorGroups = groups;
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const el = this.#c("div");
|
||||
el.style.left = `${groups[i].minX * 100}%`;
|
||||
el.style.top = `${groups[i].minY * 100}%`;
|
||||
el.style.width = `${(groups[i].maxX - groups[i].minX) * 100}%`;
|
||||
el.style.height = `${(groups[i].maxY - groups[i].minY) * 100}%`;
|
||||
el.dataset.groupIdx = i;
|
||||
boxesContainer.append(el);
|
||||
}
|
||||
}
|
||||
|
||||
#handleStepHover(e) {
|
||||
const tr = e.target.closest("tr");
|
||||
if (!tr || tr.dataset.idx === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const index = +tr.dataset.idx;
|
||||
|
||||
const closestGroupIndex =
|
||||
this.operatorGroups?.findIndex(({ idx }) => idx === index) ?? -1;
|
||||
if (closestGroupIndex === -1) {
|
||||
this.hoverStyle.innerText = "";
|
||||
return;
|
||||
}
|
||||
|
||||
this.#highlightStepsGroup(closestGroupIndex);
|
||||
}
|
||||
|
||||
#handleDebugBoxHover(e) {
|
||||
if (e.target.dataset.groupIdx === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupIdx = Number(e.target.dataset.groupIdx);
|
||||
this.#highlightStepsGroup(groupIdx);
|
||||
}
|
||||
|
||||
#handleDebugBoxClick(e) {
|
||||
if (e.target.dataset.groupIdx === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupIdx = Number(e.target.dataset.groupIdx);
|
||||
const group = this.operatorGroups[groupIdx];
|
||||
|
||||
this.table.childNodes[group.idx].scrollIntoView();
|
||||
}
|
||||
|
||||
#highlightStepsGroup(groupIndex) {
|
||||
const group = this.operatorGroups[groupIndex];
|
||||
|
||||
this.hoverStyle.innerText = `#${this.panel.id} tr[data-idx="${group.idx}"] { background-color: rgba(0, 0, 0, 0.1); }`;
|
||||
|
||||
if (group.dependencies.length > 0) {
|
||||
const selector = group.dependencies
|
||||
.map(idx => `#${this.panel.id} tr[data-idx="${idx}"]`)
|
||||
.join(", ");
|
||||
this.hoverStyle.innerText += `${selector} { background-color: rgba(0, 255, 255, 0.1); }`;
|
||||
}
|
||||
|
||||
this.hoverStyle.innerText += `
|
||||
#viewer [data-page-number="${this.pageIndex + 1}"] .pdfBugGroupsLayer :nth-child(${groupIndex + 1}) {
|
||||
background-color: var(--hover-background-color);
|
||||
outline-style: var(--hover-outline-style);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
getNextBreakPoint() {
|
||||
this.breakPoints.sort((a, b) => a - b);
|
||||
for (const breakPoint of this.breakPoints) {
|
||||
|
||||
@ -186,6 +186,55 @@ class PDFPageDetailView extends BasePDFPageView {
|
||||
this.reset({ keepCanvas: true });
|
||||
}
|
||||
|
||||
_getRenderingContext(canvas, transform) {
|
||||
const baseContext = this.pageView._getRenderingContext(canvas, transform);
|
||||
const recordedGroups = this.pdfPage.recordedGroups;
|
||||
|
||||
if (!recordedGroups || !this.enableOptimizedPartialRendering) {
|
||||
return { ...baseContext, recordOperations: false };
|
||||
}
|
||||
|
||||
// TODO: There is probably a better data structure for this.
|
||||
// The indexes are always checked in increasing order, so we can just try
|
||||
// to build a pre-sorted array which should have faster lookups.
|
||||
// Needs benchmarking.
|
||||
const filteredIndexes = new Set();
|
||||
|
||||
const {
|
||||
viewport: { width: vWidth, height: vHeight },
|
||||
} = this.pageView;
|
||||
const {
|
||||
width: aWidth,
|
||||
height: aHeight,
|
||||
minX: aMinX,
|
||||
minY: aMinY,
|
||||
} = this.#detailArea;
|
||||
|
||||
const detailMinX = aMinX / vWidth;
|
||||
const detailMinY = aMinY / vHeight;
|
||||
const detailMaxX = (aMinX + aWidth) / vWidth;
|
||||
const detailMaxY = (aMinY + aHeight) / vHeight;
|
||||
|
||||
for (let i = 0, ii = recordedGroups.length; i < ii; i++) {
|
||||
const group = recordedGroups[i];
|
||||
if (
|
||||
group.minX <= detailMaxX &&
|
||||
group.maxX >= detailMinX &&
|
||||
group.minY <= detailMaxY &&
|
||||
group.maxY >= detailMinY
|
||||
) {
|
||||
filteredIndexes.add(group.idx);
|
||||
group.dependencies.forEach(filteredIndexes.add, filteredIndexes);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...baseContext,
|
||||
recordOperations: false,
|
||||
filteredOperationIndexes: filteredIndexes,
|
||||
};
|
||||
}
|
||||
|
||||
async draw() {
|
||||
// The PDFPageView might have already dropped this PDFPageDetailView. In
|
||||
// that case, simply do nothing.
|
||||
@ -249,7 +298,7 @@ class PDFPageDetailView extends BasePDFPageView {
|
||||
style.left = `${(area.minX * 100) / width}%`;
|
||||
|
||||
const renderingPromise = this._drawCanvas(
|
||||
this.pageView._getRenderingContext(canvas, transform),
|
||||
this._getRenderingContext(canvas, transform),
|
||||
() => {
|
||||
// If the rendering is cancelled, keep the old canvas visible.
|
||||
this.canvas?.remove();
|
||||
|
||||
@ -89,6 +89,11 @@ import { XfaLayerBuilder } from "./xfa_layer_builder.js";
|
||||
* `maxCanvasDim`, it will draw a second canvas on top of the CSS-zoomed one,
|
||||
* that only renders the part of the page that is close to the viewport.
|
||||
* The default value is `true`.
|
||||
* @property {boolean} [enableOptimizedPartialRendering] - When enabled, PDF
|
||||
* rendering will keep track of which areas of the page each PDF operation
|
||||
* affects. Then, when rendering a partial page (if `enableDetailCanvas` is
|
||||
* enabled), it will only run through the operations that affect that portion.
|
||||
* The default value is `false`.
|
||||
* @property {Object} [pageColors] - Overwrites background and foreground colors
|
||||
* with user defined ones in order to improve readability in high contrast
|
||||
* mode.
|
||||
@ -538,6 +543,8 @@ class PDFPageView extends BasePDFPageView {
|
||||
keepCanvasWrapper = false,
|
||||
preserveDetailViewState = false,
|
||||
} = {}) {
|
||||
const keepPdfBugGroups = this.pdfPage?._pdfBug ?? false;
|
||||
|
||||
this.cancelRendering({
|
||||
keepAnnotationLayer,
|
||||
keepAnnotationEditorLayer,
|
||||
@ -566,6 +573,9 @@ class PDFPageView extends BasePDFPageView {
|
||||
case canvasWrapperNode:
|
||||
continue;
|
||||
}
|
||||
if (keepPdfBugGroups && node.classList.contains("pdfBugGroupsLayer")) {
|
||||
continue;
|
||||
}
|
||||
node.remove();
|
||||
const layerIndex = this.#layers.indexOf(node);
|
||||
if (layerIndex >= 0) {
|
||||
@ -634,7 +644,10 @@ class PDFPageView extends BasePDFPageView {
|
||||
this.maxCanvasPixels > 0 &&
|
||||
visibleArea
|
||||
) {
|
||||
this.detailView ??= new PDFPageDetailView({ pageView: this });
|
||||
this.detailView ??= new PDFPageDetailView({
|
||||
pageView: this,
|
||||
enableOptimizedPartialRendering: this.enableOptimizedPartialRendering,
|
||||
});
|
||||
this.detailView.update({ visibleArea });
|
||||
} else if (this.detailView) {
|
||||
this.detailView.reset();
|
||||
@ -920,6 +933,8 @@ class PDFPageView extends BasePDFPageView {
|
||||
annotationCanvasMap: this._annotationCanvasMap,
|
||||
pageColors: this.pageColors,
|
||||
isEditing: this.#isEditing,
|
||||
recordOperations:
|
||||
this.enableOptimizedPartialRendering && !this.recordedGroups,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -130,6 +130,10 @@ function isValidAnnotationEditorMode(mode) {
|
||||
* `maxCanvasDim`, it will draw a second canvas on top of the CSS-zoomed one,
|
||||
* that only renders the part of the page that is close to the viewport.
|
||||
* The default value is `true`.
|
||||
* @property {boolean} [enableOptimizedPartialRendering] - When enabled, PDF
|
||||
* rendering will keep track of which areas of the page each PDF operation
|
||||
* affects. Then, when rendering a partial page (if `enableDetailCanvas` is
|
||||
* enabled), it will only run through the operations that affect that portion.
|
||||
* @property {IL10n} [l10n] - Localization service.
|
||||
* @property {boolean} [enablePermissions] - Enables PDF document permissions,
|
||||
* when they exist. The default value is `false`.
|
||||
@ -348,6 +352,8 @@ class PDFViewer {
|
||||
this.maxCanvasDim = options.maxCanvasDim;
|
||||
this.capCanvasAreaFactor = options.capCanvasAreaFactor;
|
||||
this.enableDetailCanvas = options.enableDetailCanvas ?? true;
|
||||
this.enableOptimizedPartialRendering =
|
||||
options.enableOptimizedPartialRendering ?? false;
|
||||
this.l10n = options.l10n;
|
||||
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
|
||||
this.l10n ||= new GenericL10n();
|
||||
@ -1038,6 +1044,8 @@ class PDFViewer {
|
||||
maxCanvasDim: this.maxCanvasDim,
|
||||
capCanvasAreaFactor: this.capCanvasAreaFactor,
|
||||
enableDetailCanvas: this.enableDetailCanvas,
|
||||
enableOptimizedPartialRendering:
|
||||
this.enableOptimizedPartialRendering,
|
||||
pageColors,
|
||||
l10n: this.l10n,
|
||||
layerProperties: this._layerProperties,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user