diff --git a/src/display/canvas.js b/src/display/canvas.js index debeca6a550476..914477171b4ed6 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. @@ -57,6 +58,27 @@ const MAX_SIZE_TO_COMPILE = 1000; const FULL_CHUNK_HEIGHT = 16; +function setter(obj, name) { + return val => { + obj[name] = val; + }; +} +function setterMany(obj, names) { + return val => { + for (const name of names) { + obj[name] = val; + } + }; +} + +function allValues(obj) { + const values = []; + for (const key in obj) { + values.push(obj[key]); + } + return values; +} + /** * Overrides certain methods on a 2d ctx so that when they are called they * will also call the same method on the destCtx. The methods that are @@ -492,12 +514,15 @@ class CanvasExtraState { this.activeSMask = null; this.transferMaps = "none"; + this.dependencyIndexes = Object.create(null); + this.startNewPathAndClipBox([0, 0, width, height]); } clone() { const clone = Object.create(this); clone.clipBox = this.clipBox.slice(); + clone.dependencyIndexes = Object.create(this.dependencyIndexes); return clone; } @@ -933,7 +958,8 @@ class CanvasGraphics { operatorList, executionStartIdx, continueCallback, - stepper + stepper, + filter ) { const argsArray = operatorList.argsArray; const fnArray = operatorList.fnArray; @@ -965,7 +991,10 @@ class CanvasGraphics { if (fnId !== OPS.dependency) { // eslint-disable-next-line prefer-spread - this[fnId].apply(this, argsArray[i]); + const setup = this[fnId].apply(this, argsArray[i]); + if (setup) { + setup(i); + } } else { for (const depObjId of argsArray[i]) { const objsPool = depObjId.startsWith("g_") ? commonObjs : objs; @@ -1285,14 +1314,20 @@ class CanvasGraphics { } this.current.lineWidth = width; this.ctx.lineWidth = width; + + return setter(this.current.dependencyIndexes, "lineWidth"); } setLineCap(style) { this.ctx.lineCap = LINE_CAP_STYLES[style]; + + return setter(this.current.dependencyIndexes, "lineCap"); } setLineJoin(style) { this.ctx.lineJoin = LINE_JOIN_STYLES[style]; + + return setter(this.current.dependencyIndexes, "lineJoin"); } setMiterLimit(limit) { @@ -1754,6 +1789,8 @@ class CanvasGraphics { } current.setCurrentPoint(x, y); + + return setter(this, "_pathStartIdx"); } closePath() { @@ -1781,16 +1818,22 @@ class CanvasGraphics { this.rescaleAndStroke(/* saveRestore */ true); } } + + let res; if (consumePath) { - this.consumePath(this.current.getClippedPathBoundingBox()); + const bbox = this.current.getClippedPathBoundingBox(); + res = this.consumePath(bbox); } + // Restore the global alpha to the fill alpha ctx.globalAlpha = this.current.fillAlpha; + + return res; } closeStroke() { this.closePath(); - this.stroke(); + return this.stroke(); } fill(consumePath = true) { @@ -1824,40 +1867,42 @@ class CanvasGraphics { ctx.restore(); } if (consumePath) { - this.consumePath(intersect); + return this.consumePath(intersect); } + + return undefined; } eoFill() { this.pendingEOFill = true; - this.fill(); + return this.fill(); } fillStroke() { this.fill(false); this.stroke(false); - this.consumePath(); + return this.consumePath(); } eoFillStroke() { this.pendingEOFill = true; - this.fillStroke(); + return this.fillStroke(); } closeFillStroke() { this.closePath(); - this.fillStroke(); + return this.fillStroke(); } closeEOFillStroke() { this.pendingEOFill = true; this.closePath(); - this.fillStroke(); + return this.fillStroke(); } endPath() { - this.consumePath(); + return this.consumePath(); } // Clipping @@ -1875,27 +1920,38 @@ class CanvasGraphics { this.current.textMatrixScale = 1; this.current.x = this.current.lineX = 0; this.current.y = this.current.lineY = 0; + + const group = CanvasRecorder.startGroupRecording(this.ctx, { + type: "text", + startIdx: -1, + endIdx: -1, + dependencies: allValues(this.current.dependencyIndexes), + }); + + return group ? setter(group.data, "startIdx") : null; } endText() { const paths = this.pendingTextPaths; const ctx = this.ctx; - if (paths === undefined) { + if (paths !== undefined) { + ctx.save(); ctx.beginPath(); - return; + for (const path of paths) { + ctx.setTransform(...path.transform); + ctx.translate(path.x, path.y); + path.addToPath(ctx, path.fontSize); + } + ctx.restore(); + ctx.clip(); } - ctx.save(); - ctx.beginPath(); - for (const path of paths) { - ctx.setTransform(...path.transform); - ctx.translate(path.x, path.y); - path.addToPath(ctx, path.fontSize); - } - ctx.restore(); - ctx.clip(); + const group = CanvasRecorder.endGroupRecording(this.ctx); + ctx.beginPath(); delete this.pendingTextPaths; + + return group ? setter(group.data, "endIdx") : null; } setCharSpacing(spacing) { @@ -2410,11 +2466,21 @@ class CanvasGraphics { setStrokeColorN() { this.current.strokeColor = this.getColorN_Pattern(arguments); this.current.patternStroke = true; + + return setterMany(this.current.dependencyIndexes, [ + "strokeColor", + "patternStroke", + ]); } setFillColorN() { this.current.fillColor = this.getColorN_Pattern(arguments); this.current.patternFill = true; + + return setterMany(this.current.dependencyIndexes, [ + "fillColor", + "patternFill", + ]); } setStrokeRGBColor(r, g, b) { @@ -2424,21 +2490,45 @@ class CanvasGraphics { b ); this.current.patternStroke = false; + + return setterMany(this.current.dependencyIndexes, [ + "strokeStyle", + "strokeColor", + "patternStroke", + ]); } setStrokeTransparent() { this.ctx.strokeStyle = this.current.strokeColor = "transparent"; this.current.patternStroke = false; + + return setterMany(this.current.dependencyIndexes, [ + "strokeStyle", + "strokeColor", + "patternStroke", + ]); } setFillRGBColor(r, g, b) { this.ctx.fillStyle = this.current.fillColor = Util.makeHexColor(r, g, b); this.current.patternFill = false; + + return setterMany(this.current.dependencyIndexes, [ + "fillStyle", + "fillColor", + "patternFill", + ]); } setFillTransparent() { this.ctx.fillStyle = this.current.fillColor = "transparent"; this.current.patternFill = false; + + return setterMany(this.current.dependencyIndexes, [ + "fillStyle", + "fillColor", + "patternFill", + ]); } _getPattern(objId, matrix = null) { @@ -2901,15 +2991,23 @@ class CanvasGraphics { paintImageXObject(objId) { if (!this.contentVisible) { - return; + return undefined; } const imgData = this.getObject(objId); if (!imgData) { warn("Dependent image isn't ready yet"); - return; + return undefined; } + const group = CanvasRecorder.startGroupRecording(this.ctx, { + type: "image", + idx: -1, + }); this.paintInlineImageXObject(imgData); + + CanvasRecorder.endGroupRecording(this.ctx); + + return group ? setter(group.data, "idx") : null; } paintImageXObjectRepeat(objId, scaleX, scaleY, positions) { @@ -3148,8 +3246,36 @@ class CanvasGraphics { } this.pendingClip = null; } + + let group = null; + if (clipBox) { + group = CanvasRecorder.pushGroup( + ctx, + clipBox[0], + clipBox[2], + clipBox[1], + clipBox[3], + { + type: "path", + startIdx: this._pathStartIdx, + endIdx: -1, + dependencies: allValues(this.current.dependencyIndexes), + } + ); + } else { + // TODO: Get the actual box + group = CanvasRecorder.pushGroup(ctx, 0, Infinity, 0, Infinity, { + type: "path", + startIdx: this._pathStartIdx, + endIdx: -1, + dependencies: allValues(this.current.dependencyIndexes), + }); + } + this.current.startNewPathAndClipBox(this.current.clipBox); ctx.beginPath(); + + return group ? setter(group.data, "endIdx") : null; } getSinglePixelWidth() { diff --git a/src/display/canvas_recorder.js b/src/display/canvas_recorder.js new file mode 100644 index 00000000000000..0a606d8e8ff65b --- /dev/null +++ b/src/display/canvas_recorder.js @@ -0,0 +1,222 @@ +/** @implements {CanvasRenderingContext2D} */ +export class CanvasRecorder { + /** @type {CanvasRenderingContext2D} */ + #ctx; + + #canvasWidth; + + #canvasHeight; + + #groupsStack = []; + + #closedGroups = []; + + /** @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 endGroupRecording(ctx) { + return #endGroup in ctx ? ctx.#endGroup() : null; + } + + /** @param {CanvasRecorder} */ + static getFinishedGroups(ctx) { + return ctx.#closedGroups; + } + + static pushGroup(ctx, minX, maxX, minY, maxY, data) { + if (#closedGroups in ctx) { + const { width, height } = ctx.canvas; + const group = { + minX: minX / width, + maxX: maxX / width, + minY: minY / height, + maxY: maxY / height, + data, + }; + ctx.#closedGroups.push(group); + return group; + } + return null; + } + + #startGroup(data) { + this.#groupsStack.push({ + minX: Infinity, + maxX: 0, + minY: Infinity, + maxY: 0, + data, + }); + return this.#currentGroup; + } + + #endGroup() { + const group = this.#groupsStack.pop(); + 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, + data: group.data, + }); + + return group; + } + + get #currentGroup() { + return this.#groupsStack.at(-1); + } + + get currentGroup() { + return this.#currentGroup; + } + + #unknown() { + this.#currentGroup.minX = 0; + this.#currentGroup.maxX = Infinity; + this.#currentGroup.minY = 0; + this.#currentGroup.maxY = Infinity; + } + + #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); + } + + 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.#ctx.fillText(text, x, y, maxWidth); + } + + fillRect(x, y, width, height) { + this.#registerBox(x, x + width, y, y + height); + 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); + } + + // moveTo(x, y) { + // this.#registerPoint(x, y); + // this.#ctx.moveTo(x, y); + // } + + 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 passThrough = [ + "save", + "restore", + + // transforms + "transform", + "translate", + "rotate", + "scale", + ]; + for (const name of passThrough) { + CanvasRecorder.prototype[name] = function (...args) { + this.#ctx[name](...args); + }; + } + + const originalDescriptors = Object.getOwnPropertyDescriptors( + CanvasRenderingContext2D.prototype + ); + for (const name of Object.keys(originalDescriptors)) { + if (Object.hasOwn(CanvasRecorder.prototype, name)) { + continue; + } + if (typeof name !== "string") { + 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; + } + if (/^(?:get|set|is)[A-Z]/.test(name)) { + // These functions just query or set some state, but perform no drawing + CanvasRecorder.prototype[name] = function (...args) { + return this.#ctx[name](...args); + }; + } else { + CanvasRecorder.prototype[name] = function (...args) { + // console.warn(`Untracked call to ${name}`); + this.#unknown(); + return this.#ctx[name](...args); + }; + } + } + } + } +}