From e4ea2e0c790a8f3d238a80b11d56cd7c0258e6a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Ribaudo?= Date: Wed, 27 Aug 2025 11:04:34 +0200 Subject: [PATCH] Store ops bboxes in a linear Uint8Array This PR changes the way we store bounding boxes so that they use less memory and can be more easily shared across threads in the future. Instead of storing the bounding box and list of dependencies for each operation that renders _something_, we now only store the bounding box of _every_ operation and no dependencies list. The bounding box of each operation covers the bounding box of all the operations affected by it that render something. For example, the bounding box of a `setFont` operation will be the bounding box of all the `showText` operations that use that font. This affects the debugging experience in pdfBug, since now the bounding box of an operation may be larger than what it renders itself. To help with this, now when hovering on an operation we also highlight (in red) all its dependents. We highlight with white stripes operations that do not affect any part of the page (i.e. with an empty bbox). To save memory, we now save bounding box x/y coordinates as uint8 rather than float64. This effectively gives us a 256x256 uniform grid that covers the page, which is high enough resolution for the usecase. --- src/display/api.js | 50 +-- src/display/canvas.js | 47 +-- src/display/canvas_dependency_tracker.js | 393 +++++++++++++++-------- src/display/pattern_helper.js | 10 +- test/driver.js | 35 +- test/test_manifest.json | 24 +- web/base_pdf_page_view.js | 4 +- web/debugger.mjs | 155 ++++++--- web/pdf_page_detail_view.js | 35 +- web/pdf_page_view.js | 2 +- 10 files changed, 479 insertions(+), 276 deletions(-) diff --git a/src/display/api.js b/src/display/api.js index 54612a5c9..086755f82 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -1242,8 +1242,8 @@ class PDFDocumentProxy { * @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} [filteredOperationIndexes] - If provided, only run - * the PDF operations that are included in this set. + * @property {(index: number) => boolean} [operationsFilter] - If provided, only + * run for which this function returns `true`. */ /** @@ -1314,7 +1314,7 @@ class PDFPageProxy { this._intentStates = new Map(); this.destroyed = false; - this.recordedGroups = null; + this.recordedBBoxes = null; } /** @@ -1440,7 +1440,7 @@ class PDFPageProxy { printAnnotationStorage = null, isEditing = false, recordOperations = false, - filteredOperationIndexes = null, + operationsFilter = null, }) { this._stats?.time("Overall"); @@ -1487,23 +1487,28 @@ class PDFPageProxy { this._pumpOperatorList(intentArgs); } + const recordForDebugger = Boolean( + this._pdfBug && globalThis.StepperManager?.enabled + ); + const shouldRecordOperations = - !this.recordedGroups && - (recordOperations || - (this._pdfBug && globalThis.StepperManager?.enabled)); + !this.recordedBBoxes && (recordOperations || recordForDebugger); 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; + const recordedBBoxes = internalRenderTask.gfx?.dependencyTracker.take(); + if (recordedBBoxes) { + if (internalRenderTask.stepper) { + internalRenderTask.stepper.setOperatorBBoxes( + recordedBBoxes, + internalRenderTask.gfx.dependencyTracker.takeDebugMetadata() + ); + } + if (recordOperations) { + this.recordedBBoxes = recordedBBoxes; } - } else if (recordOperations) { - this.recordedGroups = []; } } @@ -1542,7 +1547,11 @@ class PDFPageProxy { canvas, canvasContext, dependencyTracker: shouldRecordOperations - ? new CanvasDependencyTracker(canvas) + ? new CanvasDependencyTracker( + canvas, + intentState.operatorList.length, + recordForDebugger + ) : null, viewport, transform, @@ -1559,7 +1568,7 @@ class PDFPageProxy { pdfBug: this._pdfBug, pageColors, enableHWA: this._transport.enableHWA, - filteredOperationIndexes, + operationsFilter, }); (intentState.renderTasks ||= new Set()).add(internalRenderTask); @@ -3169,7 +3178,7 @@ class InternalRenderTask { pdfBug = false, pageColors = null, enableHWA = false, - filteredOperationIndexes = null, + operationsFilter = null, }) { this.callback = callback; this.params = params; @@ -3201,7 +3210,7 @@ class InternalRenderTask { this._canvasContext = params.canvas ? null : params.canvasContext; this._enableHWA = enableHWA; this._dependencyTracker = params.dependencyTracker; - this._filteredOperationIndexes = filteredOperationIndexes; + this._operationsFilter = operationsFilter; } get completed() { @@ -3288,6 +3297,9 @@ class InternalRenderTask { this.graphicsReadyCallback ||= this._continueBound; return; } + this.gfx.dependencyTracker?.growOperationsCount( + this.operatorList.fnArray.length + ); this.stepper?.updateOperatorList(this.operatorList); if (this.running) { @@ -3328,7 +3340,7 @@ class InternalRenderTask { this.operatorListIdx, this._continueBound, this.stepper, - this._filteredOperationIndexes + this._operationsFilter ); if (this.operatorListIdx === this.operatorList.argsArray.length) { this.running = false; diff --git a/src/display/canvas.js b/src/display/canvas.js index 55054a1bb..245d29127 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -763,7 +763,7 @@ class CanvasGraphics { executionStartIdx, continueCallback, stepper, - filteredOperationIndexes + operationsFilter ) { const argsArray = operatorList.argsArray; const fnArray = operatorList.fnArray; @@ -791,7 +791,7 @@ class CanvasGraphics { return i; } - if (!filteredOperationIndexes || filteredOperationIndexes.has(i)) { + if (!operationsFilter || operationsFilter(i)) { fnId = fnArray[i]; // TODO: There is a `undefined` coming from somewhere. fnArgs = argsArray[i] ?? null; @@ -1100,7 +1100,7 @@ class CanvasGraphics { -offsetY, ]); fillCtx.fillStyle = isPatternFill - ? fillColor.getPattern(ctx, this, inverse, PathType.FILL) + ? fillColor.getPattern(ctx, this, inverse, PathType.FILL, opIdx) : fillColor; fillCtx.fillRect(0, 0, width, height); @@ -1549,7 +1549,8 @@ class CanvasGraphics { ctx, this, getCurrentTransformInverse(ctx), - PathType.STROKE + PathType.STROKE, + opIdx ); if (baseTransform) { const newPath = new Path2D(); @@ -1603,7 +1604,8 @@ class CanvasGraphics { ctx, this, getCurrentTransformInverse(ctx), - PathType.FILL + PathType.FILL, + opIdx ); if (baseTransform) { const newPath = new Path2D(); @@ -1759,7 +1761,7 @@ class CanvasGraphics { setFont(opIdx, fontRefName, size) { this.dependencyTracker ?.recordSimpleData("font", opIdx) - .recordNamedDependency(opIdx, fontRefName); + .recordSimpleDataFromNamed("fontObj", fontRefName, opIdx); const fontObj = this.commonObjs.get(fontRefName); const current = this.current; @@ -2034,7 +2036,6 @@ class CanvasGraphics { if (this.dependencyTracker) { this.dependencyTracker .recordDependencies(opIdx, Dependencies.showText) - .copyDependenciesFromIncrementalOperation(opIdx, "sameLineText") .resetBBox(opIdx); if (this.current.textRenderingMode & TextRenderingMode.ADD_TO_PATH_FLAG) { this.dependencyTracker @@ -2047,9 +2048,7 @@ class CanvasGraphics { const font = current.font; if (font.isType3Font) { this.showType3Text(opIdx, glyphs); - this.dependencyTracker - ?.recordOperation(opIdx) - .recordIncrementalData("sameLineText", opIdx); + this.dependencyTracker?.recordShowTextOperation(opIdx); return undefined; } @@ -2095,7 +2094,8 @@ class CanvasGraphics { ctx, this, getCurrentTransformInverse(ctx), - PathType.FILL + PathType.FILL, + opIdx ); patternFillTransform = getCurrentTransform(ctx); ctx.restore(); @@ -2108,7 +2108,8 @@ class CanvasGraphics { ctx, this, getCurrentTransformInverse(ctx), - PathType.STROKE + PathType.STROKE, + opIdx ); patternStrokeTransform = getCurrentTransform(ctx); ctx.restore(); @@ -2157,8 +2158,7 @@ class CanvasGraphics { -measure.actualBoundingBoxAscent, measure.actualBoundingBoxDescent ) - .recordOperation(opIdx) - .recordIncrementalData("sameLineText", opIdx); + .recordShowTextOperation(opIdx); } current.x += width * widthAdvanceScale * textHScale; ctx.restore(); @@ -2277,9 +2277,7 @@ class CanvasGraphics { ctx.restore(); this.compose(); - this.dependencyTracker - ?.recordOperation(opIdx) - .recordIncrementalData("sameLineText", opIdx); + this.dependencyTracker?.recordShowTextOperation(opIdx); return undefined; } @@ -2351,7 +2349,6 @@ class CanvasGraphics { } ctx.restore(); if (dependencyTracker) { - this.dependencyTracker.recordNestedDependencies(); this.dependencyTracker = dependencyTracker; } } @@ -2378,7 +2375,7 @@ class CanvasGraphics { if (IR[0] === "TilingPattern") { const baseTransform = this.baseTransform || getCurrentTransform(this.ctx); const canvasGraphicsFactory = { - createCanvasGraphics: ctx => + createCanvasGraphics: (ctx, renderingOpIdx) => new CanvasGraphics( ctx, this.commonObjs, @@ -2392,7 +2389,11 @@ class CanvasGraphics { undefined, undefined, this.dependencyTracker - ? new CanvasNestedDependencyTracker(this.dependencyTracker, opIdx) + ? new CanvasNestedDependencyTracker( + this.dependencyTracker, + renderingOpIdx, + /* ignoreBBoxes */ true + ) : null ), }; @@ -2470,7 +2471,8 @@ class CanvasGraphics { ctx, this, getCurrentTransformInverse(ctx), - PathType.SHADING + PathType.SHADING, + opIdx ); const inv = getCurrentTransformInverse(ctx); @@ -2937,7 +2939,8 @@ class CanvasGraphics { maskCtx, this, getCurrentTransformInverse(ctx), - PathType.FILL + PathType.FILL, + opIdx ) : fillColor; maskCtx.fillRect(0, 0, width, height); diff --git a/src/display/canvas_dependency_tracker.js b/src/display/canvas_dependency_tracker.js index 2b5ff3ef0..0d391c406 100644 --- a/src/display/canvas_dependency_tracker.js +++ b/src/display/canvas_dependency_tracker.js @@ -2,10 +2,70 @@ import { Util } from "../shared/util.js"; const FORCED_DEPENDENCY_LABEL = "__forcedDependency"; +const { floor, ceil } = Math; + +function expandBBox(array, index, minX, minY, maxX, maxY) { + array[index * 4 + 0] = Math.min(array[index * 4 + 0], minX); + array[index * 4 + 1] = Math.min(array[index * 4 + 1], minY); + array[index * 4 + 2] = Math.max(array[index * 4 + 2], maxX); + array[index * 4 + 3] = Math.max(array[index * 4 + 3], maxY); +} + +// This is computed rathter than hard-coded to keep into +// account the platform's endianess. +const EMPTY_BBOX = new Uint32Array(new Uint8Array([255, 255, 0, 0]).buffer)[0]; + +class BBoxReader { + #bboxes; + + #coords; + + constructor(bboxes, coords) { + this.#bboxes = bboxes; + this.#coords = coords; + } + + get length() { + return this.#bboxes.length; + } + + isEmpty(i) { + return this.#bboxes[i] === EMPTY_BBOX; + } + + minX(i) { + return this.#coords[i * 4 + 0] / 256; + } + + minY(i) { + return this.#coords[i * 4 + 1] / 256; + } + + maxX(i) { + return (this.#coords[i * 4 + 2] + 1) / 256; + } + + maxY(i) { + return (this.#coords[i * 4 + 3] + 1) / 256; + } +} + +const ensureDebugMetadata = (map, key) => { + if (!map) { + return undefined; + } + let value = map.get(key); + if (!value) { + value = { dependencies: new Set(), isRenderingOperation: false }; + map.set(key, value); + } + return value; +}; + /** * @typedef {"lineWidth" | "lineCap" | "lineJoin" | "miterLimit" | "dash" | * "strokeAlpha" | "fillColor" | "fillAlpha" | "globalCompositeOperation" | - * "path" | "filter"} SimpleDependency + * "path" | "filter" | "font" | "fontObj"} SimpleDependency */ /** @@ -54,9 +114,38 @@ class CanvasDependencyTracker { #canvasHeight; - constructor(canvas) { + // Uint8ClampedArray + #bboxesCoords; + + #bboxes; + + #debugMetadata; + + constructor(canvas, operationsCount, recordDebugMetadata = false) { this.#canvasWidth = canvas.width; this.#canvasHeight = canvas.height; + this.#initializeBBoxes(operationsCount); + if (recordDebugMetadata) { + this.#debugMetadata = new Map(); + } + } + + growOperationsCount(operationsCount) { + if (operationsCount >= this.#bboxes.length) { + this.#initializeBBoxes(operationsCount, this.#bboxes); + } + } + + #initializeBBoxes(operationsCount, oldBBoxes) { + const buffer = new ArrayBuffer(operationsCount * 4); + this.#bboxesCoords = new Uint8ClampedArray(buffer); + this.#bboxes = new Uint32Array(buffer); + if (oldBBoxes && oldBBoxes.length > 0) { + this.#bboxes.set(oldBBoxes); + this.#bboxes.fill(EMPTY_BBOX, oldBBoxes.length); + } else { + this.#bboxes.fill(EMPTY_BBOX); + } } save(opIdx) { @@ -71,7 +160,7 @@ class CanvasDependencyTracker { }, }; this.#clipBox = { __proto__: this.#clipBox }; - this.#savesStack.push([opIdx, null]); + this.#savesStack.push(opIdx); return this; } @@ -87,9 +176,12 @@ class CanvasDependencyTracker { this.#incremental = Object.getPrototypeOf(this.#incremental); this.#clipBox = Object.getPrototypeOf(this.#clipBox); - const lastPair = this.#savesStack.pop(); - if (lastPair !== undefined) { - lastPair[1] = opIdx; + const lastSave = this.#savesStack.pop(); + if (lastSave !== undefined) { + ensureDebugMetadata(this.#debugMetadata, opIdx)?.dependencies.add( + lastSave + ); + this.#bboxes[opIdx] = this.#bboxes[lastSave]; } return this; @@ -99,7 +191,7 @@ class CanvasDependencyTracker { * @param {number} idx */ recordOpenMarker(idx) { - this.#savesStack.push([idx, null]); + this.#savesStack.push(idx); return this; } @@ -107,13 +199,16 @@ class CanvasDependencyTracker { if (this.#savesStack.length === 0) { return null; } - return this.#savesStack.at(-1)[0]; + return this.#savesStack.at(-1); } - recordCloseMarker(idx) { - const lastPair = this.#savesStack.pop(); - if (lastPair !== undefined) { - lastPair[1] = idx; + recordCloseMarker(opIdx) { + const lastSave = this.#savesStack.pop(); + if (lastSave !== undefined) { + ensureDebugMetadata(this.#debugMetadata, opIdx)?.dependencies.add( + lastSave + ); + this.#bboxes[opIdx] = this.#bboxes[lastSave]; } return this; } @@ -121,14 +216,17 @@ class CanvasDependencyTracker { // Marked content needs a separate stack from save/restore, because they // form two independent trees. beginMarkedContent(opIdx) { - this.#markedContentStack.push([opIdx, null]); + this.#markedContentStack.push(opIdx); return this; } endMarkedContent(opIdx) { - const lastPair = this.#markedContentStack.pop(); - if (lastPair !== undefined) { - lastPair[1] = opIdx; + const lastSave = this.#markedContentStack.pop(); + if (lastSave !== undefined) { + ensureDebugMetadata(this.#debugMetadata, opIdx)?.dependencies.add( + lastSave + ); + this.#bboxes[opIdx] = this.#bboxes[lastSave]; } return this; } @@ -182,6 +280,15 @@ class CanvasDependencyTracker { return this; } + /** + * @param {SimpleDependency} name + * @param {string} depName + * @param {number} fallbackIdx + */ + recordSimpleDataFromNamed(name, depName, fallbackIdx) { + this.#simple[name] = this.#namedDependencies.get(depName) ?? fallbackIdx; + } + // All next operations, until the next .restore(), will depend on this recordFutureForcedDependency(name, idx) { this.recordIncrementalData(FORCED_DEPENDENCY_LABEL, idx); @@ -207,18 +314,16 @@ class CanvasDependencyTracker { } resetBBox(idx) { - this.#pendingBBoxIdx = idx; - this.#pendingBBox[0] = Infinity; - this.#pendingBBox[1] = Infinity; - this.#pendingBBox[2] = -Infinity; - this.#pendingBBox[3] = -Infinity; + if (this.#pendingBBoxIdx !== 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), @@ -384,20 +489,6 @@ class CanvasDependencyTracker { 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)); @@ -409,38 +500,74 @@ class CanvasDependencyTracker { /** * @param {number} idx */ - recordOperation(idx, preserveBbox = false) { + recordOperation(idx, preserve = 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; + + if (this.#debugMetadata) { + const metadata = ensureDebugMetadata(this.#debugMetadata, idx); + const { dependencies } = metadata; + this.#pendingDependencies.forEach(dependencies.add, dependencies); + this.#savesStack.forEach(dependencies.add, dependencies); + this.#markedContentStack.forEach(dependencies.add, dependencies); + dependencies.delete(idx); + metadata.isRenderingOperation = true; + } + + if (this.#pendingBBoxIdx === idx) { + const minX = floor((this.#pendingBBox[0] * 256) / this.#canvasWidth); + const minY = floor((this.#pendingBBox[1] * 256) / this.#canvasHeight); + const maxX = ceil((this.#pendingBBox[2] * 256) / this.#canvasWidth); + const maxY = ceil((this.#pendingBBox[3] * 256) / this.#canvasHeight); + + expandBBox(this.#bboxesCoords, idx, minX, minY, maxX, maxY); + for (const depIdx of this.#pendingDependencies) { + if (depIdx !== idx) { + expandBBox(this.#bboxesCoords, depIdx, minX, minY, maxX, maxY); + } + } + for (const saveIdx of this.#savesStack) { + if (saveIdx !== idx) { + expandBBox(this.#bboxesCoords, saveIdx, minX, minY, maxX, maxY); + } + } + for (const saveIdx of this.#markedContentStack) { + if (saveIdx !== idx) { + expandBBox(this.#bboxesCoords, saveIdx, minX, minY, maxX, maxY); + } + } + + if (!preserve) { + this.#pendingDependencies.clear(); + this.#pendingBBoxIdx = -1; + } } - this.#pendingDependencies.clear(); return this; } - bboxToClipBoxDropOperation(idx) { - if (this.#pendingBBoxIdx !== -1) { + recordShowTextOperation(idx, preserve = false) { + const deps = Array.from(this.#pendingDependencies); + this.recordOperation(idx, preserve); + this.recordIncrementalData("sameLineText", idx); + for (const dep of deps) { + this.recordIncrementalData("sameLineText", dep); + } + return this; + } + + bboxToClipBoxDropOperation(idx, preserve = false) { + if (this.#pendingBBoxIdx === idx) { 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]); + + if (!preserve) { + this.#pendingDependencies.clear(); + } } - this.#pendingDependencies.clear(); return this; } @@ -464,21 +591,11 @@ class CanvasDependencyTracker { 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, - }; - } - ); + return new BBoxReader(this.#bboxes, this.#bboxesCoords); + } + + takeDebugMetadata() { + return this.#debugMetadata; } } @@ -496,14 +613,17 @@ class CanvasNestedDependencyTracker { /** @type {number} */ #opIdx; - #nestingLevel = 0; + #ignoreBBoxes; - #outerDependencies; + #nestingLevel = 0; #savesLevel = 0; - constructor(dependencyTracker, opIdx) { - if (dependencyTracker instanceof CanvasNestedDependencyTracker) { + constructor(dependencyTracker, opIdx, ignoreBBoxes) { + if ( + dependencyTracker instanceof CanvasNestedDependencyTracker && + dependencyTracker.#ignoreBBoxes === !!ignoreBBoxes + ) { // The goal of CanvasNestedDependencyTracker is to collapse all operations // into a single one. If we are already in a // CanvasNestedDependencyTracker, that is already happening. @@ -511,8 +631,12 @@ class CanvasNestedDependencyTracker { } this.#dependencyTracker = dependencyTracker; - this.#outerDependencies = dependencyTracker._takePendingDependencies(); this.#opIdx = opIdx; + this.#ignoreBBoxes = !!ignoreBBoxes; + } + + growOperationsCount() { + throw new Error("Unreachable"); } save(opIdx) { @@ -595,6 +719,20 @@ class CanvasNestedDependencyTracker { return this; } + /** + * @param {SimpleDependency} name + * @param {string} depName + * @param {number} fallbackIdx + */ + recordSimpleDataFromNamed(name, depName, fallbackIdx) { + this.#dependencyTracker.recordSimpleDataFromNamed( + name, + depName, + this.#opIdx + ); + return this; + } + // All next operations, until the next .restore(), will depend on this recordFutureForcedDependency(name, idx) { this.#dependencyTracker.recordFutureForcedDependency(name, this.#opIdx); @@ -614,55 +752,59 @@ class CanvasNestedDependencyTracker { } resetBBox(idx) { - if (!this.#dependencyTracker.hasPendingBBox) { + if (!this.#ignoreBBoxes) { 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 - ); + if (!this.#ignoreBBoxes) { + 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 - ); + if (!this.#ignoreBBoxes) { + 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 - ); + if (!this.#ignoreBBoxes) { + this.#dependencyTracker.recordCharacterBBox( + this.#opIdx, + ctx, + font, + scale, + x, + y, + getMeasure + ); + } return this; } recordFullPageBBox(idx) { - this.#dependencyTracker.recordFullPageBBox(this.#opIdx); + if (!this.#ignoreBBoxes) { + this.#dependencyTracker.recordFullPageBBox(this.#opIdx); + } return this; } @@ -675,14 +817,6 @@ class CanvasNestedDependencyTracker { return this; } - copyDependenciesFromIncrementalOperation(idx, name) { - this.#dependencyTracker.copyDependenciesFromIncrementalOperation( - this.#opIdx, - name - ); - return this; - } - recordNamedDependency(idx, name) { this.#dependencyTracker.recordNamedDependency(this.#opIdx, name); return this; @@ -694,25 +828,26 @@ class CanvasNestedDependencyTracker { */ 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; + } + + recordShowTextOperation(idx) { + this.#dependencyTracker.recordShowTextOperation(this.#opIdx, true); return this; } bboxToClipBoxDropOperation(idx) { - this.#dependencyTracker.bboxToClipBoxDropOperation(this.#opIdx); + if (!this.#ignoreBBoxes) { + this.#dependencyTracker.bboxToClipBoxDropOperation(this.#opIdx, true); + } return this; } - recordNestedDependencies() { - this.#dependencyTracker._pushPendingDependencies(this.#outerDependencies); + take() { + throw new Error("Unreachable"); } - take() { + takeDebugMetadata() { throw new Error("Unreachable"); } } @@ -759,6 +894,7 @@ const Dependencies = { "moveText", "textMatrix", "font", + "fontObj", "filter", "fillColor", "textRenderingMode", @@ -766,7 +902,8 @@ const Dependencies = { "fillAlpha", "strokeAlpha", "globalCompositeOperation", - // TODO: More + + "sameLineText", ], transform: ["transform"], transformAndFill: ["transform", "fillColor"], diff --git a/src/display/pattern_helper.js b/src/display/pattern_helper.js index 6bb6fe120..7d9b8c9f7 100644 --- a/src/display/pattern_helper.js +++ b/src/display/pattern_helper.js @@ -478,7 +478,7 @@ class TilingPattern { this.baseTransform = baseTransform; } - createPatternCanvas(owner) { + createPatternCanvas(owner, opIdx) { const { bbox, operatorList, @@ -567,7 +567,7 @@ class TilingPattern { dimy.size ); const tmpCtx = tmpCanvas.context; - const graphics = canvasGraphicsFactory.createCanvasGraphics(tmpCtx); + const graphics = canvasGraphicsFactory.createCanvasGraphics(tmpCtx, opIdx); graphics.groupLevel = owner.groupLevel; this.setFillAndStrokeStyleToContext(graphics, paintType, color); @@ -600,7 +600,7 @@ class TilingPattern { graphics.endDrawing(); - graphics.dependencyTracker?.restore().recordNestedDependencies?.(); + graphics.dependencyTracker?.restore(); tmpCtx.restore(); if (redrawHorizontally || redrawVertically) { @@ -726,7 +726,7 @@ class TilingPattern { return false; } - getPattern(ctx, owner, inverse, pathType) { + getPattern(ctx, owner, inverse, pathType, opIdx) { // PDF spec 8.7.2 NOTE 1: pattern's matrix is relative to initial matrix. let matrix = inverse; if (pathType !== PathType.SHADING) { @@ -736,7 +736,7 @@ class TilingPattern { } } - const temporaryPatternCanvas = this.createPatternCanvas(owner); + const temporaryPatternCanvas = this.createPatternCanvas(owner, opIdx); let domMatrix = new DOMMatrix(matrix); // Rescale and so that the ctx.createPattern call generates a pattern with diff --git a/test/driver.js b/test/driver.js index 4a46695e8..6333421f2 100644 --- a/test/driver.js +++ b/test/driver.js @@ -1117,28 +1117,7 @@ class Driver { 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 recordedBBoxes = page.recordedBBoxes; const partialRenderContext = { canvasContext: ctx, @@ -1149,7 +1128,17 @@ class Driver { pageColors, transform, recordOperations: false, - filteredOperationIndexes: filteredIndexes, + operationsFilter(index) { + if (recordedBBoxes.isEmpty(index)) { + return false; + } + return ( + recordedBBoxes.minX(index) <= partialCrop.maxX && + recordedBBoxes.maxX(index) >= partialCrop.minX && + recordedBBoxes.minY(index) <= partialCrop.maxY && + recordedBBoxes.maxY(index) >= partialCrop.minY + ); + }, }; const partialRenderTask = page.render(partialRenderContext); diff --git a/test/test_manifest.json b/test/test_manifest.json index b83b54648..90f5883a0 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -541,8 +541,7 @@ "maxX": 0.5, "minY": 0.25, "maxY": 0.5 - }, - "knownPartialMismatch": true + } }, { "id": "bug1753983", @@ -673,8 +672,7 @@ "maxX": 0.5, "minY": 0.25, "maxY": 0.5 - }, - "knownPartialMismatch": true + } }, { "id": "bug1473809", @@ -780,8 +778,7 @@ "maxX": 0.5, "minY": 0.25, "maxY": 0.5 - }, - "knownPartialMismatch": "Gives slightly different results in Firefox when running in headless mode" + } }, { "id": "issue2128", @@ -1556,8 +1553,7 @@ "maxX": 0.5, "minY": 0.25, "maxY": 0.5 - }, - "knownPartialMismatch": "Gives slightly different results in Firefox when running in headless mode" + } }, { "id": "bug1811668", @@ -2904,8 +2900,7 @@ "maxX": 0.5, "minY": 0.25, "maxY": 0.5 - }, - "knownPartialMismatch": true + } }, { "id": "bug1245391-text", @@ -3250,8 +3245,7 @@ "maxX": 0.5, "minY": 0.25, "maxY": 0.5 - }, - "knownPartialMismatch": true + } }, { "id": "bug1337429", @@ -8080,8 +8074,7 @@ "maxX": 0.5, "minY": 0.25, "maxY": 0.5 - }, - "knownPartialMismatch": "Half pixel rendering at the edge of the image" + } }, { "id": "issue16454", @@ -11270,8 +11263,7 @@ "maxX": 0.5, "minY": 0.25, "maxY": 0.5 - }, - "knownPartialMismatch": "One-bit difference in two pixels at the edge" + } }, { "id": "issue14724", diff --git a/web/base_pdf_page_view.js b/web/base_pdf_page_view.js index 116ac1a55..4c8ab5e95 100644 --- a/web/base_pdf_page_view.js +++ b/web/base_pdf_page_view.js @@ -44,7 +44,7 @@ class BasePDFPageView { pageColors = null; - recordedGroups = null; + recordedBBoxes = null; renderingQueue = null; @@ -235,7 +235,7 @@ class BasePDFPageView { if (renderTask === this.renderTask) { this.renderTask = null; if (this.enableOptimizedPartialRendering) { - this.recordedGroups ??= renderTask.recordedGroups; + this.recordedBBoxes ??= renderTask.recordedBBoxes; } } } diff --git a/web/debugger.mjs b/web/debugger.mjs index 91300d624..310ec83a6 100644 --- a/web/debugger.mjs +++ b/web/debugger.mjs @@ -295,7 +295,7 @@ class Stepper { this.currentIdx = -1; this.operatorListIdx = 0; this.indentLevel = 0; - this.operatorGroups = null; + this.operatorsGroups = null; this.pageContainer = pageContainer; } @@ -439,7 +439,7 @@ class Stepper { this.table.append(chunk); } - setOperatorGroups(groups) { + setOperatorBBoxes(bboxes, metadata) { let boxesContainer = this.pageContainer.querySelector(".pdfBugGroupsLayer"); if (!boxesContainer) { boxesContainer = this.#c("div"); @@ -457,12 +457,55 @@ class Stepper { } boxesContainer.innerHTML = ""; - groups = groups.toSorted((a, b) => { + const dependents = new Map(); + for (const [dependentIdx, { dependencies: ownDependencies }] of metadata) { + for (const dependencyIdx of ownDependencies) { + let ownDependents = dependents.get(dependencyIdx); + if (!ownDependents) { + dependents.set(dependencyIdx, (ownDependents = new Set())); + } + ownDependents.add(dependentIdx); + } + } + + const groups = Array.from({ length: bboxes.length }, (_, i) => { + let minX = null; + let minY = null; + let maxX = null; + let maxY = null; + if (!bboxes.isEmpty(i)) { + minX = bboxes.minX(i); + minY = bboxes.minY(i); + maxX = bboxes.maxX(i); + maxY = bboxes.maxY(i); + } + + return { + minX, + minY, + maxX, + maxY, + dependencies: Array.from(metadata.get(i)?.dependencies ?? []).sort(), + dependents: Array.from(dependents.get(i) ?? []).sort(), + isRenderingOperation: metadata.get(i)?.isRenderingOperation ?? false, + idx: i, + }; + }); + this.operatorsGroups = groups; + + const operatorsGroupsByZindex = groups.toSorted((a, b) => { + if (a.minX === null) { + return b.minX === null ? 0 : 1; + } + if (b.minX === null) { + return -1; + } + const diffs = [ - a.minX - b.minX, a.minY - b.minY, - b.maxX - a.maxX, + a.minX - b.minX, b.maxY - a.maxY, + b.maxX - a.maxX, ]; for (const diff of diffs) { if (Math.abs(diff) > 0.01) { @@ -476,16 +519,18 @@ class Stepper { } 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); + for (let i = 0; i < operatorsGroupsByZindex.length; i++) { + const group = operatorsGroupsByZindex[i]; + if (group.minX !== null) { + const el = this.#c("div"); + el.style.left = `${group.minX * 100}%`; + el.style.top = `${group.minY * 100}%`; + el.style.width = `${(group.maxX - group.minX) * 100}%`; + el.style.height = `${(group.maxY - group.minY) * 100}%`; + el.dataset.idx = group.idx; + boxesContainer.append(el); + } } } @@ -496,51 +541,85 @@ class Stepper { } const index = +tr.dataset.idx; - - const closestGroupIndex = - this.operatorGroups?.findIndex(({ idx }) => idx === index) ?? -1; - if (closestGroupIndex === -1) { - this.hoverStyle.innerText = ""; - return; - } - - this.#highlightStepsGroup(closestGroupIndex); + this.#highlightStepsGroup(index); } #handleDebugBoxHover(e) { - if (e.target.dataset.groupIdx === undefined) { + if (e.target.dataset.idx === undefined) { return; } - const groupIdx = Number(e.target.dataset.groupIdx); - this.#highlightStepsGroup(groupIdx); + const idx = Number(e.target.dataset.idx); + this.#highlightStepsGroup(idx); } #handleDebugBoxClick(e) { - if (e.target.dataset.groupIdx === undefined) { + if (e.target.dataset.idx === undefined) { return; } - const groupIdx = Number(e.target.dataset.groupIdx); - const group = this.operatorGroups[groupIdx]; + const idx = Number(e.target.dataset.idx); - this.table.childNodes[group.idx].scrollIntoView(); + this.table.childNodes[idx].scrollIntoView(); } - #highlightStepsGroup(groupIndex) { - const group = this.operatorGroups[groupIndex]; + #highlightStepsGroup(index) { + const group = this.operatorsGroups?.[index]; + if (!group) { + return; + } - this.hoverStyle.innerText = `#${this.panel.id} tr[data-idx="${group.idx}"] { background-color: rgba(0, 0, 0, 0.1); }`; + const renderingColor = `rgba(0, 0, 0, 0.1)`; + const dependencyColor = `rgba(0, 255, 255, 0.1)`; + const dependentColor = `rgba(255, 0, 0, 0.1)`; + const solid = color => `background-color: ${color}`; + // Used for operations that have an empty bounding box + const striped = color => ` + background-image: repeating-linear-gradient( + -45deg, + white, + white 10px, + ${color} 10px, + ${color} 20px + ) + `; + + const select = idx => `#${this.panel.id} tr[data-idx="${idx}"]`; + const selectN = idxs => + idxs.length === 0 ? "q:not(q)" : idxs.map(select).join(", "); + + const isEmpty = idx => + !this.operatorsGroups[idx] || this.operatorsGroups[idx].minX === null; + + const selfColor = group.isRenderingOperation + ? renderingColor + : dependentColor; + + this.hoverStyle.innerText = `${select(index)} { + ${group.minX === null ? striped(selfColor) : solid(selfColor)} + }`; 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 += ` + ${selectN(group.dependencies.filter(idx => !isEmpty(idx)))} { + ${solid(dependencyColor)} + } + ${selectN(group.dependencies.filter(isEmpty))} { + ${striped(dependencyColor)} + }`; + } + if (group.dependents.length > 0) { + this.hoverStyle.innerText += ` + ${selectN(group.dependents.filter(idx => !isEmpty(idx)))} { + ${solid(dependentColor)} + } + ${selectN(group.dependents.filter(isEmpty))} { + ${striped(dependentColor)} + }`; } this.hoverStyle.innerText += ` - #viewer [data-page-number="${this.pageIndex + 1}"] .pdfBugGroupsLayer :nth-child(${groupIndex + 1}) { + #viewer [data-page-number="${this.pageIndex + 1}"] .pdfBugGroupsLayer [data-idx="${index}"] { background-color: var(--hover-background-color); outline-style: var(--hover-outline-style); } diff --git a/web/pdf_page_detail_view.js b/web/pdf_page_detail_view.js index 52cf13c45..d3fedc6b4 100644 --- a/web/pdf_page_detail_view.js +++ b/web/pdf_page_detail_view.js @@ -188,18 +188,12 @@ class PDFPageDetailView extends BasePDFPageView { _getRenderingContext(canvas, transform) { const baseContext = this.pageView._getRenderingContext(canvas, transform); - const recordedGroups = this.pdfPage.recordedGroups; + const recordedBBoxes = this.pdfPage.recordedBBoxes; - if (!recordedGroups || !this.enableOptimizedPartialRendering) { + if (!recordedBBoxes || !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; @@ -215,23 +209,20 @@ class PDFPageDetailView extends BasePDFPageView { 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, + operationsFilter(index) { + if (recordedBBoxes.isEmpty(index)) { + return false; + } + return ( + recordedBBoxes.minX(index) <= detailMaxX && + recordedBBoxes.maxX(index) >= detailMinX && + recordedBBoxes.minY(index) <= detailMaxY && + recordedBBoxes.maxY(index) >= detailMinY + ); + }, }; } diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 8a983c0ef..9c0a2643b 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -934,7 +934,7 @@ class PDFPageView extends BasePDFPageView { pageColors: this.pageColors, isEditing: this.#isEditing, recordOperations: - this.enableOptimizedPartialRendering && !this.recordedGroups, + this.enableOptimizedPartialRendering && !this.recordedBBoxes, }; }