diff --git a/src/core/evaluator.js b/src/core/evaluator.js index 7126a604b..7c51351d2 100644 --- a/src/core/evaluator.js +++ b/src/core/evaluator.js @@ -29,6 +29,7 @@ import { Util, warn, } from "../shared/util.js"; +import { CheckedOperatorList, OperatorList } from "./operator_list.js"; import { CMapFactory, IdentityCMap } from "./cmap.js"; import { Cmd, Dict, EOF, isName, Name, Ref, RefSet } from "./primitives.js"; import { @@ -85,7 +86,6 @@ import { getGlyphsUnicode } from "./glyphlist.js"; import { getMetrics } from "./metrics.js"; import { getUnicodeForGlyph } from "./unicode.js"; import { MurmurHash3_64 } from "../shared/murmurhash3.js"; -import { OperatorList } from "./operator_list.js"; import { PDFImage } from "./image.js"; import { Stream } from "./stream.js"; @@ -501,7 +501,17 @@ class PartialEvaluator { if (optionalContent !== undefined) { operatorList.addOp(OPS.beginMarkedContentProps, ["OC", optionalContent]); } + const group = dict.get("Group"); + let newOpList; + + // If it's a group, a new canvas will be created that is the size of the + // bounding box and translated to the correct position so we don't need to + // apply the bounding box to it. + const f32matrix = matrix && new Float32Array(matrix); + const args = [f32matrix, (!group && f32bbox) || null]; + const localResources = dict.get("Resources"); + if (group) { groupOptions = { matrix, @@ -509,6 +519,7 @@ class PartialEvaluator { smask, isolated: false, knockout: false, + needsIsolation: false, }; const groupSubtype = group.get("S"); @@ -532,30 +543,30 @@ class PartialEvaluator { smask.backdrop = colorSpace.getRgbHex(smask.backdrop, 0); } - operatorList.addOp(OPS.beginGroup, [groupOptions]); + newOpList = new CheckedOperatorList(); + } else { + newOpList = operatorList; + operatorList.addOp(OPS.paintFormXObjectBegin, args); } - // If it's a group, a new canvas will be created that is the size of the - // bounding box and translated to the correct position so we don't need to - // apply the bounding box to it. - const f32matrix = matrix && new Float32Array(matrix); - const args = [f32matrix, (!group && f32bbox) || null]; - operatorList.addOp(OPS.paintFormXObjectBegin, args); - - const localResources = dict.get("Resources"); - await this.getOperatorList({ stream: xobj, task, resources: localResources instanceof Dict ? localResources : resources, - operatorList, + operatorList: newOpList, initialState, prevRefs: seenRefs, }); - operatorList.addOp(OPS.paintFormXObjectEnd, []); if (group) { + groupOptions.needsIsolation = newOpList.needsIsolation || !!smask; + operatorList.addOp(OPS.beginGroup, [groupOptions]); + operatorList.addOp(OPS.paintFormXObjectBegin, args); + operatorList.addOpList(newOpList); + operatorList.addOp(OPS.paintFormXObjectEnd, []); operatorList.addOp(OPS.endGroup, [groupOptions]); + } else { + operatorList.addOp(OPS.paintFormXObjectEnd, []); } if (optionalContent !== undefined) { @@ -972,7 +983,7 @@ class PartialEvaluator { localTilingPatternCache ) { // Create an IR of the pattern code. - const tilingOpList = new OperatorList(); + const tilingOpList = new CheckedOperatorList(); // Merge the available resources, to prevent issues when the patternDict // is missing some /Resources entries (fixes issue6541.pdf). const patternResources = Dict.merge({ @@ -988,10 +999,12 @@ class PartialEvaluator { }) .then(function () { const operatorListIR = tilingOpList.getIR(); + const { needsIsolation } = tilingOpList; const tilingPatternIR = getTilingPatternIR( operatorListIR, patternDict, - color + color, + needsIsolation ); // Add the dependencies to the parent operator list so they are // resolved before the sub operator list is executed synchronously. @@ -1001,6 +1014,7 @@ class PartialEvaluator { if (patternDict.objId) { localTilingPatternCache.set(/* name = */ null, patternDict.objId, { operatorListIR, + needsIsolation, dict: patternDict, }); } @@ -1578,7 +1592,8 @@ class PartialEvaluator { const tilingPatternIR = getTilingPatternIR( localTilingPattern.operatorListIR, localTilingPattern.dict, - color + color, + localTilingPattern.needsIsolation ); operatorList.addOp(fn, tilingPatternIR); return undefined; diff --git a/src/core/operator_list.js b/src/core/operator_list.js index 6c3e98c41..64091bc9a 100644 --- a/src/core/operator_list.js +++ b/src/core/operator_list.js @@ -822,4 +822,35 @@ class OperatorList { } } -export { OperatorList }; +/** + * A subclass of OperatorList that checks whether added group or pattern + * operations require being drawn in isolation (i.e. on a separate canvas). + * A group/pattern needs isolation when it uses non-default compositing + * (blend mode) or a soft mask. The result is exposed via `needsIsolation`. + */ +class CheckedOperatorList extends OperatorList { + needsIsolation = false; + + addOp(fn, args) { + if (!this.needsIsolation) { + if (fn === OPS.beginGroup) { + // Propagate isolation only if the nested group itself needs it. + this.needsIsolation = args[0].needsIsolation; + } else if (fn === OPS.setGState) { + for (const [key, val] of args[0]) { + if (key === "BM" && val !== "source-over") { + this.needsIsolation = true; + break; + } + if (key === "SMask" && val !== false) { + this.needsIsolation = true; + break; + } + } + } + } + super.addOp(fn, args); + } +} + +export { CheckedOperatorList, OperatorList }; diff --git a/src/core/pattern.js b/src/core/pattern.js index 9c9a9a4ef..161e16a1f 100644 --- a/src/core/pattern.js +++ b/src/core/pattern.js @@ -1139,7 +1139,7 @@ class DummyShading extends BaseShading { } } -function getTilingPatternIR(operatorList, dict, color) { +function getTilingPatternIR(operatorList, dict, color, needsIsolation = true) { const matrix = lookupMatrix(dict.getArray("Matrix"), IDENTITY_MATRIX); const bbox = lookupNormalRect(dict.getArray("BBox"), null); // Ensure that the pattern has a non-zero width and height, to prevent errors @@ -1174,6 +1174,7 @@ function getTilingPatternIR(operatorList, dict, color) { ystep, paintType, tilingType, + needsIsolation, ]; } diff --git a/src/display/canvas.js b/src/display/canvas.js index 2d807cd51..d39b566a0 100644 --- a/src/display/canvas.js +++ b/src/display/canvas.js @@ -2608,7 +2608,8 @@ class CanvasGraphics { 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) { + const { inSMaskMode } = this; + if (inSMaskMode) { this.endSMaskMode(); this.current.activeSMask = null; } @@ -2637,6 +2638,28 @@ class CanvasGraphics { warn("Knockout groups not supported."); } + if ( + !group.needsIsolation && + currentCtx.globalAlpha === 1 && + currentCtx.globalCompositeOperation === "source-over" && + !inSMaskMode + ) { + if (group.bbox) { + let clip = new Path2D(); + const [x0, y0, x1, y1] = group.bbox; + clip.rect(x0, y0, x1 - x0, y1 - y0); + if (group.matrix) { + const path = new Path2D(); + path.addPath(clip, new DOMMatrix(group.matrix)); + clip = path; + } + currentCtx.clip(clip); + } + this.groupStack.push(null); // null = no intermediate canvas + this.groupLevel++; + return; + } + const currentTransform = getCurrentTransform(currentCtx); if (group.matrix) { currentCtx.transform(...group.matrix); @@ -2756,6 +2779,12 @@ class CanvasGraphics { this.groupLevel--; const groupCtx = this.ctx; const ctx = this.groupStack.pop(); + if (ctx === null) { + // Simple group: content was drawn directly on the parent canvas. + this.restore(opIdx); + return; + } + this.ctx = ctx; // Turn off image smoothing to avoid sub pixel interpolation which can // look kind of blurry for some pdfs. diff --git a/src/display/pattern_helper.js b/src/display/pattern_helper.js index 9f4e5ca04..46ea8026f 100644 --- a/src/display/pattern_helper.js +++ b/src/display/pattern_helper.js @@ -614,6 +614,7 @@ class TilingPattern { this.ystep = IR[6]; this.paintType = IR[7]; this.tilingType = IR[8]; + this.needsIsolation = IR[9] ?? true; this.ctx = ctx; this.canvasGraphicsFactory = canvasGraphicsFactory; this.baseTransform = baseTransform; @@ -702,23 +703,6 @@ class TilingPattern { // 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) { @@ -730,9 +714,39 @@ class TilingPattern { // 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); + if ( + this.needsIsolation || + owner.ctx.globalAlpha !== 1 || + owner.ctx.globalCompositeOperation !== "source-over" || + owner.inSMaskMode + ) { + 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.ctx.drawImage(tmpCanvas.canvas, x0, y0, bboxWidth, bboxHeight); + owner.canvasFactory.destroy(tmpCanvas); + } else { + // No blend modes or transparency: render the tile directly onto owner. + this.setFillAndStrokeStyleToContext(owner, this.paintType, this.color); + this.clipBbox(owner, x0, y0, x1, y1); + owner.baseTransformStack.push(owner.baseTransform); + owner.baseTransform = getCurrentTransform(owner.ctx); + owner.executeOperatorList(this.operatorList); + owner.baseTransform = owner.baseTransformStack.pop(); + } - owner.canvasFactory.destroy(tmpCanvas); owner.restore(); } @@ -892,14 +906,15 @@ class TilingPattern { clipBbox(graphics, x0, y0, x1, y1) { const bboxWidth = x1 - x0; const bboxHeight = y1 - y0; - graphics.ctx.rect(x0, y0, bboxWidth, bboxHeight); + const clip = new Path2D(); + clip.rect(x0, y0, bboxWidth, bboxHeight); Util.axialAlignedBoundingBox( [x0, y0, x1, y1], getCurrentTransform(graphics.ctx), graphics.current.minMax ); - graphics.clip(); - graphics.endPath(); + graphics.ctx.clip(clip); + graphics.current.updateClipFromPath(); } setFillAndStrokeStyleToContext(graphics, paintType, color) {