Add logic to track rendering area of various PDF ops

This commit is a first step towards #6419, and it can also help with
first compute which ops can affect what is visible in that part of
the page.

This commit adds logic to track operations with their respective
bounding boxes. Only operations that actually cause something to
be rendered have a bounding box and dependencies.

Consider the following example:
```
0. setFillRGBColor
1. beginText
2. showText "Hello"
3. endText
4. constructPath [...] -> eoFill
```
here we have three rendering operations: the showText op (2) and the
path (4). (2) depends on (0), (1) and (3), while (4) only depends on
(0). Both (2) and (4) have a bounding box.

This tracking happens when first rendering a PDF: we then use the
recorded information to optimize future partial renderings of a PDF, so
that we can skip operations that do not affected the PDF area on the
canvas.

All this logic only runs when the new `enableOptimizedPartialRendering`
preference, disabled by default, is enabled.

The bounding boxes and dependencies are also shown in the pdfBug
stepper. When hovering over a step now:
- it highlights the steps that they depend on
- it highlights on the PDF itself the bounding box
This commit is contained in:
Nicolò Ribaudo 2024-11-14 13:04:49 +01:00
parent e20ee99580
commit 6a22da9c2e
No known key found for this signature in database
GPG Key ID: AAFDA9101C58F338
17 changed files with 2557 additions and 211 deletions

View File

@ -238,6 +238,11 @@
"description": "Enable creation of comment annotations.", "description": "Enable creation of comment annotations.",
"type": "boolean", "type": "boolean",
"default": false "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, NodeStandardFontDataFactory,
NodeWasmFactory, NodeWasmFactory,
} from "display-node_utils"; } from "display-node_utils";
import { CanvasDependencyTracker } from "./canvas_dependency_tracker.js";
import { CanvasGraphics } from "./canvas.js"; import { CanvasGraphics } from "./canvas.js";
import { DOMCanvasFactory } from "./canvas_factory.js"; import { DOMCanvasFactory } from "./canvas_factory.js";
import { DOMCMapReaderFactory } from "display-cmap_reader_factory"; import { DOMCMapReaderFactory } from "display-cmap_reader_factory";
@ -1239,6 +1240,10 @@ class PDFDocumentProxy {
* annotation ids with canvases used to render them. * annotation ids with canvases used to render them.
* @property {PrintAnnotationStorage} [printAnnotationStorage] * @property {PrintAnnotationStorage} [printAnnotationStorage]
* @property {boolean} [isEditing] - Render the page in editing mode. * @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._intentStates = new Map();
this.destroyed = false; this.destroyed = false;
this.recordedGroups = null;
} }
/** /**
@ -1433,6 +1439,8 @@ class PDFPageProxy {
pageColors = null, pageColors = null,
printAnnotationStorage = null, printAnnotationStorage = null,
isEditing = false, isEditing = false,
recordOperations = false,
filteredOperationIndexes = null,
}) { }) {
this._stats?.time("Overall"); this._stats?.time("Overall");
@ -1479,9 +1487,26 @@ class PDFPageProxy {
this._pumpOperatorList(intentArgs); this._pumpOperatorList(intentArgs);
} }
const shouldRecordOperations =
!this.recordedGroups &&
(recordOperations ||
(this._pdfBug && globalThis.StepperManager?.enabled));
const complete = error => { const complete = error => {
intentState.renderTasks.delete(internalRenderTask); 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 // Attempt to reduce memory usage during *printing*, by always running
// cleanup immediately once rendering has finished. // cleanup immediately once rendering has finished.
if (intentPrint) { if (intentPrint) {
@ -1516,6 +1541,9 @@ class PDFPageProxy {
params: { params: {
canvas, canvas,
canvasContext, canvasContext,
dependencyTracker: shouldRecordOperations
? new CanvasDependencyTracker(canvas)
: null,
viewport, viewport,
transform, transform,
background, background,
@ -1531,6 +1559,7 @@ class PDFPageProxy {
pdfBug: this._pdfBug, pdfBug: this._pdfBug,
pageColors, pageColors,
enableHWA: this._transport.enableHWA, enableHWA: this._transport.enableHWA,
filteredOperationIndexes,
}); });
(intentState.renderTasks ||= new Set()).add(internalRenderTask); (intentState.renderTasks ||= new Set()).add(internalRenderTask);
@ -3140,6 +3169,7 @@ class InternalRenderTask {
pdfBug = false, pdfBug = false,
pageColors = null, pageColors = null,
enableHWA = false, enableHWA = false,
filteredOperationIndexes = null,
}) { }) {
this.callback = callback; this.callback = callback;
this.params = params; this.params = params;
@ -3170,6 +3200,8 @@ class InternalRenderTask {
this._canvas = params.canvas; this._canvas = params.canvas;
this._canvasContext = params.canvas ? null : params.canvasContext; this._canvasContext = params.canvas ? null : params.canvasContext;
this._enableHWA = enableHWA; this._enableHWA = enableHWA;
this._dependencyTracker = params.dependencyTracker;
this._filteredOperationIndexes = filteredOperationIndexes;
} }
get completed() { get completed() {
@ -3199,7 +3231,7 @@ class InternalRenderTask {
this.stepper.init(this.operatorList); this.stepper.init(this.operatorList);
this.stepper.nextBreakPoint = this.stepper.getNextBreakPoint(); 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 // When printing in Firefox, we get a specific context in mozPrintCallback
// which cannot be created from the canvas itself. // which cannot be created from the canvas itself.
@ -3218,7 +3250,8 @@ class InternalRenderTask {
this.filterFactory, this.filterFactory,
{ optionalContentConfig }, { optionalContentConfig },
this.annotationCanvasMap, this.annotationCanvasMap,
this.pageColors this.pageColors,
dependencyTracker
); );
this.gfx.beginDrawing({ this.gfx.beginDrawing({
transform, transform,
@ -3294,7 +3327,8 @@ class InternalRenderTask {
this.operatorList, this.operatorList,
this.operatorListIdx, this.operatorListIdx,
this._continueBound, this._continueBound,
this.stepper this.stepper,
this._filteredOperationIndexes
); );
if (this.operatorListIdx === this.operatorList.argsArray.length) { if (this.operatorListIdx === this.operatorList.argsArray.length) {
this.running = false; 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); this.setFillAndStrokeStyleToContext(graphics, paintType, color);
tmpCtx.translate(-dimx.scale * x0, -dimy.scale * y0); 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 // To match CanvasGraphics beginDrawing we must save the context here or
// else we end up with unbalanced save/restores. // else we end up with unbalanced save/restores.
tmpCtx.save(); tmpCtx.save();
graphics.dependencyTracker?.save();
this.clipBbox(graphics, x0, y0, x1, y1); this.clipBbox(graphics, x0, y0, x1, y1);
@ -587,6 +600,7 @@ class TilingPattern {
graphics.endDrawing(); graphics.endDrawing();
graphics.dependencyTracker?.restore().recordNestedDependencies?.();
tmpCtx.restore(); tmpCtx.restore();
if (redrawHorizontally || redrawVertically) { if (redrawHorizontally || redrawVertically) {

View File

@ -674,6 +674,10 @@ class Util {
return `#${hexNumbers[r]}${hexNumbers[g]}${hexNumbers[b]}`; 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. // Apply a scaling matrix to some min/max values.
// If a scaling factor is negative then min and max must be // If a scaling factor is negative then min and max must be
// swapped. // 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 // For 2d affine transforms
static applyTransform(p, m, pos = 0) { static applyTransform(p, m, pos = 0) {
const p0 = p[pos]; const p0 = p[pos];

View File

@ -920,7 +920,8 @@ class Driver {
renderPrint = false, renderPrint = false,
renderXfa = false, renderXfa = false,
annotationCanvasMap = null, annotationCanvasMap = null,
pageColors = null; pageColors = null,
partialCrop = null;
if (task.annotationStorage) { if (task.annotationStorage) {
task.pdfDoc.annotationStorage._setValues(task.annotationStorage); task.pdfDoc.annotationStorage._setValues(task.annotationStorage);
@ -968,10 +969,14 @@ class Driver {
textLayerCanvas = null; textLayerCanvas = null;
// We fetch the `eq` specific test subtypes here, to avoid // We fetch the `eq` specific test subtypes here, to avoid
// accidentally changing the behaviour for other types of tests. // accidentally changing the behaviour for other types of tests.
partialCrop = task.partial;
if (!partialCrop) {
renderAnnotations = !!task.annotations; renderAnnotations = !!task.annotations;
renderForms = !!task.forms; renderForms = !!task.forms;
renderPrint = !!task.print; renderPrint = !!task.print;
renderXfa = !!task.enableXfa; renderXfa = !!task.enableXfa;
}
pageColors = task.pageColors || null; pageColors = task.pageColors || null;
// Render the annotation layer if necessary. // Render the annotation layer if necessary.
@ -1031,6 +1036,9 @@ class Driver {
} }
renderContext.intent = "print"; renderContext.intent = "print";
} }
if (partialCrop) {
renderContext.recordOperations = true;
}
const completeRender = error => { const completeRender = error => {
// if text layer is present, compose it on top of the page // if text layer is present, compose it on top of the page
@ -1061,7 +1069,7 @@ class Driver {
this._snapshot(task, error); this._snapshot(task, error);
}; };
initPromise initPromise
.then(data => { .then(async data => {
const renderTask = page.render(renderContext); const renderTask = page.render(renderContext);
if (task.renderTaskOnContinue) { if (task.renderTaskOnContinue) {
@ -1070,7 +1078,104 @@ class Driver {
setTimeout(cont, RENDER_TASK_ON_CONTINUE_DELAY); 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) { if (annotationCanvasMap) {
Rasterize.annotationLayer( Rasterize.annotationLayer(
annotationLayerContext, annotationLayerContext,
@ -1089,7 +1194,6 @@ class Driver {
} else { } else {
completeRender(false); completeRender(false);
} }
});
}) })
.catch(function (error) { .catch(function (error) {
completeRender("render : " + error); completeRender("render : " + error);
@ -1112,11 +1216,16 @@ class Driver {
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
} }
_snapshot(task, failure) { _snapshot(task, failure, baselineDataUrl = null) {
this._log("Snapshotting... "); this._log("Snapshotting... ");
const dataUrl = this.canvas.toDataURL("image/png"); 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( this._log(
"done" + (failure ? " (failed !: " + failure + ")" : "") + "\n" "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({ const result = JSON.stringify({
browser: this.browser, browser: this.browser,
id: task.id, id: task.id,
@ -1181,6 +1290,7 @@ class Driver {
round: task.round, round: task.round,
page: task.pageNum, page: task.pageNum,
snapshot, snapshot,
baselineSnapshot,
stats: task.stats.times, stats: task.stats.times,
viewportWidth: task.viewportWidth, viewportWidth: task.viewportWidth,
viewportHeight: task.viewportHeight, viewportHeight: task.viewportHeight,

View File

@ -460,6 +460,13 @@ function checkEq(task, results, browser, masterMode) {
} else { } else {
console.error("Valid snapshot was not found."); 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 refSnapshot = null;
var eq = false; var eq = false;
@ -526,7 +533,7 @@ function checkEq(task, results, browser, masterMode) {
ensureDirSync(tmpSnapshotDir); ensureDirSync(tmpSnapshotDir);
fs.writeFileSync( fs.writeFileSync(
path.join(tmpSnapshotDir, page + 1 + ".png"), path.join(tmpSnapshotDir, page + 1 + ".png"),
testSnapshot unoptimizedSnapshot ?? testSnapshot
); );
} }
} }
@ -616,7 +623,14 @@ function checkRefTestResults(browser, id, results) {
return; // no results return; // no results
} }
if (pageResult.failure) { 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; failed = true;
}
if (fs.existsSync(task.file + ".error")) { if (fs.existsSync(task.file + ".error")) {
console.log( console.log(
"TEST-SKIPPED | PDF was not downloaded " + "TEST-SKIPPED | PDF was not downloaded " +
@ -631,7 +645,9 @@ function checkRefTestResults(browser, id, results) {
pageResult.failure pageResult.failure
); );
} else { } else {
if (failed) {
session.numErrors++; session.numErrors++;
}
console.log( console.log(
"TEST-UNEXPECTED-FAIL | test failed " + "TEST-UNEXPECTED-FAIL | test failed " +
id + id +
@ -653,6 +669,7 @@ function checkRefTestResults(browser, id, results) {
} }
switch (task.type) { switch (task.type) {
case "eq": case "eq":
case "partial":
case "text": case "text":
case "highlight": case "highlight":
checkEq(task, results, browser, session.masterMode); checkEq(task, results, browser, session.masterMode);
@ -712,6 +729,7 @@ function refTestPostHandler(parsedUrl, req, res) {
var page = data.page - 1; var page = data.page - 1;
var failure = data.failure; var failure = data.failure;
var snapshot = data.snapshot; var snapshot = data.snapshot;
var baselineSnapshot = data.baselineSnapshot;
var lastPageNum = data.lastPageNum; var lastPageNum = data.lastPageNum;
session = getSession(browser); session = getSession(browser);
@ -740,6 +758,7 @@ function refTestPostHandler(parsedUrl, req, res) {
taskResults[round][page] = { taskResults[round][page] = {
failure, failure,
snapshot, snapshot,
baselineSnapshot,
viewportWidth: data.viewportWidth, viewportWidth: data.viewportWidth,
viewportHeight: data.viewportHeight, viewportHeight: data.viewportHeight,
outputScale: data.outputScale, outputScale: data.outputScale,

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -246,6 +246,11 @@ const defaultOptions = {
value: true, value: true,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE, kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
}, },
enableOptimizedPartialRendering: {
/** @type {boolean} */
value: false,
kind: OptionKind.VIEWER + OptionKind.PREFERENCE,
},
enablePermissions: { enablePermissions: {
/** @type {boolean} */ /** @type {boolean} */
value: false, value: false,

View File

@ -36,12 +36,16 @@ class BasePDFPageView {
/** @type {null | HTMLDivElement} */ /** @type {null | HTMLDivElement} */
div = null; div = null;
enableOptimizedPartialRendering = false;
eventBus = null; eventBus = null;
id = null; id = null;
pageColors = null; pageColors = null;
recordedGroups = null;
renderingQueue = null; renderingQueue = null;
renderTask = null; renderTask = null;
@ -53,6 +57,8 @@ class BasePDFPageView {
this.id = options.id; this.id = options.id;
this.pageColors = options.pageColors || null; this.pageColors = options.pageColors || null;
this.renderingQueue = options.renderingQueue; this.renderingQueue = options.renderingQueue;
this.enableOptimizedPartialRendering =
options.enableOptimizedPartialRendering ?? false;
this.#minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500; this.#minDurationToUpdateCanvas = options.minDurationToUpdateCanvas ?? 500;
} }
@ -228,6 +234,9 @@ class BasePDFPageView {
// triggering this callback. // triggering this callback.
if (renderTask === this.renderTask) { if (renderTask === this.renderTask) {
this.renderTask = null; this.renderTask = null;
if (this.enableOptimizedPartialRendering) {
this.recordedGroups ??= renderTask.recordedGroups;
}
} }
} }
this.renderingState = RenderingStates.FINISHED; this.renderingState = RenderingStates.FINISHED;

View File

@ -112,3 +112,34 @@
background-color: rgb(255 255 255 / 0.6); background-color: rgb(255 255 255 / 0.6);
color: rgb(0 0 0); 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, active: false,
// Stepper specific functions. // Stepper specific functions.
create(pageIndex) { create(pageIndex) {
const pageContainer = document.querySelector(
`#viewer div[data-page-number="${pageIndex + 1}"]`
);
const debug = document.createElement("div"); const debug = document.createElement("div");
debug.id = "stepper" + pageIndex; debug.id = "stepper" + pageIndex;
debug.hidden = true; debug.hidden = true;
@ -210,7 +214,12 @@ const StepperManager = (function StepperManagerClosure() {
b.value = pageIndex; b.value = pageIndex;
stepperChooser.append(b); stepperChooser.append(b);
const initBreakPoints = breakPoints[pageIndex] || []; const initBreakPoints = breakPoints[pageIndex] || [];
const stepper = new Stepper(debug, pageIndex, initBreakPoints); const stepper = new Stepper(
debug,
pageIndex,
initBreakPoints,
pageContainer
);
steppers.push(stepper); steppers.push(stepper);
if (steppers.length === 1) { if (steppers.length === 1) {
this.selectStepper(pageIndex, false); this.selectStepper(pageIndex, false);
@ -277,7 +286,7 @@ class Stepper {
return simpleObj; return simpleObj;
} }
constructor(panel, pageIndex, initialBreakPoints) { constructor(panel, pageIndex, initialBreakPoints, pageContainer) {
this.panel = panel; this.panel = panel;
this.breakPoint = 0; this.breakPoint = 0;
this.nextBreakPoint = null; this.nextBreakPoint = null;
@ -286,11 +295,20 @@ class Stepper {
this.currentIdx = -1; this.currentIdx = -1;
this.operatorListIdx = 0; this.operatorListIdx = 0;
this.indentLevel = 0; this.indentLevel = 0;
this.operatorGroups = null;
this.pageContainer = pageContainer;
} }
init(operatorList) { init(operatorList) {
const panel = this.panel; const panel = this.panel;
const content = this.#c("div", "c=continue, s=step"); 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"); const table = this.#c("table");
content.append(table); content.append(table);
table.cellSpacing = 0; table.cellSpacing = 0;
@ -305,6 +323,22 @@ class Stepper {
panel.append(content); panel.append(content);
this.table = table; this.table = table;
this.updateOperatorList(operatorList); 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) { updateOperatorList(operatorList) {
@ -405,6 +439,114 @@ class Stepper {
this.table.append(chunk); 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() { getNextBreakPoint() {
this.breakPoints.sort((a, b) => a - b); this.breakPoints.sort((a, b) => a - b);
for (const breakPoint of this.breakPoints) { for (const breakPoint of this.breakPoints) {

View File

@ -186,6 +186,55 @@ class PDFPageDetailView extends BasePDFPageView {
this.reset({ keepCanvas: true }); 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() { async draw() {
// The PDFPageView might have already dropped this PDFPageDetailView. In // The PDFPageView might have already dropped this PDFPageDetailView. In
// that case, simply do nothing. // that case, simply do nothing.
@ -249,7 +298,7 @@ class PDFPageDetailView extends BasePDFPageView {
style.left = `${(area.minX * 100) / width}%`; style.left = `${(area.minX * 100) / width}%`;
const renderingPromise = this._drawCanvas( const renderingPromise = this._drawCanvas(
this.pageView._getRenderingContext(canvas, transform), this._getRenderingContext(canvas, transform),
() => { () => {
// If the rendering is cancelled, keep the old canvas visible. // If the rendering is cancelled, keep the old canvas visible.
this.canvas?.remove(); 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, * `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. * that only renders the part of the page that is close to the viewport.
* The default value is `true`. * 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 * @property {Object} [pageColors] - Overwrites background and foreground colors
* with user defined ones in order to improve readability in high contrast * with user defined ones in order to improve readability in high contrast
* mode. * mode.
@ -538,6 +543,8 @@ class PDFPageView extends BasePDFPageView {
keepCanvasWrapper = false, keepCanvasWrapper = false,
preserveDetailViewState = false, preserveDetailViewState = false,
} = {}) { } = {}) {
const keepPdfBugGroups = this.pdfPage?._pdfBug ?? false;
this.cancelRendering({ this.cancelRendering({
keepAnnotationLayer, keepAnnotationLayer,
keepAnnotationEditorLayer, keepAnnotationEditorLayer,
@ -566,6 +573,9 @@ class PDFPageView extends BasePDFPageView {
case canvasWrapperNode: case canvasWrapperNode:
continue; continue;
} }
if (keepPdfBugGroups && node.classList.contains("pdfBugGroupsLayer")) {
continue;
}
node.remove(); node.remove();
const layerIndex = this.#layers.indexOf(node); const layerIndex = this.#layers.indexOf(node);
if (layerIndex >= 0) { if (layerIndex >= 0) {
@ -634,7 +644,10 @@ class PDFPageView extends BasePDFPageView {
this.maxCanvasPixels > 0 && this.maxCanvasPixels > 0 &&
visibleArea visibleArea
) { ) {
this.detailView ??= new PDFPageDetailView({ pageView: this }); this.detailView ??= new PDFPageDetailView({
pageView: this,
enableOptimizedPartialRendering: this.enableOptimizedPartialRendering,
});
this.detailView.update({ visibleArea }); this.detailView.update({ visibleArea });
} else if (this.detailView) { } else if (this.detailView) {
this.detailView.reset(); this.detailView.reset();
@ -920,6 +933,8 @@ class PDFPageView extends BasePDFPageView {
annotationCanvasMap: this._annotationCanvasMap, annotationCanvasMap: this._annotationCanvasMap,
pageColors: this.pageColors, pageColors: this.pageColors,
isEditing: this.#isEditing, 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, * `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. * that only renders the part of the page that is close to the viewport.
* The default value is `true`. * 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 {IL10n} [l10n] - Localization service.
* @property {boolean} [enablePermissions] - Enables PDF document permissions, * @property {boolean} [enablePermissions] - Enables PDF document permissions,
* when they exist. The default value is `false`. * when they exist. The default value is `false`.
@ -348,6 +352,8 @@ class PDFViewer {
this.maxCanvasDim = options.maxCanvasDim; this.maxCanvasDim = options.maxCanvasDim;
this.capCanvasAreaFactor = options.capCanvasAreaFactor; this.capCanvasAreaFactor = options.capCanvasAreaFactor;
this.enableDetailCanvas = options.enableDetailCanvas ?? true; this.enableDetailCanvas = options.enableDetailCanvas ?? true;
this.enableOptimizedPartialRendering =
options.enableOptimizedPartialRendering ?? false;
this.l10n = options.l10n; this.l10n = options.l10n;
if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) { if (typeof PDFJSDev === "undefined" || PDFJSDev.test("GENERIC")) {
this.l10n ||= new GenericL10n(); this.l10n ||= new GenericL10n();
@ -1038,6 +1044,8 @@ class PDFViewer {
maxCanvasDim: this.maxCanvasDim, maxCanvasDim: this.maxCanvasDim,
capCanvasAreaFactor: this.capCanvasAreaFactor, capCanvasAreaFactor: this.capCanvasAreaFactor,
enableDetailCanvas: this.enableDetailCanvas, enableDetailCanvas: this.enableDetailCanvas,
enableOptimizedPartialRendering:
this.enableOptimizedPartialRendering,
pageColors, pageColors,
l10n: this.l10n, l10n: this.l10n,
layerProperties: this._layerProperties, layerProperties: this._layerProperties,