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,