From 54be55ace33acfec51b1f958af8493ac8bd5b52f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Ribaudo?= Date: Thu, 14 Nov 2024 13:04:49 +0100 Subject: [PATCH] Add logic to track rendering area of various PDF ops This commit is a first step towards #6419, and it can also help with #13287. To support rendering _part_ of a page, we will need to first compute which ops can affect what is visible in that part of the page. This commit adds logic to track "group of ops" with their respective bounding boxes. Each group eather corresponds to a single op or to a range, and it can have dependencies earlier in the ops list that are not contiguous to the range. Consider the following example: ``` 0. setFillRGBColor 1. beginText 2. showText "Hello" 3. endText 4. constructPath [...] 5. eoFill ``` here we have two groups: the text (range 1-3) and the path (range 4-5). Each of them has a corresponding bounding box, and a dependency on the op at index 0. This tracking happens when first rendering a PDF: we wrap the canvas with a "canvas recorder" that has the same API, but with additional methods to mark the start/end of a group. --- src/display/canvas.js | 174 ++++++++++++++++++++++---- src/display/canvas_recorder.js | 222 +++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+), 24 deletions(-) create mode 100644 src/display/canvas_recorder.js 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); + }; + } + } + } + } +}