From 6a22da9c2ee0c165eefad42eac0e9d2fd94cb680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Ribaudo?= Date: Thu, 14 Nov 2024 13:04:49 +0100 Subject: [PATCH] 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 --- extensions/chromium/preferences_schema.json | 5 + src/display/api.js | 40 +- src/display/canvas.js | 655 +++++++++++----- src/display/canvas_dependency_tracker.js | 775 +++++++++++++++++++ src/display/pattern_helper.js | 16 +- src/shared/util.js | 16 + test/driver.js | 166 +++- test/test.mjs | 25 +- test/test_manifest.json | 800 ++++++++++++++++++++ web/app.js | 3 + web/app_options.js | 5 + web/base_pdf_page_view.js | 9 + web/debugger.css | 31 + web/debugger.mjs | 146 +++- web/pdf_page_detail_view.js | 51 +- web/pdf_page_view.js | 17 +- web/pdf_viewer.js | 8 + 17 files changed, 2557 insertions(+), 211 deletions(-) create mode 100644 src/display/canvas_dependency_tracker.js diff --git a/extensions/chromium/preferences_schema.json b/extensions/chromium/preferences_schema.json index 34d72e258..974b00730 100644 --- a/extensions/chromium/preferences_schema.json +++ b/extensions/chromium/preferences_schema.json @@ -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 } } } diff --git a/src/display/api.js b/src/display/api.js index a384a819e..54612a5c9 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -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} [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; diff --git a/src/display/canvas.js b/src/display/canvas.js index 0cadf5d0a..55054a1bb 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -13,6 +13,10 @@ * limitations under the License. */ +import { + CanvasNestedDependencyTracker, + Dependencies, +} from "./canvas_dependency_tracker.js"; import { DrawOPS, FeatureTest, @@ -359,7 +363,9 @@ class CanvasExtraState { transferMaps = "none"; - constructor(width, height) { + constructor(width, height, preInit) { + preInit?.(this); + this.clipBox = new Float32Array([0, 0, width, height]); this.minMax = MIN_MAX_INIT.slice(); } @@ -650,7 +656,8 @@ class CanvasGraphics { filterFactory, { optionalContentConfig, markedContentStack = null }, annotationCanvasMap, - pageColors + pageColors, + dependencyTracker ) { this.ctx = canvasCtx; this.current = new CanvasExtraState( @@ -690,10 +697,13 @@ class CanvasGraphics { this._cachedScaleForStroking = [-1, 0]; this._cachedGetSinglePixelWidth = null; this._cachedBitmapsMap = new Map(); + + this.dependencyTracker = dependencyTracker ?? null; } - getObject(data, fallback = null) { + getObject(opIdx, data, fallback = null) { if (typeof data === "string") { + this.dependencyTracker?.recordNamedDependency(opIdx, data); return data.startsWith("g_") ? this.commonObjs.get(data) : this.objs.get(data); @@ -752,7 +762,8 @@ class CanvasGraphics { operatorList, executionStartIdx, continueCallback, - stepper + stepper, + filteredOperationIndexes ) { const argsArray = operatorList.argsArray; const fnArray = operatorList.fnArray; @@ -772,7 +783,7 @@ class CanvasGraphics { const commonObjs = this.commonObjs; const objs = this.objs; - let fnId; + let fnId, fnArgs; while (true) { if (stepper !== undefined && i === stepper.nextBreakPoint) { @@ -780,20 +791,28 @@ class CanvasGraphics { return i; } - fnId = fnArray[i]; + if (!filteredOperationIndexes || filteredOperationIndexes.has(i)) { + fnId = fnArray[i]; + // TODO: There is a `undefined` coming from somewhere. + fnArgs = argsArray[i] ?? null; - if (fnId !== OPS.dependency) { - // eslint-disable-next-line prefer-spread - this[fnId].apply(this, argsArray[i]); - } else { - for (const depObjId of argsArray[i]) { - const objsPool = depObjId.startsWith("g_") ? commonObjs : objs; + if (fnId !== OPS.dependency) { + if (fnArgs === null) { + this[fnId](i); + } else { + this[fnId](i, ...fnArgs); + } + } else { + for (const depObjId of fnArgs) { + this.dependencyTracker?.recordNamedData(depObjId, i); + const objsPool = depObjId.startsWith("g_") ? commonObjs : objs; - // If the promise isn't resolved yet, add the continueCallback - // to the promise and bail out. - if (!objsPool.has(depObjId)) { - objsPool.get(depObjId, continueCallback); - return i; + // If the promise isn't resolved yet, add the continueCallback + // to the promise and bail out. + if (!objsPool.has(depObjId)) { + objsPool.get(depObjId, continueCallback); + return i; + } } } } @@ -950,7 +969,7 @@ class CanvasGraphics { }; } - _createMaskCanvas(img) { + _createMaskCanvas(opIdx, img) { const ctx = this.ctx; const { width, height } = img; const fillColor = this.current.fillColor; @@ -987,6 +1006,11 @@ class CanvasGraphics { Math.min(currentTransform[1], currentTransform[3]) + currentTransform[5] ); + + this.dependencyTracker?.recordDependencies( + opIdx, + Dependencies.transformAndFill + ); return { canvas: cachedImage, offsetX, @@ -1088,6 +1112,11 @@ class CanvasGraphics { cache.set(cacheKey, fillCanvas.canvas); } + this.dependencyTracker?.recordDependencies( + opIdx, + Dependencies.transformAndFill + ); + // Round the offsets to avoid drawing fractional pixels. return { canvas: fillCanvas.canvas, @@ -1097,7 +1126,8 @@ class CanvasGraphics { } // Graphics state - setLineWidth(width) { + setLineWidth(opIdx, width) { + this.dependencyTracker?.recordSimpleData("lineWidth", opIdx); if (width !== this.current.lineWidth) { this._cachedScaleForStroking[0] = -1; } @@ -1105,19 +1135,23 @@ class CanvasGraphics { this.ctx.lineWidth = width; } - setLineCap(style) { + setLineCap(opIdx, style) { + this.dependencyTracker?.recordSimpleData("lineCap", opIdx); this.ctx.lineCap = LINE_CAP_STYLES[style]; } - setLineJoin(style) { + setLineJoin(opIdx, style) { + this.dependencyTracker?.recordSimpleData("lineJoin", opIdx); this.ctx.lineJoin = LINE_JOIN_STYLES[style]; } - setMiterLimit(limit) { + setMiterLimit(opIdx, limit) { + this.dependencyTracker?.recordSimpleData("miterLimit", opIdx); this.ctx.miterLimit = limit; } - setDash(dashArray, dashPhase) { + setDash(opIdx, dashArray, dashPhase) { + this.dependencyTracker?.recordSimpleData("dash", opIdx); const ctx = this.ctx; if (ctx.setLineDash !== undefined) { ctx.setLineDash(dashArray); @@ -1125,56 +1159,64 @@ class CanvasGraphics { } } - setRenderingIntent(intent) { + setRenderingIntent(opIdx, intent) { // This operation is ignored since we haven't found a use case for it yet. } - setFlatness(flatness) { + setFlatness(opIdx, flatness) { // This operation is ignored since we haven't found a use case for it yet. } - setGState(states) { + setGState(opIdx, states) { for (const [key, value] of states) { switch (key) { case "LW": - this.setLineWidth(value); + this.setLineWidth(opIdx, value); break; case "LC": - this.setLineCap(value); + this.setLineCap(opIdx, value); break; case "LJ": - this.setLineJoin(value); + this.setLineJoin(opIdx, value); break; case "ML": - this.setMiterLimit(value); + this.setMiterLimit(opIdx, value); break; case "D": - this.setDash(value[0], value[1]); + this.setDash(opIdx, value[0], value[1]); break; case "RI": - this.setRenderingIntent(value); + this.setRenderingIntent(opIdx, value); break; case "FL": - this.setFlatness(value); + this.setFlatness(opIdx, value); break; case "Font": - this.setFont(value[0], value[1]); + this.setFont(opIdx, value[0], value[1]); break; case "CA": + this.dependencyTracker?.recordSimpleData("strokeAlpha", opIdx); this.current.strokeAlpha = value; break; case "ca": + this.dependencyTracker?.recordSimpleData("fillAlpha", opIdx); this.ctx.globalAlpha = this.current.fillAlpha = value; break; case "BM": + this.dependencyTracker?.recordSimpleData( + "globalCompositeOperation", + opIdx + ); this.ctx.globalCompositeOperation = value; break; case "SMask": + this.dependencyTracker?.recordSimpleData("SMask", opIdx); this.current.activeSMask = value ? this.tempSMask : null; this.tempSMask = null; this.checkSMaskState(); break; case "TR": + this.dependencyTracker?.recordSimpleData("filter", opIdx); this.ctx.filter = this.current.transferMaps = this.filterFactory.addFilter(value); break; @@ -1205,7 +1247,7 @@ class CanvasGraphics { * mode ends any clipping paths or transformations will still be active and in * the right order on the canvas' graphics state stack. */ - beginSMaskMode() { + beginSMaskMode(opIdx) { if (this.inSMaskMode) { throw new Error("beginSMaskMode called while already in smask mode"); } @@ -1223,7 +1265,7 @@ class CanvasGraphics { copyCtxState(this.suspendedCtx, ctx); mirrorContextOperations(ctx, this.suspendedCtx); - this.setGState([["BM", "source-over"]]); + this.setGState(opIdx, [["BM", "source-over"]]); } endSMaskMode() { @@ -1373,7 +1415,7 @@ class CanvasGraphics { layerCtx.restore(); } - save() { + save(opIdx) { if (this.inSMaskMode) { // SMask mode may be turned on/off causing us to lose graphics state. // Copy the temporary canvas state to the main(suspended) canvas to keep @@ -1384,9 +1426,12 @@ class CanvasGraphics { const old = this.current; this.stateStack.push(old); this.current = old.clone(); + this.dependencyTracker?.save(opIdx); } - restore() { + restore(opIdx) { + this.dependencyTracker?.restore(opIdx); + if (this.stateStack.length === 0) { if (this.inSMaskMode) { this.endSMaskMode(); @@ -1410,7 +1455,8 @@ class CanvasGraphics { this._cachedGetSinglePixelWidth = null; } - transform(a, b, c, d, e, f) { + transform(opIdx, a, b, c, d, e, f) { + this.dependencyTracker?.recordIncrementalData("transform", opIdx); this.ctx.transform(a, b, c, d, e, f); this._cachedScaleForStroking[0] = -1; @@ -1418,14 +1464,30 @@ class CanvasGraphics { } // Path - constructPath(op, data, minMax) { + constructPath(opIdx, op, data, minMax) { let [path] = data; if (!minMax) { // The path is empty, so no need to update the current minMax. path ||= data[0] = new Path2D(); - this[op](path); + this[op](opIdx, path); return; } + + if (this.dependencyTracker !== null) { + const outerExtraSize = op === OPS.stroke ? this.current.lineWidth / 2 : 0; + this.dependencyTracker + .resetBBox(opIdx) + .recordBBox( + opIdx, + this.ctx, + minMax[0] - outerExtraSize, + minMax[2] + outerExtraSize, + minMax[1] - outerExtraSize, + minMax[3] + outerExtraSize + ) + .recordDependencies(opIdx, ["transform"]); + } + if (!(path instanceof Path2D)) { // Using a SVG string is slightly slower than using the following loop. const path2d = (data[0] = new Path2D()); @@ -1462,14 +1524,16 @@ class CanvasGraphics { getCurrentTransform(this.ctx), this.current.minMax ); - this[op](path); + this[op](opIdx, path); + + this._pathStartIdx = opIdx; } - closePath() { + closePath(opIdx) { this.ctx.closePath(); } - stroke(path, consumePath = true) { + stroke(opIdx, path, consumePath = true) { const ctx = this.ctx; const strokeColor = this.current.strokeColor; // For stroke we want to temporarily change the global alpha to the @@ -1501,8 +1565,12 @@ class CanvasGraphics { this.rescaleAndStroke(path, /* saveRestore */ true); } } + + this.dependencyTracker?.recordDependencies(opIdx, Dependencies.stroke); + if (consumePath) { this.consumePath( + opIdx, path, this.current.getClippedPathBoundingBox( PathType.STROKE, @@ -1510,15 +1578,16 @@ class CanvasGraphics { ) ); } + // Restore the global alpha to the fill alpha ctx.globalAlpha = this.current.fillAlpha; } - closeStroke(path) { - this.stroke(path); + closeStroke(opIdx, path) { + this.stroke(opIdx, path); } - fill(path, consumePath = true) { + fill(opIdx, path, consumePath = true) { const ctx = this.ctx; const fillColor = this.current.fillColor; const isPatternFill = this.current.patternFill; @@ -1528,6 +1597,7 @@ class CanvasGraphics { const baseTransform = fillColor.isModifyingCurrentTransform() ? ctx.getTransform() : null; + this.dependencyTracker?.save(opIdx); ctx.save(); ctx.fillStyle = fillColor.getPattern( ctx, @@ -1556,108 +1626,140 @@ class CanvasGraphics { } } + this.dependencyTracker?.recordDependencies(opIdx, Dependencies.fill); + if (needRestore) { ctx.restore(); + this.dependencyTracker?.restore(opIdx); } if (consumePath) { - this.consumePath(path, intersect); + this.consumePath(opIdx, path, intersect); } } - eoFill(path) { + eoFill(opIdx, path) { this.pendingEOFill = true; - this.fill(path); + this.fill(opIdx, path); } - fillStroke(path) { - this.fill(path, false); - this.stroke(path, false); + fillStroke(opIdx, path) { + this.fill(opIdx, path, false); + this.stroke(opIdx, path, false); - this.consumePath(path); + this.consumePath(opIdx, path); } - eoFillStroke(path) { + eoFillStroke(opIdx, path) { this.pendingEOFill = true; - this.fillStroke(path); + this.fillStroke(opIdx, path); } - closeFillStroke(path) { - this.fillStroke(path); + closeFillStroke(opIdx, path) { + this.fillStroke(opIdx, path); } - closeEOFillStroke(path) { + closeEOFillStroke(opIdx, path) { this.pendingEOFill = true; - this.fillStroke(path); + this.fillStroke(opIdx, path); } - endPath(path) { - this.consumePath(path); + endPath(opIdx, path) { + this.consumePath(opIdx, path); } - rawFillPath(path) { + rawFillPath(opIdx, path) { this.ctx.fill(path); + this.dependencyTracker + ?.recordDependencies(opIdx, Dependencies.rawFillPath) + .recordOperation(opIdx); } // Clipping - clip() { + clip(opIdx) { + this.dependencyTracker?.recordFutureForcedDependency("clipMode", opIdx); this.pendingClip = NORMAL_CLIP; } - eoClip() { + eoClip(opIdx) { + this.dependencyTracker?.recordFutureForcedDependency("clipMode", opIdx); this.pendingClip = EO_CLIP; } // Text - beginText() { + beginText(opIdx) { this.current.textMatrix = null; this.current.textMatrixScale = 1; this.current.x = this.current.lineX = 0; this.current.y = this.current.lineY = 0; + + this.dependencyTracker + ?.recordOpenMarker(opIdx) + .resetIncrementalData("sameLineText") + .resetIncrementalData("moveText", opIdx); } - endText() { + endText(opIdx) { const paths = this.pendingTextPaths; const ctx = this.ctx; - if (paths === undefined) { - return; - } - const newPath = new Path2D(); - const invTransf = ctx.getTransform().invertSelf(); - for (const { transform, x, y, fontSize, path } of paths) { - if (!path) { - continue; // Skip empty paths. + if (this.dependencyTracker) { + const { dependencyTracker } = this; + if (paths !== undefined) { + dependencyTracker + .recordFutureForcedDependency( + "textClip", + dependencyTracker.getOpenMarker() + ) + .recordFutureForcedDependency("textClip", opIdx); } - newPath.addPath( - path, - new DOMMatrix(transform) - .preMultiplySelf(invTransf) - .translate(x, y) - .scale(fontSize, -fontSize) - ); + dependencyTracker.recordCloseMarker(opIdx); } - ctx.clip(newPath); + if (paths !== undefined) { + const newPath = new Path2D(); + const invTransf = ctx.getTransform().invertSelf(); + for (const { transform, x, y, fontSize, path } of paths) { + if (!path) { + continue; // Skip empty paths. + } + newPath.addPath( + path, + new DOMMatrix(transform) + .preMultiplySelf(invTransf) + .translate(x, y) + .scale(fontSize, -fontSize) + ); + } + + ctx.clip(newPath); + } delete this.pendingTextPaths; } - setCharSpacing(spacing) { + setCharSpacing(opIdx, spacing) { + this.dependencyTracker?.recordSimpleData("charSpacing", opIdx); this.current.charSpacing = spacing; } - setWordSpacing(spacing) { + setWordSpacing(opIdx, spacing) { + this.dependencyTracker?.recordSimpleData("wordSpacing", opIdx); this.current.wordSpacing = spacing; } - setHScale(scale) { + setHScale(opIdx, scale) { + this.dependencyTracker?.recordSimpleData("hScale", opIdx); this.current.textHScale = scale / 100; } - setLeading(leading) { + setLeading(opIdx, leading) { + this.dependencyTracker?.recordSimpleData("leading", opIdx); this.current.leading = -leading; } - setFont(fontRefName, size) { + setFont(opIdx, fontRefName, size) { + this.dependencyTracker + ?.recordSimpleData("font", opIdx) + .recordNamedDependency(opIdx, fontRefName); const fontObj = this.commonObjs.get(fontRefName); const current = this.current; @@ -1715,25 +1817,31 @@ class CanvasGraphics { this.ctx.font = `${italic} ${bold} ${browserFontSize}px ${typeface}`; } - setTextRenderingMode(mode) { + setTextRenderingMode(opIdx, mode) { + this.dependencyTracker?.recordSimpleData("textRenderingMode", opIdx); this.current.textRenderingMode = mode; } - setTextRise(rise) { + setTextRise(opIdx, rise) { + this.dependencyTracker?.recordSimpleData("textRise", opIdx); this.current.textRise = rise; } - moveText(x, y) { + moveText(opIdx, x, y) { + this.dependencyTracker + ?.resetIncrementalData("sameLineText") + .recordIncrementalData("moveText", opIdx); this.current.x = this.current.lineX += x; this.current.y = this.current.lineY += y; } - setLeadingMoveText(x, y) { - this.setLeading(-y); - this.moveText(x, y); + setLeadingMoveText(opIdx, x, y) { + this.setLeading(opIdx, -y); + this.moveText(opIdx, x, y); } - setTextMatrix(matrix) { + setTextMatrix(opIdx, matrix) { + this.dependencyTracker?.recordSimpleData("textMatrix", opIdx); const { current } = this; current.textMatrix = matrix; current.textMatrixScale = Math.hypot(matrix[0], matrix[1]); @@ -1742,8 +1850,16 @@ class CanvasGraphics { current.y = current.lineY = 0; } - nextLine() { - this.moveText(0, this.current.leading); + nextLine(opIdx) { + this.moveText(opIdx, 0, this.current.leading); + + this.dependencyTracker?.recordIncrementalData( + "moveText", + // 'leading' affects 'nextLine' operations. Rather than dealing + // with transitive dependencies, just mark everything that depends on + // the 'moveText' operation as depending on the 'leading' value. + this.dependencyTracker.getSimpleIndex("leading") ?? opIdx + ); } #getScaledPath(path, currentTransform, transform) { @@ -1755,7 +1871,14 @@ class CanvasGraphics { return newPath; } - paintChar(character, x, y, patternFillTransform, patternStrokeTransform) { + paintChar( + opIdx, + character, + x, + y, + patternFillTransform, + patternStrokeTransform + ) { const ctx = this.ctx; const current = this.current; const font = current.font; @@ -1784,6 +1907,9 @@ class CanvasGraphics { ctx.save(); ctx.translate(x, y); ctx.scale(fontSize, -fontSize); + + this.dependencyTracker?.recordCharacterBBox(opIdx, ctx, font); + let currentTransform; if ( fillStrokeMode === TextRenderingMode.FILL || @@ -1792,9 +1918,12 @@ class CanvasGraphics { if (patternFillTransform) { currentTransform = ctx.getTransform(); ctx.setTransform(...patternFillTransform); - ctx.fill( - this.#getScaledPath(path, currentTransform, patternFillTransform) + const scaledPath = this.#getScaledPath( + path, + currentTransform, + patternFillTransform ); + ctx.fill(scaledPath); } else { ctx.fill(path); } @@ -1835,11 +1964,27 @@ class CanvasGraphics { fillStrokeMode === TextRenderingMode.FILL_STROKE ) { ctx.fillText(character, x, y); + this.dependencyTracker?.recordCharacterBBox( + opIdx, + ctx, + font, + fontSize, + x, + y, + () => ctx.measureText(character) + ); } if ( fillStrokeMode === TextRenderingMode.STROKE || fillStrokeMode === TextRenderingMode.FILL_STROKE ) { + if (this.dependencyTracker) { + this.dependencyTracker + ?.recordCharacterBBox(opIdx, ctx, font, fontSize, x, y, () => + ctx.measureText(character) + ) + .recordDependencies(opIdx, Dependencies.stroke); + } ctx.strokeText(character, x, y); } } @@ -1853,6 +1998,14 @@ class CanvasGraphics { fontSize, path, }); + this.dependencyTracker?.recordCharacterBBox( + opIdx, + ctx, + font, + fontSize, + x, + y + ); } } @@ -1877,15 +2030,32 @@ class CanvasGraphics { return shadow(this, "isFontSubpixelAAEnabled", enabled); } - showText(glyphs) { + showText(opIdx, glyphs) { + 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 + .recordFutureForcedDependency("textClip", opIdx) + .inheritPendingDependenciesAsFutureForcedDependencies(); + } + } + const current = this.current; const font = current.font; if (font.isType3Font) { - return this.showType3Text(glyphs); + this.showType3Text(opIdx, glyphs); + this.dependencyTracker + ?.recordOperation(opIdx) + .recordIncrementalData("sameLineText", opIdx); + return undefined; } const fontSize = current.fontSize; if (fontSize === 0) { + this.dependencyTracker?.recordOperation(opIdx); return undefined; } @@ -1974,11 +2144,25 @@ class CanvasGraphics { chars.push(glyph.unicode); width += glyph.width; } - ctx.fillText(chars.join(""), 0, 0); + const joinedChars = chars.join(""); + ctx.fillText(joinedChars, 0, 0); + if (this.dependencyTracker !== null) { + const measure = ctx.measureText(joinedChars); + this.dependencyTracker + .recordBBox( + opIdx, + this.ctx, + -measure.actualBoundingBoxLeft, + measure.actualBoundingBoxRight, + -measure.actualBoundingBoxAscent, + measure.actualBoundingBoxDescent + ) + .recordOperation(opIdx) + .recordIncrementalData("sameLineText", opIdx); + } current.x += width * widthAdvanceScale * textHScale; ctx.restore(); this.compose(); - return undefined; } @@ -2011,13 +2195,16 @@ class CanvasGraphics { scaledY = 0; } + let measure; + if (font.remeasure && width > 0) { + measure = ctx.measureText(character); + // Some standard fonts may not have the exact width: rescale per // character if measured width is greater than expected glyph width // and subpixel-aa is enabled, otherwise just center the glyph. const measuredWidth = - ((ctx.measureText(character).width * 1000) / fontSize) * - fontSizeScale; + ((measure.width * 1000) / fontSize) * fontSizeScale; if (width < measuredWidth && this.isFontSubpixelAAEnabled) { const characterScaleX = width / measuredWidth; restoreNeeded = true; @@ -2036,8 +2223,20 @@ class CanvasGraphics { if (simpleFillText && !accent) { // common case ctx.fillText(character, scaledX, scaledY); + + this.dependencyTracker?.recordCharacterBBox( + opIdx, + ctx, + // If we already measured the character, force usage of that + measure ? { bbox: null } : font, + fontSize / fontSizeScale, + scaledX, + scaledY, + () => measure ?? ctx.measureText(character) + ); } else { this.paintChar( + opIdx, character, scaledX, scaledY, @@ -2050,6 +2249,7 @@ class CanvasGraphics { const scaledAccentY = scaledY - (fontSize * accent.offset.y) / fontSizeScale; this.paintChar( + opIdx, accent.fontChar, scaledAccentX, scaledAccentY, @@ -2077,10 +2277,13 @@ class CanvasGraphics { ctx.restore(); this.compose(); + this.dependencyTracker + ?.recordOperation(opIdx) + .recordIncrementalData("sameLineText", opIdx); return undefined; } - showType3Text(glyphs) { + showType3Text(opIdx, glyphs) { // Type3 fonts - each glyph is a "mini-PDF" const ctx = this.ctx; const current = this.current; @@ -2111,6 +2314,13 @@ class CanvasGraphics { ctx.scale(textHScale, fontDirection); + // Type3 fonts have their own operator list. Avoid mixing it up with the + // dependency tracker of the main operator list. + const dependencyTracker = this.dependencyTracker; + this.dependencyTracker = dependencyTracker + ? new CanvasNestedDependencyTracker(dependencyTracker, opIdx) + : null; + for (i = 0; i < glyphsLength; ++i) { glyph = glyphs[i]; if (typeof glyph === "number") { @@ -2140,23 +2350,30 @@ class CanvasGraphics { current.x += width * textHScale; } ctx.restore(); + if (dependencyTracker) { + this.dependencyTracker.recordNestedDependencies(); + this.dependencyTracker = dependencyTracker; + } } // Type3 fonts - setCharWidth(xWidth, yWidth) { + setCharWidth(opIdx, xWidth, yWidth) { // We can safely ignore this since the width should be the same // as the width in the Widths array. } - setCharWidthAndBounds(xWidth, yWidth, llx, lly, urx, ury) { + setCharWidthAndBounds(opIdx, xWidth, yWidth, llx, lly, urx, ury) { const clip = new Path2D(); clip.rect(llx, lly, urx - llx, ury - lly); this.ctx.clip(clip); - this.endPath(); + this.dependencyTracker + ?.recordBBox(opIdx, this.ctx, llx, urx, lly, ury) + .recordClipBox(opIdx, this.ctx, llx, urx, lly, ury); + this.endPath(opIdx); } // Color - getColorN_Pattern(IR) { + getColorN_Pattern(opIdx, IR) { let pattern; if (IR[0] === "TilingPattern") { const baseTransform = this.baseTransform || getCurrentTransform(this.ctx); @@ -2171,7 +2388,12 @@ class CanvasGraphics { { optionalContentConfig: this.optionalContentConfig, markedContentStack: this.markedContentStack, - } + }, + undefined, + undefined, + this.dependencyTracker + ? new CanvasNestedDependencyTracker(this.dependencyTracker, opIdx) + : null ), }; pattern = new TilingPattern( @@ -2181,47 +2403,53 @@ class CanvasGraphics { baseTransform ); } else { - pattern = this._getPattern(IR[1], IR[2]); + pattern = this._getPattern(opIdx, IR[1], IR[2]); } return pattern; } - setStrokeColorN() { - this.current.strokeColor = this.getColorN_Pattern(arguments); + setStrokeColorN(opIdx, ...args) { + this.dependencyTracker?.recordSimpleData("strokeColor", opIdx); + this.current.strokeColor = this.getColorN_Pattern(opIdx, args); this.current.patternStroke = true; } - setFillColorN() { - this.current.fillColor = this.getColorN_Pattern(arguments); + setFillColorN(opIdx, ...args) { + this.dependencyTracker?.recordSimpleData("fillColor", opIdx); + this.current.fillColor = this.getColorN_Pattern(opIdx, args); this.current.patternFill = true; } - setStrokeRGBColor(color) { + setStrokeRGBColor(opIdx, color) { + this.dependencyTracker?.recordSimpleData("strokeColor", opIdx); this.ctx.strokeStyle = this.current.strokeColor = color; this.current.patternStroke = false; } - setStrokeTransparent() { + setStrokeTransparent(opIdx) { + this.dependencyTracker?.recordSimpleData("strokeColor", opIdx); this.ctx.strokeStyle = this.current.strokeColor = "transparent"; this.current.patternStroke = false; } - setFillRGBColor(color) { + setFillRGBColor(opIdx, color) { + this.dependencyTracker?.recordSimpleData("fillColor", opIdx); this.ctx.fillStyle = this.current.fillColor = color; this.current.patternFill = false; } - setFillTransparent() { + setFillTransparent(opIdx) { + this.dependencyTracker?.recordSimpleData("fillColor", opIdx); this.ctx.fillStyle = this.current.fillColor = "transparent"; this.current.patternFill = false; } - _getPattern(objId, matrix = null) { + _getPattern(opIdx, objId, matrix = null) { let pattern; if (this.cachedPatterns.has(objId)) { pattern = this.cachedPatterns.get(objId); } else { - pattern = getShadingPattern(this.getObject(objId)); + pattern = getShadingPattern(this.getObject(opIdx, objId)); this.cachedPatterns.set(objId, pattern); } if (matrix) { @@ -2230,14 +2458,14 @@ class CanvasGraphics { return pattern; } - shadingFill(objId) { + shadingFill(opIdx, objId) { if (!this.contentVisible) { return; } const ctx = this.ctx; - this.save(); - const pattern = this._getPattern(objId); + this.save(opIdx); + const pattern = this._getPattern(opIdx, objId); ctx.fillStyle = pattern.getPattern( ctx, this, @@ -2263,8 +2491,16 @@ class CanvasGraphics { this.ctx.fillRect(-1e10, -1e10, 2e10, 2e10); } + this.dependencyTracker + ?.resetBBox(opIdx) + // TODO: Track proper bbox + .recordFullPageBBox(opIdx) + .recordDependencies(opIdx, Dependencies.transform) + .recordDependencies(opIdx, Dependencies.fill) + .recordOperation(opIdx); + this.compose(this.current.getClippedPathBoundingBox()); - this.restore(); + this.restore(opIdx); } // Images @@ -2276,15 +2512,15 @@ class CanvasGraphics { unreachable("Should not call beginImageData"); } - paintFormXObjectBegin(matrix, bbox) { + paintFormXObjectBegin(opIdx, matrix, bbox) { if (!this.contentVisible) { return; } - this.save(); + this.save(opIdx); this.baseTransformStack.push(this.baseTransform); if (matrix) { - this.transform(...matrix); + this.transform(opIdx, ...matrix); } this.baseTransform = getCurrentTransform(this.ctx); @@ -2298,24 +2534,25 @@ class CanvasGraphics { const clip = new Path2D(); clip.rect(x0, y0, x1 - x0, y1 - y0); this.ctx.clip(clip); - this.endPath(); + this.dependencyTracker?.recordClipBox(opIdx, this.ctx, x0, x1, y0, y1); + this.endPath(opIdx); } } - paintFormXObjectEnd() { + paintFormXObjectEnd(opIdx) { if (!this.contentVisible) { return; } - this.restore(); + this.restore(opIdx); this.baseTransform = this.baseTransformStack.pop(); } - beginGroup(group) { + beginGroup(opIdx, group) { if (!this.contentVisible) { return; } - this.save(); + this.save(opIdx); // If there's an active soft mask we don't want it enabled for the group, so // clear it out. The mask and suspended canvas will be restored in endGroup. if (this.inSMaskMode) { @@ -2421,7 +2658,14 @@ class CanvasGraphics { transferMap: group.smask.transferMap || null, startTransformInverse: null, // used during suspend operation }); - } else { + } + if ( + !group.smask || + // When this is not an SMask group, we only need to update the current + // transform if recording operations bboxes, so they the bboxes have the + // correct transform applied. + this.dependencyTracker + ) { // Setup the current ctx so when the group is popped we draw it at the // right location. currentCtx.setTransform(1, 0, 0, 1, 0, 0); @@ -2432,7 +2676,14 @@ class CanvasGraphics { // except the blend mode, soft mask, and alpha constants. copyCtxState(currentCtx, groupCtx); this.ctx = groupCtx; - this.setGState([ + this.dependencyTracker + ?.inheritSimpleDataAsFutureForcedDependencies([ + "fillAlpha", + "strokeAlpha", + "globalCompositeOperation", + ]) + .pushBaseTransform(currentCtx); + this.setGState(opIdx, [ ["BM", "source-over"], ["ca", 1], ["CA", 1], @@ -2441,7 +2692,7 @@ class CanvasGraphics { this.groupLevel++; } - endGroup(group) { + endGroup(opIdx, group) { if (!this.contentVisible) { return; } @@ -2453,13 +2704,18 @@ class CanvasGraphics { // look kind of blurry for some pdfs. this.ctx.imageSmoothingEnabled = false; + this.dependencyTracker?.popBaseTransform(); + if (group.smask) { this.tempSMask = this.smaskStack.pop(); - this.restore(); + this.restore(opIdx); + if (this.dependencyTracker) { + this.ctx.restore(); + } } else { this.ctx.restore(); const currentMtx = getCurrentTransform(this.ctx); - this.restore(); + this.restore(opIdx); this.ctx.save(); this.ctx.setTransform(...currentMtx); const dirtyBox = MIN_MAX_INIT.slice(); @@ -2474,7 +2730,7 @@ class CanvasGraphics { } } - beginAnnotation(id, rect, transform, matrix, hasOwnCanvas) { + beginAnnotation(opIdx, id, rect, transform, matrix, hasOwnCanvas) { // The annotations are drawn just after the page content. // The page content drawing can potentially have set a transform, // a clipping path, whatever... @@ -2483,7 +2739,7 @@ class CanvasGraphics { resetCtxToDefault(this.ctx); this.ctx.save(); - this.save(); + this.save(opIdx); if (this.baseTransform) { this.ctx.setTransform(...this.baseTransform); @@ -2528,7 +2784,7 @@ class CanvasGraphics { resetCtxToDefault(this.ctx); // Consume a potential path before clipping. - this.endPath(); + this.endPath(opIdx); const clip = new Path2D(); clip.rect(rect[0], rect[1], width, height); @@ -2541,11 +2797,11 @@ class CanvasGraphics { this.ctx.canvas.height ); - this.transform(...transform); - this.transform(...matrix); + this.transform(opIdx, ...transform); + this.transform(opIdx, ...matrix); } - endAnnotation() { + endAnnotation(opIdx) { if (this.annotationCanvas) { this.ctx.restore(); this.#drawFilter(); @@ -2556,16 +2812,17 @@ class CanvasGraphics { } } - paintImageMaskXObject(img) { + paintImageMaskXObject(opIdx, img) { if (!this.contentVisible) { return; } + const count = img.count; - img = this.getObject(img.data, img); + img = this.getObject(opIdx, img.data, img); img.count = count; const ctx = this.ctx; - const mask = this._createMaskCanvas(img); + const mask = this._createMaskCanvas(opIdx, img); const maskCanvas = mask.canvas; ctx.save(); @@ -2573,11 +2830,23 @@ class CanvasGraphics { // transform to draw to the identity. ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.drawImage(maskCanvas, mask.offsetX, mask.offsetY); + this.dependencyTracker + ?.resetBBox(opIdx) + .recordBBox( + opIdx, + this.ctx, + mask.offsetX, + mask.offsetX + maskCanvas.width, + mask.offsetY, + mask.offsetY + maskCanvas.height + ) + .recordOperation(opIdx); ctx.restore(); this.compose(); } paintImageMaskXObjectRepeat( + opIdx, img, scaleX, skewX = 0, @@ -2589,13 +2858,13 @@ class CanvasGraphics { return; } - img = this.getObject(img.data, img); + img = this.getObject(opIdx, img.data, img); const ctx = this.ctx; ctx.save(); const currentTransform = getCurrentTransform(ctx); ctx.transform(scaleX, skewX, skewY, scaleY, 0, 0); - const mask = this._createMaskCanvas(img); + const mask = this._createMaskCanvas(opIdx, img); ctx.setTransform( 1, @@ -2605,6 +2874,7 @@ class CanvasGraphics { mask.offsetX - currentTransform[4], mask.offsetY - currentTransform[5] ); + this.dependencyTracker?.resetBBox(opIdx); for (let i = 0, ii = positions.length; i < ii; i += 2) { const trans = Util.transform(currentTransform, [ scaleX, @@ -2618,12 +2888,22 @@ class CanvasGraphics { // Here we want to apply the transform at the origin, // hence no additional computation is necessary. ctx.drawImage(mask.canvas, trans[4], trans[5]); + this.dependencyTracker?.recordBBox( + opIdx, + this.ctx, + trans[4], + trans[4] + mask.canvas.width, + trans[5], + trans[5] + mask.canvas.height + ); } ctx.restore(); this.compose(); + + this.dependencyTracker?.recordOperation(opIdx); } - paintImageMaskXObjectGroup(images) { + paintImageMaskXObjectGroup(opIdx, images) { if (!this.contentVisible) { return; } @@ -2632,6 +2912,10 @@ class CanvasGraphics { const fillColor = this.current.fillColor; const isPatternFill = this.current.patternFill; + this.dependencyTracker + ?.resetBBox(opIdx) + .recordDependencies(opIdx, Dependencies.transformAndFill); + for (const image of images) { const { data, width, height, transform } = image; @@ -2643,7 +2927,7 @@ class CanvasGraphics { const maskCtx = maskCanvas.context; maskCtx.save(); - const img = this.getObject(data, image); + const img = this.getObject(opIdx, data, image); putBinaryImageMask(maskCtx, img); maskCtx.globalCompositeOperation = "source-in"; @@ -2675,29 +2959,32 @@ class CanvasGraphics { 1, 1 ); + + this.dependencyTracker?.recordBBox(opIdx, ctx, 0, width, 0, height); ctx.restore(); } this.compose(); + this.dependencyTracker?.recordOperation(opIdx); } - paintImageXObject(objId) { + paintImageXObject(opIdx, objId) { if (!this.contentVisible) { return; } - const imgData = this.getObject(objId); + const imgData = this.getObject(opIdx, objId); if (!imgData) { warn("Dependent image isn't ready yet"); return; } - this.paintInlineImageXObject(imgData); + this.paintInlineImageXObject(opIdx, imgData); } - paintImageXObjectRepeat(objId, scaleX, scaleY, positions) { + paintImageXObjectRepeat(opIdx, objId, scaleX, scaleY, positions) { if (!this.contentVisible) { return; } - const imgData = this.getObject(objId); + const imgData = this.getObject(opIdx, objId); if (!imgData) { warn("Dependent image isn't ready yet"); return; @@ -2715,7 +3002,7 @@ class CanvasGraphics { h: height, }); } - this.paintInlineImageXObjectGroup(imgData, map); + this.paintInlineImageXObjectGroup(opIdx, imgData, map); } applyTransferMapsToCanvas(ctx) { @@ -2745,7 +3032,7 @@ class CanvasGraphics { return tmpCanvas.canvas; } - paintInlineImageXObject(imgData) { + paintInlineImageXObject(opIdx, imgData) { if (!this.contentVisible) { return; } @@ -2753,7 +3040,7 @@ class CanvasGraphics { const height = imgData.height; const ctx = this.ctx; - this.save(); + this.save(opIdx); // The filter, if any, will be applied in applyTransferMapsToBitmap. // It must be applied to the image before rescaling else some artifacts @@ -2796,6 +3083,12 @@ class CanvasGraphics { imgData.interpolate ); + this.dependencyTracker + ?.resetBBox(opIdx) + .recordBBox(opIdx, ctx, 0, width, -height, 0) + .recordDependencies(opIdx, Dependencies.imageXObject) + .recordOperation(opIdx); + drawImageAtIntegerCoords( ctx, scaled.img, @@ -2809,10 +3102,10 @@ class CanvasGraphics { height ); this.compose(); - this.restore(); + this.restore(opIdx); } - paintInlineImageXObjectGroup(imgData, map) { + paintInlineImageXObjectGroup(opIdx, imgData, map) { if (!this.contentVisible) { return; } @@ -2830,6 +3123,8 @@ class CanvasGraphics { imgToPaint = this.applyTransferMapsToCanvas(tmpCtx); } + this.dependencyTracker?.resetBBox(opIdx); + for (const entry of map) { ctx.save(); ctx.transform(...entry.transform); @@ -2846,36 +3141,45 @@ class CanvasGraphics { 1, 1 ); + this.dependencyTracker?.recordBBox(opIdx, ctx, 0, 1, -1, 0); ctx.restore(); } + this.dependencyTracker?.recordOperation(opIdx); this.compose(); } - paintSolidColorImageMask() { + paintSolidColorImageMask(opIdx) { if (!this.contentVisible) { return; } + this.dependencyTracker + ?.resetBBox(opIdx) + .recordBBox(opIdx, this.ctx, 0, 1, 0, 1) + .recordDependencies(opIdx, Dependencies.fill) + .recordOperation(opIdx); this.ctx.fillRect(0, 0, 1, 1); this.compose(); } // Marked content - markPoint(tag) { + markPoint(opIdx, tag) { // TODO Marked content. } - markPointProps(tag, properties) { + markPointProps(opIdx, tag, properties) { // TODO Marked content. } - beginMarkedContent(tag) { + beginMarkedContent(opIdx, tag) { + this.dependencyTracker?.beginMarkedContent(opIdx); this.markedContentStack.push({ visible: true, }); } - beginMarkedContentProps(tag, properties) { + beginMarkedContentProps(opIdx, tag, properties) { + this.dependencyTracker?.beginMarkedContent(opIdx); if (tag === "OC") { this.markedContentStack.push({ visible: this.optionalContentConfig.isVisible(properties), @@ -2888,24 +3192,25 @@ class CanvasGraphics { this.contentVisible = this.isContentVisible(); } - endMarkedContent() { + endMarkedContent(opIdx) { + this.dependencyTracker?.endMarkedContent(opIdx); this.markedContentStack.pop(); this.contentVisible = this.isContentVisible(); } // Compatibility - beginCompat() { + beginCompat(opIdx) { // TODO ignore undefined operators (should we do that anyway?) } - endCompat() { + endCompat(opIdx) { // TODO stop ignoring undefined operators } // Helper functions - consumePath(path, clipBox) { + consumePath(opIdx, path, clipBox) { const isEmpty = this.current.isEmptyClip(); if (this.pendingClip) { this.current.updateClipFromPath(); @@ -2923,7 +3228,13 @@ class CanvasGraphics { } } this.pendingClip = null; + this.dependencyTracker + ?.bboxToClipBoxDropOperation(opIdx) + .recordFutureForcedDependency("clipPath", opIdx); + } else { + this.dependencyTracker?.recordOperation(opIdx); } + this.current.startNewPathAndClipBox(this.current.clipBox); } diff --git a/src/display/canvas_dependency_tracker.js b/src/display/canvas_dependency_tracker.js new file mode 100644 index 000000000..2b5ff3ef0 --- /dev/null +++ b/src/display/canvas_dependency_tracker.js @@ -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} */ + #simple = { __proto__: null }; + + /** @type {Record} */ + #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 + #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} */ +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 }; diff --git a/src/display/pattern_helper.js b/src/display/pattern_helper.js index 9483d11f2..6bb6fe120 100644 --- a/src/display/pattern_helper.js +++ b/src/display/pattern_helper.js @@ -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) { diff --git a/src/shared/util.js b/src/shared/util.js index 2d1a0d12a..d253a40b0 100644 --- a/src/shared/util.js +++ b/src/shared/util.js @@ -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]; diff --git a/test/driver.js b/test/driver.js index 64292c993..4a46695e8 100644 --- a/test/driver.js +++ b/test/driver.js @@ -920,7 +920,8 @@ class Driver { renderPrint = false, renderXfa = false, annotationCanvasMap = null, - pageColors = null; + pageColors = null, + partialCrop = null; if (task.annotationStorage) { task.pdfDoc.annotationStorage._setValues(task.annotationStorage); @@ -968,10 +969,14 @@ class Driver { textLayerCanvas = null; // We fetch the `eq` specific test subtypes here, to avoid // accidentally changing the behaviour for other types of tests. - renderAnnotations = !!task.annotations; - renderForms = !!task.forms; - renderPrint = !!task.print; - renderXfa = !!task.enableXfa; + + 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,26 +1078,122 @@ class Driver { setTimeout(cont, RENDER_TASK_ON_CONTINUE_DELAY); }; } - return renderTask.promise.then(() => { - if (annotationCanvasMap) { - Rasterize.annotationLayer( - annotationLayerContext, - viewport, - outputScale, - data, - annotationCanvasMap, - task.pdfDoc.annotationStorage, - task.fieldObjects, - page, - IMAGE_RESOURCES_PATH, - renderForms - ).then(() => { - completeRender(false); - }); - } else { - completeRender(false); + 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, + viewport, + outputScale, + data, + annotationCanvasMap, + task.pdfDoc.annotationStorage, + task.fieldObjects, + page, + IMAGE_RESOURCES_PATH, + renderForms + ).then(() => { + completeRender(false); + }); + } 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, diff --git a/test/test.mjs b/test/test.mjs index f0ff0dc36..1292dacc7 100644 --- a/test/test.mjs +++ b/test/test.mjs @@ -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) { - failed = true; + // 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 { - session.numErrors++; + 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, diff --git a/test/test_manifest.json b/test/test_manifest.json index 2e28f992f..b83b54648 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -45,6 +45,32 @@ "rounds": 1, "type": "eq" }, + { + "id": "tracemonkey-partial", + "file": "pdfs/tracemonkey.pdf", + "md5": "9a192d8b1a7dc652a19835f6f08098bd", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, + { + "id": "tracemonkey-partial-2", + "file": "pdfs/tracemonkey.pdf", + "md5": "9a192d8b1a7dc652a19835f6f08098bd", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.5, + "maxX": 0.75, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "tracemonkey-renderTaskOnContinue", "file": "pdfs/tracemonkey.pdf", @@ -60,6 +86,19 @@ "rounds": 1, "type": "eq" }, + { + "id": "issue17147-partial", + "file": "pdfs/issue17147.pdf", + "md5": "47012ba13ee819ec0af278c9d943f010", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue13130", "file": "pdfs/issue13130.pdf", @@ -70,6 +109,22 @@ "lastPage": 2, "type": "eq" }, + { + "id": "issue13130-partial", + "file": "pdfs/issue13130.pdf", + "md5": "318518299132fe3b52252ca43a69a23e", + "rounds": 1, + "link": true, + "firstPage": 2, + "lastPage": 2, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue16395", "file": "pdfs/issue16395.pdf", @@ -80,6 +135,22 @@ "lastPage": 3, "type": "eq" }, + { + "id": "issue16395-partial", + "file": "pdfs/issue16395.pdf", + "md5": "a5de985711ec27cd2a2ed97d5f1c536c", + "rounds": 1, + "link": true, + "firstPage": 3, + "lastPage": 3, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue16432", "file": "pdfs/issue16432.pdf", @@ -98,6 +169,21 @@ "lastPage": 1, "type": "eq" }, + { + "id": "issue16464-partial", + "file": "pdfs/issue16464.pdf", + "md5": "2bd97ff909e2185605788daa70de38ee", + "rounds": 1, + "link": true, + "lastPage": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "tracemonkey-fbf", "file": "pdfs/tracemonkey.pdf", @@ -191,6 +277,22 @@ "lastPage": 2, "type": "eq" }, + { + "id": "issue11532-partial", + "file": "pdfs/issue11532.pdf", + "md5": "9216481d259ae2b8d747236e745cbc80", + "rounds": 1, + "link": true, + "firstPage": 2, + "lastPage": 2, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue14256", "file": "pdfs/issue14256.pdf", @@ -320,6 +422,23 @@ "type": "eq", "annotations": true }, + { + "id": "issue13915-partial", + "file": "pdfs/issue13915.pdf", + "md5": "fef3108733bbf80ea8551feedb427b1c", + "rounds": 1, + "firstPage": 51, + "lastPage": 51, + "link": true, + "type": "eq", + "annotations": true, + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue15516", "file": "pdfs/issue15516_reduced.pdf", @@ -365,6 +484,20 @@ "link": false, "type": "eq" }, + { + "id": "bug911034-partial", + "file": "pdfs/bug911034.pdf", + "md5": "54ee432a4e16b26b242fbf549cdad177", + "rounds": 1, + "link": false, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "bug920426", "file": "pdfs/bug920426.pdf", @@ -395,6 +528,22 @@ "lastPage": 3, "type": "eq" }, + { + "id": "bug1734802-partial", + "file": "pdfs/bug1734802.pdf", + "md5": "a23ad8af95ffca3876e110a5f4dec9bc", + "rounds": 1, + "link": true, + "lastPage": 3, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": true + }, { "id": "bug1753983", "file": "pdfs/bug1753983.pdf", @@ -411,6 +560,20 @@ "link": true, "type": "eq" }, + { + "id": "issue15604-partial", + "file": "pdfs/issue15604.pdf", + "md5": "505040e5634434ae97118a4c39bf27e5", + "rounds": 1, + "link": true, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "bug921760", "file": "pdfs/bug921760.pdf", @@ -496,6 +659,23 @@ "lastPage": 4, "type": "eq" }, + { + "id": "bug1443140-partial", + "file": "pdfs/bug1443140.pdf", + "md5": "8f9347b0d5620537850b24b8385b0982", + "rounds": 1, + "link": true, + "firstPage": 4, + "lastPage": 4, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": true + }, { "id": "bug1473809", "file": "pdfs/bug1473809.pdf", @@ -569,6 +749,40 @@ "rounds": 1, "type": "eq" }, + { + "id": "intelisa-partial", + "file": "pdfs/intelisa.pdf", + "md5": "24643ebe348a568cfe6a532055c71493", + "link": true, + "firstPage": 22, + "lastPage": 22, + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": "Two one-bit differences at the bottom edge of page 22" + }, + { + "id": "intelisa-84-partial", + "file": "pdfs/intelisa.pdf", + "md5": "24643ebe348a568cfe6a532055c71493", + "link": true, + "firstPage": 84, + "lastPage": 84, + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": "Gives slightly different results in Firefox when running in headless mode" + }, { "id": "issue2128", "file": "pdfs/issue2128r.pdf", @@ -835,6 +1049,20 @@ "rounds": 1, "type": "eq" }, + { + "id": "wnv_chinese-pdf-partial", + "file": "pdfs/wnv_chinese.pdf", + "md5": "db682638e68391125e8982d3c984841e", + "link": true, + "rounds": 1, + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "type": "eq" + }, { "id": "i9-pdf", "file": "pdfs/i9.pdf", @@ -850,6 +1078,19 @@ "rounds": 1, "type": "eq" }, + { + "id": "issue12798_page1_reduced-partial", + "file": "pdfs/issue12798_page1_reduced.pdf", + "md5": "f4c3e91c181b510929ade67c1e34c5c5", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue15557", "file": "pdfs/issue15557.pdf", @@ -1001,6 +1242,21 @@ "rounds": 1, "type": "eq" }, + { + "id": "artofwar-partial", + "file": "pdfs/artofwar.pdf", + "md5": "7bdd51c327b74f1f7abdd90eedb2f912", + "link": true, + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": "TODO: I cannot figure out what the mismatch is. 'Circle differences' circles something, but then with the 'big pixels' view there are no differences." + }, { "id": "issue3371", "file": "pdfs/issue3371.pdf", @@ -1081,6 +1337,21 @@ "rounds": 1, "type": "eq" }, + { + "id": "wdsg_fitc-partial", + "file": "pdfs/wdsg_fitc.pdf", + "md5": "5bb1c2b83705d4cdfc43197ee74f07f9", + "link": true, + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": "Two one-bit differences at the bottom edge of page 11" + }, { "id": "issue6769_no_matrix", "file": "pdfs/issue6769_no_matrix.pdf", @@ -1096,6 +1367,21 @@ "rounds": 1, "type": "eq" }, + { + "id": "unix01-partial", + "file": "pdfs/unix01.pdf", + "md5": "2742999f0bf9b9c035dbb0736096e220", + "link": true, + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": "A one-bit difference at the bottom edge of page 29" + }, { "id": "issue4909", "file": "pdfs/issue4909.pdf", @@ -1145,6 +1431,20 @@ "rounds": 1, "type": "eq" }, + { + "id": "fit11-talk-partial", + "file": "pdfs/fit11-talk.pdf", + "md5": "eb7b224107205db4fea9f7df0185f77d", + "link": true, + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "fips197", "file": "pdfs/fips197.pdf", @@ -1243,6 +1543,22 @@ "lastPage": 1, "type": "eq" }, + { + "id": "issue8078-partial", + "file": "pdfs/issue8078.pdf", + "md5": "8b7d74bc24b4157393e4e88a511c05f1", + "link": true, + "rounds": 1, + "lastPage": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": "Gives slightly different results in Firefox when running in headless mode" + }, { "id": "bug1811668", "file": "pdfs/bug1811668_reduced.pdf", @@ -1371,6 +1687,19 @@ "rounds": 1, "type": "eq" }, + { + "id": "issue13561_reduced-partial", + "file": "pdfs/issue13561_reduced.pdf", + "md5": "e68c315d6349530180dd90f93027147e", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue5202", "file": "pdfs/issue5202.pdf", @@ -1991,6 +2320,20 @@ "link": false, "type": "eq" }, + { + "id": "issue9458-partial", + "file": "pdfs/issue9458.pdf", + "md5": "ee54358d8b2fdc75dc8da5220cf8e8da", + "rounds": 1, + "link": false, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue5501", "file": "pdfs/issue5501.pdf", @@ -2176,6 +2519,20 @@ "type": "eq", "about": "Optional marked content." }, + { + "id": "issue269_1-partial", + "file": "pdfs/issue269_1.pdf", + "md5": "ab932f697b4d2e2bf700de15a8efea9c", + "rounds": 1, + "type": "eq", + "about": "Optional marked content.", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue269_2", "file": "pdfs/issue269_2.pdf", @@ -2224,6 +2581,21 @@ "firstPage": 2, "type": "eq" }, + { + "id": "issue10491-partial", + "file": "pdfs/issue10491.pdf", + "md5": "0759ec46739b13bb0b66170a18d33d4f", + "rounds": 1, + "link": true, + "firstPage": 13, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue10542", "file": "pdfs/issue10542_reduced.pdf", @@ -2269,6 +2641,21 @@ "lastPage": 1, "type": "eq" }, + { + "id": "pr8808-partial", + "file": "pdfs/pr8808.pdf", + "md5": "bdac6051a98fd8dcfc5344b05fed06f4", + "rounds": 1, + "link": true, + "lastPage": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue5599", "file": "pdfs/issue5599.pdf", @@ -2435,6 +2822,21 @@ "lastPage": 1, "type": "eq" }, + { + "id": "issue15942-partial", + "file": "pdfs/issue15942.pdf", + "md5": "d690e16e6a3a8486ebf7289a9c43ba39", + "rounds": 1, + "link": true, + "lastPage": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "bug1046314", "file": "pdfs/bug1046314.pdf", @@ -2491,6 +2893,20 @@ "rounds": 1, "type": "eq" }, + { + "id": "issue12295-partial", + "file": "pdfs/issue12295.pdf", + "md5": "c534f74866ba8ada56010d19b57231ec", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": true + }, { "id": "bug1245391-text", "file": "pdfs/bug1245391_reduced.pdf", @@ -2587,6 +3003,19 @@ "rounds": 1, "type": "eq" }, + { + "id": "issue14953-partial", + "file": "pdfs/issue14953.pdf", + "md5": "dd7d6cb92e58d75a0eb8c0476a3bc64a", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue14990", "file": "pdfs/issue14990.pdf", @@ -2597,6 +3026,22 @@ "lastPage": 25, "type": "eq" }, + { + "id": "issue14990-partial", + "file": "pdfs/issue14990.pdf", + "md5": "0fb397e1506acc4ab8e68c18212a1362", + "link": true, + "rounds": 1, + "firstPage": 25, + "lastPage": 25, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "extgstate-text", "file": "pdfs/extgstate.pdf", @@ -2794,6 +3239,20 @@ "rounds": 1, "type": "eq" }, + { + "id": "bug1365930-partial", + "file": "pdfs/bug1365930.pdf", + "md5": "0db06efce060d82da16a79e8bd762a1c", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": true + }, { "id": "bug1337429", "file": "pdfs/bug1337429.pdf", @@ -2851,6 +3310,21 @@ "lastPage": 1, "type": "eq" }, + { + "id": "bug810214-partial", + "file": "pdfs/bug810214.pdf", + "md5": "2b7243178f5dd5fd3edc7b6649e4bdf3", + "link": true, + "rounds": 1, + "lastPage": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "Type3WordSpacing", "file": "pdfs/Type3WordSpacing.pdf", @@ -2859,6 +3333,20 @@ "rounds": 1, "type": "eq" }, + { + "id": "Type3WordSpacing-partial", + "file": "pdfs/Type3WordSpacing.pdf", + "md5": "8c75440e5b95cf521d186f862b404516", + "link": false, + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue19954", "file": "pdfs/issue19954.pdf", @@ -2929,6 +3417,22 @@ "type": "eq", "about": "Type3 fonts with pattern resources; both pages need to be tested, otherwise the bug won't manifest." }, + { + "id": "issue16127-partial", + "file": "pdfs/issue16127.pdf", + "md5": "42714567a818876f51ef960df21600f5", + "link": true, + "rounds": 1, + "firstPage": 1, + "lastPage": 2, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "doc_actions", "file": "pdfs/doc_actions.pdf", @@ -3312,6 +3816,21 @@ "rounds": 1, "type": "eq" }, + { + "id": "tutorial-partial", + "file": "pdfs/tutorial.pdf", + "md5": "6e122f618c27f3aa9a689423e3be6b8d", + "link": true, + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": "Text antialiasing is rendered differently, but only in headless firefox (used in the bots)" + }, { "id": "geothermal.pdf", "file": "pdfs/geothermal.pdf", @@ -3420,6 +3939,20 @@ "rounds": 1, "type": "eq" }, + { + "id": "issue1905-partial", + "file": "pdfs/issue1905.pdf", + "md5": "b1bbd72ca6522ae1502aa26320f81994", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": true + }, { "id": "issue918", "file": "pdfs/issue918.pdf", @@ -3590,6 +4123,20 @@ "link": false, "type": "eq" }, + { + "id": "issue5804-partial", + "file": "pdfs/issue5804.pdf", + "md5": "442f27939edb6aaf173ceff38d69bb14", + "rounds": 1, + "link": false, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue7446", "file": "pdfs/issue7446.pdf", @@ -4323,6 +4870,7 @@ "type": "eq" }, { + "TODO": "Very slow when recording", "id": "issue12810", "file": "pdfs/issue12810.pdf", "md5": "585e19781308603dd706f941b1ace774", @@ -4523,6 +5071,23 @@ "37R": false } }, + { + "id": "issue15719-partial", + "file": "pdfs/issue15719.pdf", + "md5": "4a58dbe725897e787a93e26abc621191", + "link": true, + "rounds": 1, + "type": "eq", + "optionalContent": { + "37R": false + }, + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue15833", "file": "pdfs/issue15833.pdf", @@ -4844,6 +5409,19 @@ "rounds": 1, "type": "eq" }, + { + "id": "issue7891_bc0-partial", + "file": "pdfs/issue7891_bc0.pdf", + "md5": "744a22244a4e4708b7f1691eec155fc8", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue14821", "file": "pdfs/issue14821.pdf", @@ -4985,6 +5563,20 @@ "link": true, "type": "eq" }, + { + "id": "issue4244-partial", + "file": "pdfs/issue4244.pdf", + "md5": "26845274a32a537182ced1fd693a38b2", + "rounds": 1, + "link": true, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue14462", "file": "pdfs/issue14462_reduced.pdf", @@ -5236,6 +5828,21 @@ "link": true, "type": "eq" }, + { + "id": "issue1466-partial", + "file": "pdfs/issue1466.pdf", + "md5": "8a8877432e5bb10cfd50d60488d947bb", + "rounds": 1, + "link": true, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": "Slightly different (not visible) partial pixel color at the edge of the image" + }, { "id": "bug1064894", "file": "pdfs/bug1064894.pdf", @@ -5338,6 +5945,22 @@ "lastPage": 6, "type": "eq" }, + { + "id": "issue6117-partial", + "file": "pdfs/issue6117.pdf", + "md5": "691f5f8268e07f3831e8293258a68da7", + "rounds": 1, + "link": true, + "firstPage": 6, + "lastPage": 6, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue6238", "file": "pdfs/issue6238.pdf", @@ -5718,6 +6341,22 @@ "lastPage": 1, "type": "eq" }, + { + "id": "bug1898802-partial", + "file": "pdfs/bug1898802.pdf", + "md5": "65c3af306253faa8967982812aff523e", + "rounds": 1, + "link": true, + "lastPage": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": "Slightly different (not visible) single pixel color at the edge of the image" + }, { "id": "issue4890", "file": "pdfs/issue4890.pdf", @@ -5969,6 +6608,19 @@ "rounds": 1, "type": "eq" }, + { + "id": "pattern_text_embedded_font-partial", + "file": "pdfs/pattern_text_embedded_font.pdf", + "md5": "763b1b9efaecb2b5aefea71c39233f56", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue6113", "file": "pdfs/issue6113.pdf", @@ -6049,6 +6701,20 @@ "type": "eq", "about": "Rotated transparency group with blend mode." }, + { + "id": "transparency_group-partial", + "file": "pdfs/transparency_group.pdf", + "md5": "10391f76434128e5da70cff5fc485ff0", + "rounds": 1, + "type": "eq", + "about": "Rotated transparency group with blend mode.", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue6010_1", "file": "pdfs/issue6010_1.pdf", @@ -6342,6 +7008,20 @@ "type": "eq", "about": "CFF font that is drawn with clipping." }, + { + "id": "issue3584-partial", + "file": "pdfs/issue3584.pdf", + "md5": "7a00646865a840eefc76f05c588b60ce", + "rounds": 1, + "type": "eq", + "about": "CFF font that is drawn with clipping.", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "prefilled_f1040", "file": "pdfs/prefilled_f1040.pdf", @@ -6530,6 +7210,23 @@ "link": true, "type": "eq" }, + { + "id": "bug887152-partial", + "file": "pdfs/bug887152.pdf", + "md5": "783a3e7b1de2cf40a47ffe1f36a41d4f", + "rounds": 1, + "lastPage": 1, + "link": true, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "TODO": "There are a ton of one-bit differences in pixel colors", + "knownPartialMismatch": true + }, { "id": "issue11473", "file": "pdfs/issue11473.pdf", @@ -7371,6 +8068,21 @@ "link": true, "type": "eq" }, + { + "id": "issue4926-partial", + "file": "pdfs/issue4926.pdf", + "md5": "ed881c8ea2f9bc4be94ecb7f2b2c149b", + "rounds": 1, + "link": true, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": "Half pixel rendering at the edge of the image" + }, { "id": "issue16454", "file": "pdfs/issue16454.pdf", @@ -8185,6 +8897,22 @@ "lastPage": 1, "type": "eq" }, + { + "id": "bug1749563-partial", + "file": "pdfs/bug1749563.pdf", + "md5": "11294f6071a8dcc25b0e18953cee68fa", + "rounds": 1, + "link": true, + "firstPage": 1, + "lastPage": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue9186", "file": "pdfs/issue9186.pdf", @@ -8303,6 +9031,21 @@ "lastPage": 1, "type": "eq" }, + { + "id": "issue12306-partial", + "file": "pdfs/issue12306.pdf", + "md5": "7fd05ba56791238b5a60adc6cc0e7a22", + "rounds": 1, + "link": true, + "lastPage": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue3351.1", "file": "pdfs/issue3351.1.pdf", @@ -8788,6 +9531,20 @@ "rounds": 1, "type": "eq" }, + { + "id": "bug1791583-partial", + "file": "pdfs/bug1791583.pdf", + "md5": "1ff6badc865c9a5e9a0dc0b7131ffe28", + "link": true, + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "bug1795263", "file": "pdfs/bug1795263.pdf", @@ -8960,6 +9717,19 @@ "rounds": 1, "type": "eq" }, + { + "id": "freetext_no_appearance-partial", + "file": "pdfs/freetext_no_appearance.pdf", + "md5": "1dc519c06f1dc6f6e594f168080dcde9", + "rounds": 1, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "freetext_print_no_appearance", "file": "pdfs/freetext_no_appearance.pdf", @@ -9177,6 +9947,20 @@ "link": true, "type": "eq" }, + { + "id": "issue16114-partial", + "file": "pdfs/issue16114.pdf", + "md5": "c04827ea33692e0f94a5e51716d9aa2e", + "rounds": 1, + "link": true, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + } + }, { "id": "issue16114-disable-isOffscreenCanvasSupported", "file": "pdfs/issue16114.pdf", @@ -10473,6 +11257,22 @@ "link": true, "type": "eq" }, + { + "id": "issue17779-partial", + "file": "pdfs/issue17779.pdf", + "md5": "764b72e8e56e22662b321b308254fd2b", + "talos": false, + "rounds": 1, + "link": true, + "type": "eq", + "partial": { + "minX": 0.25, + "maxX": 0.5, + "minY": 0.25, + "maxY": 0.5 + }, + "knownPartialMismatch": "One-bit difference in two pixels at the edge" + }, { "id": "issue14724", "file": "pdfs/issue14724.pdf", diff --git a/web/app.js b/web/app.js index 69f5ea298..14fe84e70 100644 --- a/web/app.js +++ b/web/app.js @@ -534,6 +534,9 @@ const PDFViewerApplication = { capCanvasAreaFactor, enableDetailCanvas: AppOptions.get("enableDetailCanvas"), enablePermissions: AppOptions.get("enablePermissions"), + enableOptimizedPartialRendering: AppOptions.get( + "enableOptimizedPartialRendering" + ), pageColors, mlManager, abortSignal, diff --git a/web/app_options.js b/web/app_options.js index aa52b978f..254b5cbb6 100644 --- a/web/app_options.js +++ b/web/app_options.js @@ -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, diff --git a/web/base_pdf_page_view.js b/web/base_pdf_page_view.js index 71d7693be..116ac1a55 100644 --- a/web/base_pdf_page_view.js +++ b/web/base_pdf_page_view.js @@ -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; diff --git a/web/debugger.css b/web/debugger.css index 0c64d69b7..74058440d 100644 --- a/web/debugger.css +++ b/web/debugger.css @@ -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; + } +} diff --git a/web/debugger.mjs b/web/debugger.mjs index ba0186925..91300d624 100644 --- a/web/debugger.mjs +++ b/web/debugger.mjs @@ -200,6 +200,10 @@ const StepperManager = (function StepperManagerClosure() { active: false, // Stepper specific functions. create(pageIndex) { + const pageContainer = document.querySelector( + `#viewer div[data-page-number="${pageIndex + 1}"]` + ); + const debug = document.createElement("div"); debug.id = "stepper" + pageIndex; debug.hidden = true; @@ -210,7 +214,12 @@ const StepperManager = (function StepperManagerClosure() { b.value = pageIndex; stepperChooser.append(b); const initBreakPoints = breakPoints[pageIndex] || []; - const stepper = new Stepper(debug, pageIndex, initBreakPoints); + const stepper = new Stepper( + debug, + pageIndex, + initBreakPoints, + pageContainer + ); steppers.push(stepper); if (steppers.length === 1) { this.selectStepper(pageIndex, false); @@ -277,7 +286,7 @@ class Stepper { return simpleObj; } - constructor(panel, pageIndex, initialBreakPoints) { + constructor(panel, pageIndex, initialBreakPoints, pageContainer) { this.panel = panel; this.breakPoint = 0; this.nextBreakPoint = null; @@ -286,11 +295,20 @@ class Stepper { this.currentIdx = -1; this.operatorListIdx = 0; this.indentLevel = 0; + this.operatorGroups = null; + this.pageContainer = pageContainer; } init(operatorList) { const panel = this.panel; const content = this.#c("div", "c=continue, s=step"); + + const showBoxesToggle = this.#c("label", "Show bounding boxes"); + const showBoxesCheckbox = this.#c("input"); + showBoxesCheckbox.type = "checkbox"; + showBoxesToggle.prepend(showBoxesCheckbox); + content.append(this.#c("br"), showBoxesToggle); + const table = this.#c("table"); content.append(table); table.cellSpacing = 0; @@ -305,6 +323,22 @@ class Stepper { panel.append(content); this.table = table; this.updateOperatorList(operatorList); + + const hoverStyle = this.#c("style"); + this.hoverStyle = hoverStyle; + content.prepend(hoverStyle); + table.addEventListener("mouseover", this.#handleStepHover.bind(this)); + table.addEventListener("mouseleave", e => { + hoverStyle.innerText = ""; + }); + + showBoxesCheckbox.addEventListener("change", () => { + if (showBoxesCheckbox.checked) { + this.pageContainer.classList.add("showDebugBoxes"); + } else { + this.pageContainer.classList.remove("showDebugBoxes"); + } + }); } updateOperatorList(operatorList) { @@ -405,6 +439,114 @@ class Stepper { this.table.append(chunk); } + setOperatorGroups(groups) { + let boxesContainer = this.pageContainer.querySelector(".pdfBugGroupsLayer"); + if (!boxesContainer) { + boxesContainer = this.#c("div"); + boxesContainer.classList.add("pdfBugGroupsLayer"); + this.pageContainer.append(boxesContainer); + + boxesContainer.addEventListener( + "click", + this.#handleDebugBoxClick.bind(this) + ); + boxesContainer.addEventListener( + "mouseover", + this.#handleDebugBoxHover.bind(this) + ); + } + boxesContainer.innerHTML = ""; + + groups = groups.toSorted((a, b) => { + const diffs = [ + a.minX - b.minX, + a.minY - b.minY, + b.maxX - a.maxX, + b.maxY - a.maxY, + ]; + for (const diff of diffs) { + if (Math.abs(diff) > 0.01) { + return Math.sign(diff); + } + } + for (const diff of diffs) { + if (Math.abs(diff) > 0.0001) { + return Math.sign(diff); + } + } + return 0; + }); + this.operatorGroups = groups; + + for (let i = 0; i < groups.length; i++) { + const el = this.#c("div"); + el.style.left = `${groups[i].minX * 100}%`; + el.style.top = `${groups[i].minY * 100}%`; + el.style.width = `${(groups[i].maxX - groups[i].minX) * 100}%`; + el.style.height = `${(groups[i].maxY - groups[i].minY) * 100}%`; + el.dataset.groupIdx = i; + boxesContainer.append(el); + } + } + + #handleStepHover(e) { + const tr = e.target.closest("tr"); + if (!tr || tr.dataset.idx === undefined) { + return; + } + + const index = +tr.dataset.idx; + + const closestGroupIndex = + this.operatorGroups?.findIndex(({ idx }) => idx === index) ?? -1; + if (closestGroupIndex === -1) { + this.hoverStyle.innerText = ""; + return; + } + + this.#highlightStepsGroup(closestGroupIndex); + } + + #handleDebugBoxHover(e) { + if (e.target.dataset.groupIdx === undefined) { + return; + } + + const groupIdx = Number(e.target.dataset.groupIdx); + this.#highlightStepsGroup(groupIdx); + } + + #handleDebugBoxClick(e) { + if (e.target.dataset.groupIdx === undefined) { + return; + } + + const groupIdx = Number(e.target.dataset.groupIdx); + const group = this.operatorGroups[groupIdx]; + + this.table.childNodes[group.idx].scrollIntoView(); + } + + #highlightStepsGroup(groupIndex) { + const group = this.operatorGroups[groupIndex]; + + this.hoverStyle.innerText = `#${this.panel.id} tr[data-idx="${group.idx}"] { background-color: rgba(0, 0, 0, 0.1); }`; + + if (group.dependencies.length > 0) { + const selector = group.dependencies + .map(idx => `#${this.panel.id} tr[data-idx="${idx}"]`) + .join(", "); + this.hoverStyle.innerText += `${selector} { background-color: rgba(0, 255, 255, 0.1); }`; + } + + this.hoverStyle.innerText += ` + #viewer [data-page-number="${this.pageIndex + 1}"] .pdfBugGroupsLayer :nth-child(${groupIndex + 1}) { + background-color: var(--hover-background-color); + outline-style: var(--hover-outline-style); + } + `; + } + getNextBreakPoint() { this.breakPoints.sort((a, b) => a - b); for (const breakPoint of this.breakPoints) { diff --git a/web/pdf_page_detail_view.js b/web/pdf_page_detail_view.js index 10269c592..52cf13c45 100644 --- a/web/pdf_page_detail_view.js +++ b/web/pdf_page_detail_view.js @@ -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(); diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index c25c5dfcd..e98d6571c 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -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, }; } diff --git a/web/pdf_viewer.js b/web/pdf_viewer.js index 99a4c49fc..37644840f 100644 --- a/web/pdf_viewer.js +++ b/web/pdf_viewer.js @@ -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,