Repository navigation
[Bug]: Isolated tiling pattern is stretched when the tile is larger than the canvas (wrong colours in the detail view at high zoom) #22029
Description
Attach (recommended) or Link to PDF file
Web browser and its version
Firefox Developer Edition 157.0b5 and Chrome 153.0.8010.53 (also reproduces in Node.js 24 with @napi-rs/canvas, so it is not browser-specific)
Operating system and its version
macOS 27.0
PDF.js version
6.4.224 (online demo; the code is unchanged on master); also 6.3.289 from npm
Is the bug present in the latest PDF.js version?
Yes
Is a browser extension
No
Steps to reproduce the problem
- Open the attached PDF in the online demo viewer.
- At 100% the page is four equal stripes: red, green, blue, black.
- Set the zoom to 1000% (or 600%) and scroll to the right-hand quarter of the page (the black stripe), vertically centred.
- Wait for the detail view to render.
What is the expected behavior?
The black stripe stays black at every zoom level.
What went wrong?
Once the detail view has rendered, the region shows another stripe's colour. Sampled at the centre of the viewer after it settled — identical in Firefox 157 and Chrome 153:
| zoom | page x | expected | rendered |
|---|---|---|---|
| 1000% | 525pt | black | green |
| 1000% | 225pt | green | red |
| 1000% | 75pt | red | red |
| 600% | 525pt | black | blue |
It is the same in a plain page.render of part of a page (Node.js, pdfjs-dist/legacy/build/pdf.mjs + @napi-rs/canvas):
const doc = await getDocument({ data }).promise; // the attached PDF
const page = await doc.getPage(1);
const scale = 6, left = 450, top = 150; // the black stripe, x 450–600
const viewport = page.getViewport({ scale, offsetX: -left * scale, offsetY: -top * scale });
const { canvas, context } = doc.canvasFactory.create(150 * scale, 300 * scale); // 900×1800
await page.render({ canvasContext: context, viewport, canvas }).promise;
context.getImageData(450, 900, 1, 1).data; // expected 0,0,0 — got 0,0,255 (blue)The same render of the whole page at the same scale is correct.
Link to a viewer
No response
Additional context
The attached PDF is hand-written (819 bytes): one 600×600pt page filled with a tiling pattern whose single 600×600pt tile paints four vertical 150pt stripes — red, green, blue, black — under /BM /Multiply, so the pattern needs isolation.
Cause. The fill falls inside one tile, so canSkipPatternCanvas picks TilingPattern.drawPattern. The pattern needs isolation (needsIsolation, here from the blend mode; the same applies with globalAlpha, a non-default composite operation or SMask mode), so it takes the branch that renders the whole tile into a scratch canvas and draws it back over the tile's bbox (src/display/pattern_helper.js on master, drawPattern):
const dimx = this.getSizeAndScale(bboxWidth, this.ctx.canvas.width, combinedScaleX);
const dimy = this.getSizeAndScale(bboxHeight, this.ctx.canvas.height, combinedScaleY);
const tmpCanvas = this._renderTileCanvas(owner, opIdx, dimx, dimy);
owner.ctx.drawImage(tmpCanvas.canvas, x0, y0, bboxWidth, bboxHeight);getSizeAndScale caps the size at max(MAX_PATTERN_SIZE, destination canvas size) but, when it caps, it keeps the uncapped scale. Here the tile is 600pt × 6 = 3600px and the canvas is 900×1800, so the scratch canvas is 3000px wide. The tile is rendered at full scale into it and cut off at 3000px (the black stripe starts at 2700px and is mostly lost), and then drawImage stretches those 3000px over the whole 600pt bbox. Every stripe comes out wider and shifted, and the region shows whatever stripe got stretched over it.
A whole-page render does not hit this as long as the tile is no larger than the page: the cap is at least the canvas, so the whole tile fits in it. It needs a canvas that shows only part of a tile that is larger than both the canvas and 3000px — which is exactly what the viewer's detail view renders at high zoom, and what any partial or tiled render does.
We hit it with real-world exports: every filled shape of a design tool's export is a tiling pattern whose content carries a luminosity soft mask, and zoomed in, the shapes were drawn visibly larger and off-centre.
History. The stretched drawImage above first appears in 5.7.284 (#21061, "reduce intermediate canvases"). 5.6.205 renders this file wrong as well, but differently: the same region comes out red — presumably the same cap, reached in createPatternCanvas. So this is not a clean regression of #21061. It looks like a long-standing problem with capping a tile canvas without rescaling, which #21061 changed the symptom of.
Proposed fix. drawPattern already knows which part of the tile the fill can reach: owner.current.tilingPatternDims, the path's bbox clipped to the current clip, in pattern space — the same value canSkipPatternCanvas just used to choose this path. Rendering only that part of the tile keeps the scratch canvas no larger than the destination canvas plus a 2px margin, so the cap is out of reach (bar those margin pixels when the canvas is over 3000px and is therefore the cap itself — a few pixels in thousands, and one more reason to look at the cap, see below); the rest of the tile is outside the canvas and could not be seen anyway. Where the dims are missing or the intersection is empty, the whole tile is rendered as today.
--- a/src/display/pattern_helper.js
+++ b/src/display/pattern_helper.js
@@ -635,8 +635,8 @@
}
// Renders the tile operators onto a fresh canvas and returns it.
- _renderTileCanvas(owner, opIdx, dimx, dimy) {
- const [x0, y0, x1, y1] = this.bbox;
+ _renderTileCanvas(owner, opIdx, dimx, dimy, bbox = this.bbox) {
+ const [x0, y0, x1, y1] = bbox;
const tmpCanvas = owner.canvasFactory.create(dimx.size, dimy.size);
const tmpCtx = tmpCanvas.context;
const graphics = this.canvasGraphicsFactory.createCanvasGraphics(
@@ -705,9 +705,19 @@
owner.ctx.globalCompositeOperation !== "source-over" ||
owner.inSMaskMode
) {
- const bboxWidth = x1 - x0;
- const bboxHeight = y1 - y0;
const [combinedScaleX, combinedScaleY] = this._getCombinedScales();
+ // Render only the part of the tile the fill can reach: the whole tile
+ // can be larger than getSizeAndScale's cap, and a capped tile canvas
+ // would be stretched over the full bbox below.
+ const tile = this._getReachableTileBbox(
+ owner.current.tilingPatternDims,
+ n * this.xstep,
+ m * this.ystep,
+ combinedScaleX,
+ combinedScaleY
+ );
+ const bboxWidth = tile[2] - tile[0];
+ const bboxHeight = tile[3] - tile[1];
const dimx = this.getSizeAndScale(
bboxWidth,
this.ctx.canvas.width,
@@ -719,8 +729,14 @@
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);
+ const tmpCanvas = this._renderTileCanvas(owner, opIdx, dimx, dimy, tile);
+ owner.ctx.drawImage(
+ tmpCanvas.canvas,
+ tile[0],
+ tile[1],
+ bboxWidth,
+ bboxHeight
+ );
owner.canvasFactory.destroy(tmpCanvas);
} else {
// No blend modes or transparency: render the tile directly onto owner.
@@ -876,6 +892,30 @@
};
}
+ /**
+ * The part of the tile's bbox that a fill can reach, in tile space.
+ * `dims` is `[width, height, offsetX, offsetY]` of the (clipped) fill in
+ * pattern space, as computed for `canSkipPatternCanvas`; tile (n, m) is
+ * shifted by (shiftX, shiftY). Two device pixels of margin are kept so the
+ * edge of the resampled canvas never lands on painted pixels.
+ */
+ _getReachableTileBbox(dims, shiftX, shiftY, scaleX, scaleY) {
+ const [x0, y0, x1, y1] = this.bbox;
+ if (!dims) {
+ return [x0, y0, x1, y1];
+ }
+ const [width, height, offsetX, offsetY] = dims;
+ const padX = 2 / scaleX,
+ padY = 2 / scaleY;
+ const left = Math.max(x0, offsetX - shiftX - padX);
+ const top = Math.max(y0, offsetY - shiftY - padY);
+ const right = Math.min(x1, offsetX - shiftX + width + padX);
+ const bottom = Math.min(y1, offsetY - shiftY + height + padY);
+ return left < right && top < bottom
+ ? [left, top, right, bottom]
+ : [x0, y0, x1, y1];
+ }
+
getSizeAndScale(step, realOutputSize, scale) {
// MAX_PATTERN_SIZE is used to avoid OOM situation.
// Use the destination canvas's size if it is bigger than the hard-codedWe patch the equivalent change into the 6.3.289 build our app ships, and it renders the attached file correctly (the region above comes out 0,0,0) as well as the real-world files. I'm happy to turn this into a PR with a reference test if that is the direction you want. The getSizeAndScale cap itself (keeping an uncapped scale for a capped size) is probably worth a look too; this change only makes sure the isolated branch no longer asks it for a canvas larger than it will give.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity