From 4c019e771214a492384c7e4a60e8b1eaa9efd24d Mon Sep 17 00:00:00 2001 From: Calixte Denizet Date: Sat, 28 Sep 2024 18:58:56 +0200 Subject: [PATCH] Don't use an intermediate canvas when rendering a tiling pattern bigger than the rectangle to fill --- src/display/canvas.js | 50 +++++- src/display/pattern_helper.js | 215 +++++++++++++++-------- test/pdfs/.gitignore | 1 + test/pdfs/tiling_patterns_variations.pdf | 102 +++++++++++ test/test_manifest.json | 7 + 5 files changed, 296 insertions(+), 79 deletions(-) create mode 100755 test/pdfs/tiling_patterns_variations.pdf diff --git a/src/display/canvas.js b/src/display/canvas.js index c8aaf6b5c..44544baf6 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -318,6 +318,8 @@ class CanvasExtraState { strokeColor = "#000000"; + tilingPatternDims = null; + patternFill = false; patternStroke = false; @@ -343,6 +345,7 @@ class CanvasExtraState { const clone = Object.create(this); clone.clipBox = this.clipBox.slice(); clone.minMax = this.minMax.slice(); + clone.tilingPatternDims = this.tilingPatternDims?.slice(); return clone; } @@ -1494,6 +1497,9 @@ class CanvasGraphics { if (!minMax) { // The path is empty, so no need to update the current minMax. path ||= data[0] = new Path2D(); + if (op !== OPS.stroke && op !== OPS.closeStroke) { + this.current.tilingPatternDims = null; + } this[op](opIdx, path); return; } @@ -1521,6 +1527,27 @@ class CanvasGraphics { getCurrentTransform(this.ctx), this.current.minMax ); + + const tilingDims = this.current.tilingPatternDims; + if ( + tilingDims && + op !== OPS.stroke && + op !== OPS.closeStroke && + this.current.fillColor instanceof TilingPattern + ) { + // Intersect with clip box to get the actual fill area, then convert + // to pattern space. + const clippedBBox = Util.intersect( + this.current.clipBox, + this.current.minMax + ); + if (!clippedBBox) { + this.current.tilingPatternDims = null; + } else { + this.current.fillColor.updatePatternDims(clippedBBox, tilingDims); + } + } + this[op](opIdx, path); this._pathStartIdx = opIdx; @@ -1590,8 +1617,21 @@ class CanvasGraphics { const fillColor = this.current.fillColor; const isPatternFill = this.current.patternFill; let needRestore = false; + const intersect = this.current.getClippedPathBoundingBox(); if (isPatternFill) { + const dims = this.current.tilingPatternDims; + const tileIdx = dims && fillColor.canSkipPatternCanvas(dims); + if (tileIdx) { + // Draw the tile directly, skipping the pattern canvas. + fillColor.drawPattern(this, path, this.pendingEOFill, tileIdx, opIdx); + this.pendingEOFill = false; + if (consumePath) { + this.consumePath(opIdx, path, intersect); + } + this.current.tilingPatternDims = null; + return; + } const baseTransform = fillColor.isModifyingCurrentTransform() ? ctx.getTransform() : null; @@ -1615,7 +1655,6 @@ class CanvasGraphics { needRestore = true; } - const intersect = this.current.getClippedPathBoundingBox(); if (this.contentVisible && intersect !== null) { if (this.pendingEOFill) { ctx.fill(path, "evenodd"); @@ -2422,8 +2461,13 @@ class CanvasGraphics { setFillColorN(opIdx, ...args) { this.dependencyTracker?.recordSimpleData("fillColor", opIdx); - this.current.fillColor = this.getColorN_Pattern(opIdx, args); + const pattern = (this.current.fillColor = this.getColorN_Pattern( + opIdx, + args + )); this.current.patternFill = true; + this.current.tilingPatternDims = + pattern instanceof TilingPattern ? [0, 0, 0, 0] : null; } setStrokeRGBColor(opIdx, color) { @@ -2442,12 +2486,14 @@ class CanvasGraphics { this.dependencyTracker?.recordSimpleData("fillColor", opIdx); this.ctx.fillStyle = this.current.fillColor = color; this.current.patternFill = false; + this.current.tilingPatternDims = null; } setFillTransparent(opIdx) { this.dependencyTracker?.recordSimpleData("fillColor", opIdx); this.ctx.fillStyle = this.current.fillColor = "transparent"; this.current.patternFill = false; + this.current.tilingPatternDims = null; } _getPattern(opIdx, objId, matrix = null) { diff --git a/src/display/pattern_helper.js b/src/display/pattern_helper.js index a777a68a1..d56e92423 100644 --- a/src/display/pattern_helper.js +++ b/src/display/pattern_helper.js @@ -616,22 +616,134 @@ class TilingPattern { this.ctx = ctx; this.canvasGraphicsFactory = canvasGraphicsFactory; this.baseTransform = baseTransform; + // baseTransform * patternMatrix. + this.patternBaseMatrix = this.matrix + ? Util.transform(baseTransform, this.matrix) + : baseTransform; + } + + // Returns [n, m] tile index if the fill area fits within one tile, + // null otherwise. + canSkipPatternCanvas([width, height, offsetX, offsetY]) { + const [x0, y0, x1, y1] = this.bbox; + const absXStep = Math.abs(this.xstep); + const absYStep = Math.abs(this.ystep); + + // dims is in pattern space, so compare directly with xstep/ystep. + if (width > absXStep + 1e-6 || height > absYStep + 1e-6) { + return null; + } + + // Tile n covers [x0+n·xstep, x1+n·xstep]; find the range intersecting + // [offsetX, offsetX+width]. + const nXFirst = Math.floor((offsetX - x1) / absXStep) + 1; + const nXLast = Math.ceil((offsetX + width - x0) / absXStep) - 1; + const nYFirst = Math.floor((offsetY - y1) / absYStep) + 1; + const nYLast = Math.ceil((offsetY + height - y0) / absYStep) - 1; + return nXLast <= nXFirst && nYLast <= nYFirst ? [nXFirst, nYFirst] : null; + } + + // Converts clippedBBox from device space to pattern space and stores it + // as [width, height, offsetX, offsetY] in dims. + updatePatternDims(clippedBBox, dims) { + const inv = Util.inverseTransform(this.patternBaseMatrix); + const c1 = [clippedBBox[0], clippedBBox[1]]; + const c2 = [clippedBBox[2], clippedBBox[3]]; + Util.applyTransform(c1, inv); + Util.applyTransform(c2, inv); + dims[0] = Math.abs(c2[0] - c1[0]); + dims[1] = Math.abs(c2[1] - c1[1]); + dims[2] = Math.min(c1[0], c2[0]); + dims[3] = Math.min(c1[1], c2[1]); + } + + // Renders the tile operators onto a fresh canvas and returns it. + _renderTileCanvas(owner, opIdx, dimx, dimy) { + const [x0, y0, x1, y1] = this.bbox; + const tmpCanvas = owner.canvasFactory.create(dimx.size, dimy.size); + const tmpCtx = tmpCanvas.context; + const graphics = this.canvasGraphicsFactory.createCanvasGraphics( + tmpCtx, + opIdx + ); + graphics.groupLevel = owner.groupLevel; + + this.setFillAndStrokeStyleToContext(graphics, this.paintType, this.color); + + tmpCtx.translate(-dimx.scale * x0, -dimy.scale * y0); + // 0: sub-ops are indexed under the top-level opIdx from + // createCanvasGraphics. + graphics.transform(0, dimx.scale, 0, 0, dimy.scale, 0, 0); + + // Required to balance the save/restore in CanvasGraphics beginDrawing. + tmpCtx.save(); + graphics.dependencyTracker?.save(); + + this.clipBbox(graphics, x0, y0, x1, y1); + graphics.baseTransform = getCurrentTransform(graphics.ctx); + graphics.executeOperatorList(this.operatorList); + + graphics.endDrawing(); + graphics.dependencyTracker?.restore(); + tmpCtx.restore(); + + return tmpCanvas; + } + + _getCombinedScales() { + const scale = new Float32Array(2); + Util.singularValueDecompose2dScale(this.matrix, scale); + const [matrixScaleX, matrixScaleY] = scale; + Util.singularValueDecompose2dScale(this.baseTransform, scale); + return [matrixScaleX * scale[0], matrixScaleY * scale[1]]; + } + + // Draws a single tile directly onto owner, clipped to path. + drawPattern(owner, path, useEOFill = false, [n, m], opIdx) { + const [x0, y0, x1, y1] = this.bbox; + const bboxWidth = x1 - x0; + const bboxHeight = y1 - y0; + + const [combinedScaleX, combinedScaleY] = this._getCombinedScales(); + const dimx = this.getSizeAndScale( + bboxWidth, + this.ctx.canvas.width, + combinedScaleX + ); + const dimy = this.getSizeAndScale( + bboxHeight, + this.ctx.canvas.height, + combinedScaleY + ); + + // Isolate blend modes from the main canvas. + const tmpCanvas = this._renderTileCanvas(owner, opIdx, dimx, dimy); + + owner.save(); + if (useEOFill) { + owner.ctx.clip(path, "evenodd"); + } else { + owner.ctx.clip(path); + } + // Position tile (n, m) in device space; the clip above is unaffected + // by setTransform. + owner.ctx.setTransform(...this.patternBaseMatrix); + owner.ctx.translate(n * this.xstep, m * this.ystep); + owner.ctx.drawImage(tmpCanvas.canvas, x0, y0, bboxWidth, bboxHeight); + + owner.canvasFactory.destroy(tmpCanvas); + owner.restore(); } createPatternCanvas(owner, opIdx) { - const { - bbox, - operatorList, - paintType, - tilingType, - color, - canvasGraphicsFactory, - } = this; + const [x0, y0, x1, y1] = this.bbox; + const width = x1 - x0; + const height = y1 - y0; let { xstep, ystep } = this; xstep = Math.abs(xstep); ystep = Math.abs(ystep); - info("TilingType: " + tilingType); + info("TilingType: " + this.tilingType); // A tiling pattern as defined by PDF spec 8.7.2 is a cell whose size is // described by bbox, and may repeat regularly by shifting the cell by @@ -651,45 +763,32 @@ class TilingPattern { // "Figures on adjacent tiles should not overlap" (PDF spec 8.7.3.1), // but overlapping cells without common pixels are still valid. - const x0 = bbox[0], - y0 = bbox[1], - x1 = bbox[2], - y1 = bbox[3]; - const width = x1 - x0; - const height = y1 - y0; - // Obtain scale from matrix and current transformation matrix. - const scale = new Float32Array(2); - Util.singularValueDecompose2dScale(this.matrix, scale); - const [matrixScaleX, matrixScaleY] = scale; - Util.singularValueDecompose2dScale(this.baseTransform, scale); - const combinedScaleX = matrixScaleX * scale[0]; - const combinedScaleY = matrixScaleY * scale[1]; + const [combinedScaleX, combinedScaleY] = this._getCombinedScales(); + // Use width and height values that are as close as possible to the end + // result when the pattern is used. Too low value makes the pattern look + // blurry. Too large value makes it look too crispy. let canvasWidth = width, canvasHeight = height, redrawHorizontally = false, redrawVertically = false; - const xScaledStep = Math.ceil(xstep * combinedScaleX); - const yScaledStep = Math.ceil(ystep * combinedScaleY); - const xScaledWidth = Math.ceil(width * combinedScaleX); - const yScaledHeight = Math.ceil(height * combinedScaleY); - - if (xScaledStep >= xScaledWidth) { + if ( + Math.ceil(xstep * combinedScaleX) >= Math.ceil(width * combinedScaleX) + ) { canvasWidth = xstep; } else { redrawHorizontally = true; } - if (yScaledStep >= yScaledHeight) { + if ( + Math.ceil(ystep * combinedScaleY) >= Math.ceil(height * combinedScaleY) + ) { canvasHeight = ystep; } else { redrawVertically = true; } - // Use width and height values that are as close as possible to the end - // result when the pattern is used. Too low value makes the pattern look - // blurry. Too large value makes it look too crispy. const dimx = this.getSizeAndScale( canvasWidth, this.ctx.canvas.width, @@ -701,43 +800,7 @@ class TilingPattern { combinedScaleY ); - const tmpCanvas = owner.canvasFactory.create(dimx.size, dimy.size); - const tmpCtx = tmpCanvas.context; - const graphics = canvasGraphicsFactory.createCanvasGraphics(tmpCtx, opIdx); - graphics.groupLevel = owner.groupLevel; - - this.setFillAndStrokeStyleToContext(graphics, paintType, color); - - tmpCtx.translate(-dimx.scale * x0, -dimy.scale * y0); - 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); - - graphics.baseTransform = getCurrentTransform(graphics.ctx); - - graphics.executeOperatorList(operatorList); - - graphics.endDrawing(); - - graphics.dependencyTracker?.restore(); - tmpCtx.restore(); + const tmpCanvas = this._renderTileCanvas(owner, opIdx, dimx, dimy); if (redrawHorizontally || redrawVertically) { // The tile is overlapping itself, so we create a new tile with @@ -862,14 +925,12 @@ class TilingPattern { } getPattern(ctx, owner, inverse, pathType, opIdx) { - // PDF spec 8.7.2 NOTE 1: pattern's matrix is relative to initial matrix. - let matrix = inverse; - if (pathType !== PathType.SHADING) { - matrix = Util.transform(matrix, owner.baseTransform); - if (this.matrix) { - matrix = Util.transform(matrix, this.matrix); - } - } + // PDF spec 8.7.2: prepend inverse CTM to patternBaseMatrix to position + // the CSS pattern. + const matrix = + pathType !== PathType.SHADING + ? Util.transform(inverse, this.patternBaseMatrix) + : inverse; const temporaryPatternCanvas = this.createPatternCanvas(owner, opIdx); diff --git a/test/pdfs/.gitignore b/test/pdfs/.gitignore index b5a80afd9..7ec95079f 100644 --- a/test/pdfs/.gitignore +++ b/test/pdfs/.gitignore @@ -894,3 +894,4 @@ !hello_world_rotated.pdf !bug2025674.pdf !bug2026037.pdf +!tiling_patterns_variations.pdf diff --git a/test/pdfs/tiling_patterns_variations.pdf b/test/pdfs/tiling_patterns_variations.pdf new file mode 100755 index 000000000..5eced00ac --- /dev/null +++ b/test/pdfs/tiling_patterns_variations.pdf @@ -0,0 +1,102 @@ +%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page + /Parent 2 0 R + /MediaBox [0 0 600 800] + /Resources << + /Pattern << /P1 5 0 R /P2 6 0 R /P3 7 0 R /P4 8 0 R /P5 9 0 R >> + /ColorSpace << /Cs [/Pattern] /Cs2 [/Pattern /DeviceRGB] >> + >> + /Contents 4 0 R +>> +endobj +4 0 obj +<< /Length 637 >> +stream +1 g +0 0 600 800 re f +q /Cs cs /P1 scn 20 600 150 150 re f Q +q /Cs cs /P2 scn 220 600 150 150 re f Q +q /Cs cs /P3 scn 420 600 150 150 re f Q +q /Cs cs /P1 scn 250 460 30 30 re f Q +q 250 460 30 30 re W n /Cs cs /P1 scn 220 400 150 150 re f Q +q /Cs cs /P4 scn 420 400 150 150 re f Q +q /Cs cs /P4 scn 20 250 30 30 re f Q +q /Cs cs /P1 scn 1 0 0 1 250 200 cm 0.5 0 0 0.5 0 0 cm 0 0 60 60 re f Q +q /Cs2 cs 0.8 0.2 0.2 /P5 scn 420 200 150 150 re f Q +0 G 0.5 w +20 600 150 150 re S +220 600 150 150 re S +420 600 150 150 re S +20 400 150 150 re S +220 400 150 150 re S +420 400 150 150 re S +20 200 150 150 re S +220 200 150 150 re S +420 200 150 150 re S + +endstream +endobj +5 0 obj +<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 50 50] /XStep 50 /YStep 50 /Resources << >> /Length 30 >> +stream +0.2 0.4 0.8 rg +5 5 40 40 re f + +endstream +endobj +6 0 obj +<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 50 /YStep 50 /Resources << >> /Length 30 >> +stream +0.9 0.5 0.1 rg +0 0 20 20 re f + +endstream +endobj +7 0 obj +<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 70 70] /XStep 50 /YStep 50 /Resources << >> /Length 30 >> +stream +0.1 0.7 0.2 rg +0 0 70 70 re f + +endstream +endobj +8 0 obj +<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 50 50] /XStep 50 /YStep 50 /Matrix [0.866025 0.5 -0.5 0.866025 0 0] /Resources << >> /Length 30 >> +stream +0.7 0.1 0.7 rg +5 5 40 40 re f + +endstream +endobj +9 0 obj +<< /Type /Pattern /PatternType 1 /PaintType 2 /TilingType 1 /BBox [0 0 30 30] /XStep 30 /YStep 30 /Resources << >> /Length 31 >> +stream +0 0 10 30 re f +20 0 10 30 re f + +endstream +endobj +xref +0 10 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000369 00000 n +0000001058 00000 n +0000001250 00000 n +0000001442 00000 n +0000001634 00000 n +0000001867 00000 n +trailer +<< /Size 10 /Root 1 0 R >> +startxref +2060 +%%EOF diff --git a/test/test_manifest.json b/test/test_manifest.json index 6a5cc4e10..8b5919f13 100644 --- a/test/test_manifest.json +++ b/test/test_manifest.json @@ -14028,5 +14028,12 @@ "rounds": 1, "link": true, "type": "eq" + }, + { + "id": "tiling_patterns_variations", + "file": "pdfs/tiling_patterns_variations.pdf", + "md5": "2870c3136be00ddd975149b2c7d1e6df", + "rounds": 1, + "type": "eq" } ]