diff --git a/src/display/api.js b/src/display/api.js index 1594d7cb2bfd1..eb2344432686f 100644 --- a/src/display/api.js +++ b/src/display/api.js @@ -58,6 +58,7 @@ import { NodeStandardFontDataFactory, } from "display-node_utils"; import { CanvasGraphics } from "./canvas.js"; +import { CanvasRecorder } from "./canvas_recorder.js"; import { DOMCanvasFactory } from "./canvas_factory.js"; import { DOMCMapReaderFactory } from "display-cmap_reader_factory"; import { DOMFilterFactory } from "./filter_factory.js"; @@ -1513,9 +1514,22 @@ class PDFPageProxy { this._pumpOperatorList(intentArgs); } + const recordingContext = + this._pdfBug && + globalThis.StepperManager?.enabled && + !this._recordedGroups + ? new CanvasRecorder(canvasContext) + : null; + const complete = error => { intentState.renderTasks.delete(internalRenderTask); + if (recordingContext) { + this._recordedGroups = + CanvasRecorder.getFinishedGroups(recordingContext); + internalRenderTask.stepper.setOperatorGroups(this._recordedGroups); + } + // Attempt to reduce memory usage during *printing*, by always running // cleanup immediately once rendering has finished. if (this._maybeCleanupAfterRender || intentPrint) { @@ -1548,7 +1562,7 @@ class PDFPageProxy { callback: complete, // Only include the required properties, and *not* the entire object. params: { - canvasContext, + canvasContext: recordingContext ?? canvasContext, viewport, transform, background, diff --git a/src/display/canvas.js b/src/display/canvas.js index ee9c4f05320f9..21f709d708b83 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -37,6 +37,7 @@ import { PathType, TilingPattern, } from "./pattern_helper.js"; +import { CanvasRecorder } from "./canvas_recorder.js"; import { convertBlackAndWhiteToRGBA } from "../shared/image_utils.js"; // contexts store most of the state we need natively. @@ -459,7 +460,9 @@ function compileType3Glyph(imgData) { } class CanvasExtraState { - constructor(width, height) { + constructor(width, height, preInit) { + preInit?.(this); + // Are soft masks and alpha values shapes or opacities? this.alphaIsShape = false; this.fontSize = 0; @@ -584,6 +587,69 @@ class CanvasExtraState { this.getPathBoundingBox(pathType, transform) ); } + + takeDependencies() {} + + setNextCommandsId() {} +} + +class CanvasExtraStateDependenciesRecorder extends CanvasExtraState { + constructor(width, height) { + super(width, height, self => { + self._dependencies = new Set(); + self._dependencyIds = Object.create(null); + self._storage = {}; + self._nextCommandsIdRef = { value: -1 }; + }); + } + + clone() { + const clone = super.clone(); + clone._dependencyIds = Object.create(this._dependencyIds); + clone._storage = Object.create(this._storage); + return clone; + } + + takeDependencies() { + if (this._dependencies.size === 0) { + return undefined; + } + + const arr = Array.from(this._dependencies); + this._dependencies.clear(); + return arr; + } + + setNextCommandsId(id) { + this._nextCommandsIdRef.value = id; + } + + static { + const trackedNames = [ + "fillAlpha", + "strokeAlpha", + "lineWidth", + "activeSMask", + "transferMaps", + ]; + + for (const name of trackedNames) { + Object.defineProperty(this.prototype, name, { + enumerable: true, + get() { + const id = this._dependencyIds[name]; + if (id !== undefined && id !== -1) { + this._dependencies.add(id); + } + return this._storage[name]; + }, + set(v) { + this._storage[name] = v; + this._dependencyIds[name] = this._nextCommandsIdRef.value; + }, + }); + } + } } function putBinaryImageData(ctx, imgData) { @@ -833,10 +899,11 @@ class CanvasGraphics { pageColors ) { this.ctx = canvasCtx; - this.current = new CanvasExtraState( - this.ctx.canvas.width, - this.ctx.canvas.height - ); + this.current = new ( + canvasCtx instanceof CanvasRecorder + ? CanvasExtraStateDependenciesRecorder + : CanvasExtraState + )(this.ctx.canvas.width, this.ctx.canvas.height); this.stateStack = []; this.pendingClip = null; this.pendingEOFill = false; @@ -953,7 +1020,7 @@ class CanvasGraphics { const commonObjs = this.commonObjs; const objs = this.objs; - let fnId; + let fnId, fnArgs; while (true) { if (stepper !== undefined && i === stepper.nextBreakPoint) { @@ -962,12 +1029,24 @@ class CanvasGraphics { } fnId = fnArray[i]; + fnArgs = argsArray[i]; if (fnId !== OPS.dependency) { - // eslint-disable-next-line prefer-spread - this[fnId].apply(this, argsArray[i]); + CanvasRecorder.setNextCommandsId(this.ctx, i); + this.current.setNextCommandsId(i); + + if (fnArgs === null) { + this[fnId](i); + } else { + this[fnId](i, ...fnArgs); + } + + CanvasRecorder.addExtraDependencies( + this.ctx, + this.current.takeDependencies() + ); } else { - for (const depObjId of argsArray[i]) { + for (const depObjId of fnArgs) { const objsPool = depObjId.startsWith("g_") ? commonObjs : objs; // If the promise isn't resolved yet, add the continueCallback @@ -1279,7 +1358,7 @@ class CanvasGraphics { } // Graphics state - setLineWidth(width) { + setLineWidth(opIdx, width) { if (width !== this.current.lineWidth) { this._cachedScaleForStroking[0] = -1; } @@ -1287,19 +1366,19 @@ class CanvasGraphics { this.ctx.lineWidth = width; } - setLineCap(style) { + setLineCap(opIdx, style) { this.ctx.lineCap = LINE_CAP_STYLES[style]; } - setLineJoin(style) { + setLineJoin(opIdx, style) { this.ctx.lineJoin = LINE_JOIN_STYLES[style]; } - setMiterLimit(limit) { + setMiterLimit(opIdx, limit) { this.ctx.miterLimit = limit; } - setDash(dashArray, dashPhase) { + setDash(opIdx, dashArray, dashPhase) { const ctx = this.ctx; if (ctx.setLineDash !== undefined) { ctx.setLineDash(dashArray); @@ -1307,15 +1386,15 @@ 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": @@ -1562,7 +1641,13 @@ class CanvasGraphics { layerCtx.restore(); } - save() { + save(opIdx) { + CanvasRecorder.startGroupRecording(this.ctx, { + type: "save", + startIdx: opIdx, + endIdx: -1, + }); + 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 @@ -1579,7 +1664,7 @@ class CanvasGraphics { this.current = old.clone(); } - restore() { + restore(opIdx) { if (this.stateStack.length === 0 && this.inSMaskMode) { this.endSMaskMode(); } @@ -1595,6 +1680,11 @@ class CanvasGraphics { } this.checkSMaskState(); + const groupData = CanvasRecorder.endGroupRecording(this.ctx, "save"); + if (groupData) { + groupData.endIdx = opIdx; + } + // Ensure that the clipping path is reset (fixes issue6413.pdf). this.pendingClip = null; @@ -1603,7 +1693,7 @@ class CanvasGraphics { } } - transform(a, b, c, d, e, f) { + transform(opIdx, a, b, c, d, e, f) { this.ctx.transform(a, b, c, d, e, f); this._cachedScaleForStroking[0] = -1; @@ -1611,8 +1701,15 @@ class CanvasGraphics { } // Path - constructPath(ops, args, minMax) { + constructPath(opIdx, ops, args, minMax) { const ctx = this.ctx; + + CanvasRecorder.startGroupRecording(ctx, { + type: "path", + startIdx: opIdx, + endIdx: -1, + }); + const current = this.current; let x = current.x, y = current.y; @@ -1754,13 +1851,15 @@ class CanvasGraphics { } current.setCurrentPoint(x, y); + + this._pathStartIdx = opIdx; } - closePath() { + closePath(opIdx) { this.ctx.closePath(); } - stroke(consumePath = true) { + stroke(opIdx, consumePath = true) { const ctx = this.ctx; const strokeColor = this.current.strokeColor; // For stroke we want to temporarily change the global alpha to the @@ -1781,19 +1880,22 @@ class CanvasGraphics { this.rescaleAndStroke(/* saveRestore */ true); } } + if (consumePath) { - this.consumePath(this.current.getClippedPathBoundingBox()); + const bbox = this.current.getClippedPathBoundingBox(); + this.consumePath(opIdx, bbox); } + // Restore the global alpha to the fill alpha ctx.globalAlpha = this.current.fillAlpha; } - closeStroke() { - this.closePath(); - this.stroke(); + closeStroke(opIdx) { + this.closePath(opIdx); + this.stroke(opIdx); } - fill(consumePath = true) { + fill(opIdx, consumePath = true) { const ctx = this.ctx; const fillColor = this.current.fillColor; const isPatternFill = this.current.patternFill; @@ -1824,101 +1926,114 @@ class CanvasGraphics { ctx.restore(); } if (consumePath) { - this.consumePath(intersect); + this.consumePath(opIdx, intersect); } } - eoFill() { + eoFill(opIdx) { this.pendingEOFill = true; - this.fill(); + this.fill(opIdx); } - fillStroke() { - this.fill(false); - this.stroke(false); + fillStroke(opIdx) { + this.fill(opIdx, false); + this.stroke(opIdx, false); - this.consumePath(); + this.consumePath(opIdx); } - eoFillStroke() { + eoFillStroke(opIdx) { this.pendingEOFill = true; - this.fillStroke(); + this.fillStroke(opIdx); } - closeFillStroke() { - this.closePath(); - this.fillStroke(); + closeFillStroke(opIdx) { + this.closePath(opIdx); + this.fillStroke(opIdx); } - closeEOFillStroke() { + closeEOFillStroke(opIdx) { this.pendingEOFill = true; - this.closePath(); - this.fillStroke(); + this.closePath(opIdx); + this.fillStroke(opIdx); } - endPath() { - this.consumePath(); + endPath(opIdx) { + this.consumePath(opIdx); } // Clipping - clip() { + clip(opIdx) { this.pendingClip = NORMAL_CLIP; } - eoClip() { + eoClip(opIdx) { this.pendingClip = EO_CLIP; } // Text - beginText() { + beginText(opIdx) { this.current.textMatrix = IDENTITY_MATRIX; this.current.textMatrixScale = 1; this.current.x = this.current.lineX = 0; this.current.y = this.current.lineY = 0; + + CanvasRecorder.startGroupRecording(this.ctx, { + type: "text", + startIdx: opIdx, + endIdx: -1, + }); } - endText() { + endText(opIdx) { const paths = this.pendingTextPaths; const ctx = this.ctx; - if (paths === undefined) { - ctx.beginPath(); - return; + if (paths !== undefined) { + const newPath = new Path2D(); + const invTransf = ctx.getTransform().invertSelf(); + for (const { transform, x, y, fontSize, path } of paths) { + newPath.addPath( + path, + new DOMMatrix(transform) + .preMultiplySelf(invTransf) + .translate(x, y) + .scale(fontSize, -fontSize) + ); + } + + ctx.clip(newPath); } - const newPath = new Path2D(); - const invTransf = ctx.getTransform().invertSelf(); - for (const { transform, x, y, fontSize, path } of paths) { - newPath.addPath( - path, - new DOMMatrix(transform) - .preMultiplySelf(invTransf) - .translate(x, y) - .scale(fontSize, -fontSize) - ); + const groupData = CanvasRecorder.endGroupRecording( + this.ctx, + "text", + this.current.takeDependencies() + ); + if (groupData) { + groupData.endIdx = opIdx; } - ctx.clip(newPath); ctx.beginPath(); delete this.pendingTextPaths; } - setCharSpacing(spacing) { + setCharSpacing(opIdx, spacing) { this.current.charSpacing = spacing; } - setWordSpacing(spacing) { + setWordSpacing(opIdx, spacing) { this.current.wordSpacing = spacing; } - setHScale(scale) { + setHScale(opIdx, scale) { this.current.textHScale = scale / 100; } - setLeading(leading) { + setLeading(opIdx, leading) { this.current.leading = -leading; } - setFont(fontRefName, size) { + setFont(opIdx, fontRefName, size) { const fontObj = this.commonObjs.get(fontRefName); const current = this.current; @@ -1976,25 +2091,25 @@ class CanvasGraphics { this.ctx.font = `${italic} ${bold} ${browserFontSize}px ${typeface}`; } - setTextRenderingMode(mode) { + setTextRenderingMode(opIdx, mode) { this.current.textRenderingMode = mode; } - setTextRise(rise) { + setTextRise(opIdx, rise) { this.current.textRise = rise; } - moveText(x, y) { + moveText(opIdx, x, y) { this.current.x = this.current.lineX += x; this.current.y = this.current.lineY += y; } - setLeadingMoveText(x, y) { + setLeadingMoveText(opIdx, x, y) { this.setLeading(-y); this.moveText(x, y); } - setTextMatrix(a, b, c, d, e, f) { + setTextMatrix(opIdx, a, b, c, d, e, f) { this.current.textMatrix = [a, b, c, d, e, f]; this.current.textMatrixScale = Math.hypot(a, b); @@ -2002,7 +2117,7 @@ class CanvasGraphics { this.current.y = this.current.lineY = 0; } - nextLine() { + nextLine(opIdx) { this.moveText(0, this.current.leading); } @@ -2121,7 +2236,7 @@ class CanvasGraphics { return shadow(this, "isFontSubpixelAAEnabled", enabled); } - showText(glyphs) { + showText(opIdx, glyphs) { const current = this.current; const font = current.font; if (font.isType3Font) { @@ -2386,12 +2501,12 @@ class CanvasGraphics { } // 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) { this.ctx.rect(llx, lly, urx - llx, ury - lly); this.ctx.clip(); this.endPath(); @@ -2430,17 +2545,17 @@ class CanvasGraphics { return pattern; } - setStrokeColorN() { + setStrokeColorN(opIdx) { this.current.strokeColor = this.getColorN_Pattern(arguments); this.current.patternStroke = true; } - setFillColorN() { + setFillColorN(opIdx) { this.current.fillColor = this.getColorN_Pattern(arguments); this.current.patternFill = true; } - setStrokeRGBColor(r, g, b) { + setStrokeRGBColor(opIdx, r, g, b) { this.ctx.strokeStyle = this.current.strokeColor = Util.makeHexColor( r, g, @@ -2449,17 +2564,17 @@ class CanvasGraphics { this.current.patternStroke = false; } - setStrokeTransparent() { + setStrokeTransparent(opIdx) { this.ctx.strokeStyle = this.current.strokeColor = "transparent"; this.current.patternStroke = false; } - setFillRGBColor(r, g, b) { + setFillRGBColor(opIdx, r, g, b) { this.ctx.fillStyle = this.current.fillColor = Util.makeHexColor(r, g, b); this.current.patternFill = false; } - setFillTransparent() { + setFillTransparent(opIdx) { this.ctx.fillStyle = this.current.fillColor = "transparent"; this.current.patternFill = false; } @@ -2478,13 +2593,13 @@ class CanvasGraphics { return pattern; } - shadingFill(objId) { + shadingFill(opIdx, objId) { if (!this.contentVisible) { return; } const ctx = this.ctx; - this.save(); + this.save(opIdx); const pattern = this._getPattern(objId); ctx.fillStyle = pattern.getPattern( ctx, @@ -2513,7 +2628,7 @@ class CanvasGraphics { } this.compose(this.current.getClippedPathBoundingBox()); - this.restore(); + this.restore(opIdx); } // Images @@ -2525,15 +2640,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); @@ -2542,25 +2657,25 @@ class CanvasGraphics { const height = bbox[3] - bbox[1]; this.ctx.rect(bbox[0], bbox[1], width, height); this.current.updateRectMinMax(getCurrentTransform(this.ctx), bbox); - this.clip(); - this.endPath(); + this.clip(opIdx); + 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) { @@ -2663,7 +2778,7 @@ class CanvasGraphics { // except the blend mode, soft mask, and alpha constants. copyCtxState(currentCtx, groupCtx); this.ctx = groupCtx; - this.setGState([ + this.setGState(opIdx, [ ["BM", "source-over"], ["ca", 1], ["CA", 1], @@ -2672,7 +2787,7 @@ class CanvasGraphics { this.groupLevel++; } - endGroup(group) { + endGroup(opIdx, group) { if (!this.contentVisible) { return; } @@ -2686,11 +2801,11 @@ class CanvasGraphics { if (group.smask) { this.tempSMask = this.smaskStack.pop(); - this.restore(); + this.restore(opIdx); } else { this.ctx.restore(); const currentMtx = getCurrentTransform(this.ctx); - this.restore(); + this.restore(opIdx); this.ctx.save(); this.ctx.setTransform(...currentMtx); const dirtyBox = Util.getAxialAlignedBoundingBox( @@ -2703,7 +2818,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... @@ -2712,7 +2827,7 @@ class CanvasGraphics { resetCtxToDefault(this.ctx); this.ctx.save(); - this.save(); + this.save(opIdx); if (this.baseTransform) { this.ctx.setTransform(...this.baseTransform); @@ -2767,16 +2882,17 @@ class CanvasGraphics { } } - this.current = new CanvasExtraState( - this.ctx.canvas.width, - this.ctx.canvas.height - ); + this.current = new ( + this.ctx instanceof CanvasRecorder + ? CanvasExtraStateDependenciesRecorder + : CanvasExtraState + )(this.ctx.canvas.width, 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(); @@ -2787,7 +2903,7 @@ class CanvasGraphics { } } - paintImageMaskXObject(img) { + paintImageMaskXObject(opIdx, img) { if (!this.contentVisible) { return; } @@ -2821,6 +2937,7 @@ class CanvasGraphics { } paintImageMaskXObjectRepeat( + opIdx, img, scaleX, skewX = 0, @@ -2865,7 +2982,7 @@ class CanvasGraphics { this.compose(); } - paintImageMaskXObjectGroup(images) { + paintImageMaskXObjectGroup(opIdx, images) { if (!this.contentVisible) { return; } @@ -2922,7 +3039,7 @@ class CanvasGraphics { this.compose(); } - paintImageXObject(objId) { + paintImageXObject(opIdx, objId) { if (!this.contentVisible) { return; } @@ -2932,10 +3049,21 @@ class CanvasGraphics { return; } - this.paintInlineImageXObject(imgData); + CanvasRecorder.startGroupRecording(this.ctx, { + type: "image", + idx: opIdx, + }); + + this.paintInlineImageXObject(opIdx, imgData); + + CanvasRecorder.endGroupRecording( + this.ctx, + "image", + this.current.takeDependencies() + ); } - paintImageXObjectRepeat(objId, scaleX, scaleY, positions) { + paintImageXObjectRepeat(opIdx, objId, scaleX, scaleY, positions) { if (!this.contentVisible) { return; } @@ -2957,7 +3085,7 @@ class CanvasGraphics { h: height, }); } - this.paintInlineImageXObjectGroup(imgData, map); + this.paintInlineImageXObjectGroup(opIdx, imgData, map); } applyTransferMapsToCanvas(ctx) { @@ -2987,7 +3115,7 @@ class CanvasGraphics { return tmpCanvas.canvas; } - paintInlineImageXObject(imgData) { + paintInlineImageXObject(opIdx, imgData) { if (!this.contentVisible) { return; } @@ -2995,7 +3123,7 @@ class CanvasGraphics { const height = imgData.height; const ctx = this.ctx; - this.save(); + this.save(opIdx); if ( (typeof PDFJSDev !== "undefined" && PDFJSDev.test("MOZCENTRAL")) || @@ -3056,10 +3184,10 @@ class CanvasGraphics { height ); this.compose(); - this.restore(); + this.restore(opIdx); } - paintInlineImageXObjectGroup(imgData, map) { + paintInlineImageXObjectGroup(opIdx, imgData, map) { if (!this.contentVisible) { return; } @@ -3098,7 +3226,7 @@ class CanvasGraphics { this.compose(); } - paintSolidColorImageMask() { + paintSolidColorImageMask(opIdx) { if (!this.contentVisible) { return; } @@ -3108,21 +3236,21 @@ class CanvasGraphics { // 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.markedContentStack.push({ visible: true, }); } - beginMarkedContentProps(tag, properties) { + beginMarkedContentProps(opIdx, tag, properties) { if (tag === "OC") { this.markedContentStack.push({ visible: this.optionalContentConfig.isVisible(properties), @@ -3135,24 +3263,24 @@ class CanvasGraphics { this.contentVisible = this.isContentVisible(); } - endMarkedContent() { + 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(clipBox) { + consumePath(opIdx, clipBox) { const isEmpty = this.current.isEmptyClip(); if (this.pendingClip) { this.current.updateClipFromPath(); @@ -3171,6 +3299,30 @@ class CanvasGraphics { } this.pendingClip = null; } + + if (ctx instanceof CanvasRecorder) { + if (clipBox) { + ctx.currentGroup.minX = clipBox[0]; + ctx.currentGroup.maxX = clipBox[2]; + ctx.currentGroup.minY = clipBox[1]; + ctx.currentGroup.maxY = clipBox[3]; + + const groupData = CanvasRecorder.endGroupRecording( + ctx, + "path", + this.current.takeDependencies() + ); + if (groupData) { + groupData.endIdx = opIdx; + } + } else { + // We are actually just defining a clip area, which has no visual + // effects. Its path is already considered for the bbox of its contents, + // that are actually clipped and thus get a potentially clipped bbox. + CanvasRecorder.discardGroupRecording(ctx, "path"); + } + } + this.current.startNewPathAndClipBox(this.current.clipBox); ctx.beginPath(); } diff --git a/src/display/canvas_recorder.js b/src/display/canvas_recorder.js new file mode 100644 index 0000000000000..3412dd53d83a4 --- /dev/null +++ b/src/display/canvas_recorder.js @@ -0,0 +1,352 @@ +const strokeDependencies = [ + "lineCap", + "lineJoin", + "lineWidth", + "miterLimit", + "strokeStyle", +]; +const fillDependencies = ["fillStyle"]; +const relativeTransformMethods = ["transform", "translate", "rotate", "scale"]; +const transformedMethods = [ + "arc", + "arcTo", + "beizerCurveTo", + // "drawImage", + "ellipse", + // "fillRect", + // "fillText", + "lineTo", + "moveTo", + "quadraticCurveTo", + "rect", + "roundRect", + "strokeRect", + "strokeText", +]; + +/** @implements {CanvasRenderingContext2D} */ +export class CanvasRecorder { + /** @type {CanvasRenderingContext2D} */ + #ctx; + + #canvasWidth; + + #canvasHeight; + + #groupsStack = []; + + #closedGroups = []; + + #dependenciesIds = Object.create(null); + + #transformDepsIds = []; + + #nextCommandsId = -1; + + /** @param {CanvasRenderingContext2D} */ + constructor(ctx) { + // Node.js does not suppot CanvasRenderingContext2D, and @napi-rs/canvas + // does not expose it directly. We can just avoid recording in this case. + if (typeof CanvasRenderingContext2D === "undefined") { + return ctx; + } + + this.#ctx = ctx; + this.#canvasWidth = ctx.canvas.width; + this.#canvasHeight = ctx.canvas.height; + this.#startGroup(); + } + + static startGroupRecording(ctx, data) { + return #startGroup in ctx ? ctx.#startGroup(data) : null; + } + + static discardGroupRecording(ctx, type) { + return #discardGroup in ctx ? ctx.#discardGroup(type) : null; + } + + static endGroupRecording(ctx, type, extraDependencies) { + if (#endGroup in ctx) { + this.addExtraDependencies(ctx, extraDependencies); + return ctx.#endGroup(type); + } + return null; + } + + static setNextCommandsId(ctx, id) { + if (#nextCommandsId in ctx) { + ctx.#nextCommandsId = id; + } + } + + static addExtraDependencies(ctx, ids) { + if (#currentGroup in ctx && ids) { + const dependenciesSet = ctx.#currentGroup.dependencies; + ids.forEach(dependenciesSet.add, dependenciesSet); + } + } + + /** @param {CanvasRecorder} */ + static getFinishedGroups(ctx) { + return ctx.#closedGroups; + } + + #startGroup(data) { + this.#groupsStack.push({ + minX: Infinity, + maxX: 0, + minY: Infinity, + maxY: 0, + dependencies: new Set(), + data, + }); + return this.#currentGroup; + } + + #discardGroup(type) { + const group = this.#groupsStack.pop(); + if (group.data.type !== type) { + this.#groupsStack.push(group); + // TODO: Warn? + return null; + } + + return group.data; + } + + #endGroup(type) { + const group = this.#groupsStack.pop(); + if (group.data.type !== type) { + this.#groupsStack.push(group); + + // TODO: Warn? + return null; + } + this.#currentGroup.maxX = Math.max(this.#currentGroup.maxX, group.maxX); + this.#currentGroup.minX = Math.min(this.#currentGroup.minX, group.minX); + this.#currentGroup.maxY = Math.max(this.#currentGroup.maxY, group.maxY); + this.#currentGroup.minY = Math.min(this.#currentGroup.minY, group.minY); + + this.#closedGroups.push({ + minX: group.minX / this.#canvasWidth, + maxX: group.maxX / this.#canvasWidth, + minY: group.minY / this.#canvasHeight, + maxY: group.maxY / this.#canvasHeight, + dependencies: Array.from(group.dependencies).sort(), + data: group.data, + }); + + return group.data; + } + + get #currentGroup() { + return this.#groupsStack.at(-1); + } + + get currentGroup() { + return this.#currentGroup; + } + + #unknown() { + this.#currentGroup.minX = 0; + this.#currentGroup.maxX = this.#canvasWidth; + this.#currentGroup.minY = 0; + this.#currentGroup.maxY = this.#canvasHeight; + } + + #registerBox(minX, maxX, minY, maxY) { + const matrix = this.#ctx.getTransform(); + + ({ x: minX, y: minY } = matrix.transformPoint(new DOMPoint(minX, minY))); + ({ x: maxX, y: maxY } = matrix.transformPoint(new DOMPoint(maxX, maxY))); + if (maxX < minX) { + [maxX, minX] = [minX, maxX]; + } + if (maxY < minY) { + [maxY, minY] = [minY, maxY]; + } + + const currentGroup = this.#currentGroup; + currentGroup.minX = Math.min(currentGroup.minX, minX); + currentGroup.maxX = Math.max(currentGroup.maxX, maxX); + currentGroup.minY = Math.min(currentGroup.minY, minY); + currentGroup.maxY = Math.max(currentGroup.maxY, maxY); + + this.#registerTransformDependencies(); + } + + #registerDependencies(names) { + const dependenciesSet = this.#currentGroup.dependencies; + for (const dep of names) { + const depId = this.#dependenciesIds[dep]; + if (depId !== undefined && depId !== -1) { + dependenciesSet.add(depId); + } + } + } + + #registerTransformDependencies() { + const dependenciesSet = this.#currentGroup.dependencies; + this.#transformDepsIds.forEach(dependenciesSet.add, dependenciesSet); + } + + get canvas() { + return this.#ctx.canvas; + } + + fillText(text, x, y, maxWidth) { + const measure = this.#ctx.measureText(text); + this.#registerBox( + x, + x + Math.min(measure.width, maxWidth ?? Infinity), + y - measure.actualBoundingBoxAscent, + y + measure.actualBoundingBoxDescent + ); + this.#registerDependencies(fillDependencies); + + this.#ctx.fillText(text, x, y, maxWidth); + } + + fillRect(x, y, width, height) { + this.#registerBox(x, x + width, y, y + height); + this.#registerDependencies(fillDependencies); + this.#ctx.fillRect(x, y, width, height); + } + + drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh) { + this.#registerBox( + dx ?? sx, + (dx ?? sx) + (dw ?? sw), + dy ?? sy, + (dy ?? sy) + (dh ?? sh) + ); + + this.#ctx.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh); + } + + save() { + this.#dependenciesIds = Object.create(this.#dependenciesIds); + this.#transformDepsIds = Object.create(this.#transformDepsIds); + this.#ctx.save(); + } + + restore() { + const prevDependencies = Object.getPrototypeOf(this.#dependenciesIds); + if (prevDependencies !== null) { + this.#dependenciesIds = prevDependencies; + this.#transformDepsIds = Object.getPrototypeOf(this.#transformDepsIds); + } + this.#ctx.restore(); + } + + static { + // Node.js does not suppot CanvasRenderingContext2D. The CanvasRecorder + // constructor will just return the unwrapped CanvasRenderingContext2D + // in this case, so it's ok if the .prototype doesn't have the methods + // properly copied over. + if (typeof CanvasRenderingContext2D !== "undefined") { + const dependencyAccessors = [ + "lineCap", + "lineJoin", + "lineWidth", + "miterLimit", + "fillStyle", + "strokeStyle", + ]; + for (const name of dependencyAccessors) { + Object.defineProperty(CanvasRecorder.prototype, name, { + configurable: true, + get() { + return this.#ctx[name]; + }, + set(v) { + this.#dependenciesIds[name] = this.#nextCommandsId; + this.#ctx[name] = v; + }, + }); + } + + for (const name of relativeTransformMethods) { + CanvasRecorder.prototype[name] = function (...args) { + this.#transformDepsIds.push(this.#nextCommandsId); + this.#ctx[name](...args); + }; + } + for (const name of ["setTransform", "resetTransform"]) { + CanvasRecorder.prototype[name] = function (...args) { + this.#transformDepsIds.length = 0; + this.#transformDepsIds.push(this.#nextCommandsId); + this.#ctx[name](...args); + }; + } + + const depsOfMethods = { + stroke: strokeDependencies, + strokeRect: strokeDependencies, + strokeText: strokeDependencies, + fill: fillDependencies, + }; + + const originalDescriptors = Object.getOwnPropertyDescriptors( + CanvasRenderingContext2D.prototype + ); + for (const name of Object.keys(originalDescriptors)) { + if (typeof name !== "string") { + continue; + } + + if (Object.hasOwn(CanvasRecorder.prototype, name)) { + if ( + Object.hasOwn(depsOfMethods, name) || + transformedMethods.includes(name) + ) { + throw new Error( + `Internal error: CanvasRecorder#${name} already defined` + ); + } + + continue; + } + + const desc = originalDescriptors[name]; + if (desc.get) { + Object.defineProperty(CanvasRecorder.prototype, name, { + configurable: true, + get() { + return this.#ctx[name]; + }, + set(v) { + this.#ctx[name] = v; + }, + }); + continue; + } + + if (typeof desc.value !== "function") { + continue; + } + + const ignoreBox = + /^(?:get|set|is)[A-Z]/.test(name) || + name === "beginPath" || + name === "closePath"; + const deps = depsOfMethods[name]; + const affectedByTransform = transformedMethods.includes(name); + + CanvasRecorder.prototype[name] = function (...args) { + if (!ignoreBox) { + // console.warn(`Untracked call to ${name}`); + this.#unknown(); + } + if (affectedByTransform) { + this.#registerTransformDependencies(); + } + if (deps) { + this.#registerDependencies(deps); + } + return this.#ctx[name](...args); + }; + } + } + } +} diff --git a/web/debugger.css b/web/debugger.css index b9d9f8190686e..45dd2c80e2386 100644 --- a/web/debugger.css +++ b/web/debugger.css @@ -109,3 +109,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 59c1871b3eb32..5eb8f46341777 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) { @@ -397,6 +431,112 @@ class Stepper { this.table.append(chunk); } + setOperatorGroups(groups) { + this.operatorGroups = 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 = ""; + + 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(({ data }) => { + if ("idx" in data) { + return data.idx === index; + } + if ("startIdx" in data) { + return data.startIdx <= index && index <= data.endIdx; + } + return false; + }) ?? -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]; + + const firstOp = "idx" in group.data ? group.data.idx : group.data.startIdx; + + this.table.childNodes[firstOp].scrollIntoView(); + } + + #highlightStepsGroup(groupIndex) { + const group = this.operatorGroups[groupIndex]; + + let cssSelector; + if ("idx" in group.data) { + cssSelector = `tr[data-idx="${group.data.idx}"]`; + } else if ("startIdx" in group.data) { + cssSelector = `:nth-child(n+${group.data.startIdx + 1} of tr[data-idx]):nth-child(-n+${group.data.endIdx + 1} of tr[data-idx])`; + } + + this.hoverStyle.innerText = `#${this.panel.id} ${cssSelector} { 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(function (a, b) { return a - b; diff --git a/web/pdf_page_view.js b/web/pdf_page_view.js index 26b3257f93ec3..989a87649239e 100644 --- a/web/pdf_page_view.js +++ b/web/pdf_page_view.js @@ -527,6 +527,8 @@ class PDFPageView { keepTextLayer = false, keepCanvasWrapper = false, } = {}) { + const keepPdfBugGroups = this.pdfPage?._pdfBug ?? false; + this.cancelRendering({ keepAnnotationLayer, keepAnnotationEditorLayer, @@ -555,6 +557,9 @@ class PDFPageView { case canvasWrapperNode: continue; } + if (keepPdfBugGroups && node.classList.contains("pdfBugGroupsLayer")) { + continue; + } node.remove(); const layerIndex = this.#layers.indexOf(node); if (layerIndex >= 0) {