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:
calixteman 2025-08-22 20:34:03 +02:00 committed by GitHub
commit 673f19bc2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 2557 additions and 211 deletions

View File

@ -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
}
}
}

View File

@ -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

View 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 };

View File

@ -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) {

View File

@ -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];

View File

@ -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,

View File

@ -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

View File

@ -534,6 +534,9 @@ const PDFViewerApplication = {
capCanvasAreaFactor,
enableDetailCanvas: AppOptions.get("enableDetailCanvas"),
enablePermissions: AppOptions.get("enablePermissions"),
enableOptimizedPartialRendering: AppOptions.get(
"enableOptimizedPartialRendering"
),
pageColors,
mlManager,
abortSignal,

View File

@ -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,

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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) {

View File

@ -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();

View File

@ -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,
};
}

View File

@ -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,