Fix transformations when painting image masks and tiling patterns.

Previously, when we filled image masks we didn't copy over the current transformation,
this caused patterns to be misaligned when painted. Now we create a temporary
canvas with the mask and have the transform copied over and offset it relative to
where the mask would be painted. We also weren't properly offsetting tiling patterns.
This isn't usually noticeable since patters repeat, but in the case of #13561 the pattern
is only drawn once and has to be in the correct position to line up with the mask image.

These fixes broke #11473, but highlighted that we were drawing that correctly by
accident and not correctly handling negative bounding boxes on tiling patterns.

Fixes #6297,  #13561, #13441

Partially fixes #1344 (still blurry but boxes are in correct position now)
This commit is contained in:
Brendan Dahl 2021-06-30 15:09:07 -07:00
parent 9de0916fd4
commit a52c0c6988
5 changed files with 253 additions and 179 deletions

View File

@ -13,11 +13,6 @@
* limitations under the License.
*/
import {
createMatrix,
getShadingPattern,
TilingPattern,
} from "./pattern_helper.js";
import {
FONT_IDENTITY_MATRIX,
IDENTITY_MATRIX,
@ -32,6 +27,7 @@ import {
Util,
warn,
} from "../shared/util.js";
import { getShadingPattern, TilingPattern } from "./pattern_helper.js";
// <canvas> contexts store most of the state we need natively.
// However, PDF needs a bit more state, which we store here.
@ -197,17 +193,6 @@ function addContextCurrentTransform(ctx) {
};
}
function getAdjustmentTransformation(transform, width, height) {
// The pattern will be created at the size of the current page or form object,
// but the mask is usually scaled differently and offset, so we must account
// for these to shift and rescale the pattern to the correctly location.
let patternTransform = createMatrix(transform);
patternTransform = patternTransform.scale(1 / width, -1 / height);
patternTransform = patternTransform.translate(0, -height);
patternTransform = patternTransform.inverse();
return patternTransform;
}
class CachedCanvases {
constructor(canvasFactory) {
this.canvasFactory = canvasFactory;
@ -1046,6 +1031,154 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
}
}
_scaleImage(img, inverseTransform) {
// Vertical or horizontal scaling shall not be more than 2 to not lose the
// pixels during drawImage operation, painting on the temporary canvas(es)
// that are twice smaller in size.
const width = img.width;
const height = img.height;
let widthScale = Math.max(
Math.hypot(inverseTransform[0], inverseTransform[1]),
1
);
let heightScale = Math.max(
Math.hypot(inverseTransform[2], inverseTransform[3]),
1
);
let paintWidth = width,
paintHeight = height;
let tmpCanvasId = "prescale1";
let tmpCanvas, tmpCtx;
while (
(widthScale > 2 && paintWidth > 1) ||
(heightScale > 2 && paintHeight > 1)
) {
let newWidth = paintWidth,
newHeight = paintHeight;
if (widthScale > 2 && paintWidth > 1) {
newWidth = Math.ceil(paintWidth / 2);
widthScale /= paintWidth / newWidth;
}
if (heightScale > 2 && paintHeight > 1) {
newHeight = Math.ceil(paintHeight / 2);
heightScale /= paintHeight / newHeight;
}
tmpCanvas = this.cachedCanvases.getCanvas(
tmpCanvasId,
newWidth,
newHeight
);
tmpCtx = tmpCanvas.context;
tmpCtx.clearRect(0, 0, newWidth, newHeight);
tmpCtx.drawImage(
img,
0,
0,
paintWidth,
paintHeight,
0,
0,
newWidth,
newHeight
);
img = tmpCanvas.canvas;
paintWidth = newWidth;
paintHeight = newHeight;
tmpCanvasId = tmpCanvasId === "prescale1" ? "prescale2" : "prescale1";
}
return {
img,
paintWidth,
paintHeight,
};
}
_createMaskCanvas(img) {
const ctx = this.ctx;
const width = img.width,
height = img.height;
const fillColor = this.current.fillColor;
const isPatternFill = this.current.patternFill;
const maskCanvas = this.cachedCanvases.getCanvas(
"maskCanvas",
width,
height
);
const maskCtx = maskCanvas.context;
putBinaryImageMask(maskCtx, img);
// Create the mask canvas at the size it will be drawn at and also set
// its transform to match the current transform so if there are any
// patterns applied they will be applied relative to the correct
// transform.
const objToCanvas = ctx.mozCurrentTransform;
let maskToCanvas = Util.transform(objToCanvas, [
1 / width,
0,
0,
-1 / height,
0,
0,
]);
maskToCanvas = Util.transform(maskToCanvas, [1, 0, 0, 1, 0, -height]);
const cord1 = Util.applyTransform([0, 0], maskToCanvas);
const cord2 = Util.applyTransform([width, height], maskToCanvas);
const rect = Util.normalizeRect([cord1[0], cord1[1], cord2[0], cord2[1]]);
const drawnWidth = Math.ceil(rect[2] - rect[0]);
const drawnHeight = Math.ceil(rect[3] - rect[1]);
const fillCanvas = this.cachedCanvases.getCanvas(
"fillCanvas",
drawnWidth,
drawnHeight,
true
);
const fillCtx = fillCanvas.context;
// The offset will be the top-left cordinate mask.
const offsetX = Math.min(cord1[0], cord2[0]);
const offsetY = Math.min(cord1[1], cord2[1]);
fillCtx.translate(-offsetX, -offsetY);
fillCtx.transform.apply(fillCtx, maskToCanvas);
// Pre-scale if needed to improve image smoothing.
const scaled = this._scaleImage(
maskCanvas.canvas,
fillCtx.mozCurrentTransformInverse
);
fillCtx.drawImage(
scaled.img,
0,
0,
scaled.img.width,
scaled.img.height,
0,
0,
width,
height
);
fillCtx.globalCompositeOperation = "source-in";
const inverse = Util.transform(fillCtx.mozCurrentTransformInverse, [
1,
0,
0,
1,
-offsetX,
-offsetY,
]);
fillCtx.fillStyle = isPatternFill
? fillColor.getPattern(ctx, this, inverse, false)
: fillColor;
fillCtx.fillRect(0, 0, width, height);
// Round the offsets to avoid drawing fractional pixels.
return {
canvas: fillCanvas.canvas,
offsetX: Math.round(offsetX),
offsetY: Math.round(offsetY),
};
}
// Graphics state
setLineWidth(width) {
this.current.lineWidth = width;
@ -1375,7 +1508,11 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
if (typeof strokeColor === "object" && strokeColor?.getPattern) {
const lineWidth = this.getSinglePixelWidth();
ctx.save();
ctx.strokeStyle = strokeColor.getPattern(ctx, this);
ctx.strokeStyle = strokeColor.getPattern(
ctx,
this,
ctx.mozCurrentTransformInverse
);
// Prevent drawing too thin lines by enforcing a minimum line width.
ctx.lineWidth = Math.max(lineWidth, this.current.lineWidth);
ctx.stroke();
@ -1418,7 +1555,11 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
if (isPatternFill) {
ctx.save();
ctx.fillStyle = fillColor.getPattern(ctx, this);
ctx.fillStyle = fillColor.getPattern(
ctx,
this,
ctx.mozCurrentTransformInverse
);
needRestore = true;
}
@ -1748,7 +1889,11 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
// TODO: Patterns are not applied correctly to text if a non-embedded
// font is used. E.g. issue 8111 and ShowText-ShadingPattern.pdf.
ctx.save();
const pattern = current.fillColor.getPattern(ctx, this);
const pattern = current.fillColor.getPattern(
ctx,
this,
ctx.mozCurrentTransformInverse
);
patternTransform = ctx.mozCurrentTransform;
ctx.restore();
ctx.fillStyle = pattern;
@ -2022,7 +2167,12 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
this.save();
const pattern = getShadingPattern(patternIR);
ctx.fillStyle = pattern.getPattern(ctx, this, true);
ctx.fillStyle = pattern.getPattern(
ctx,
this,
ctx.mozCurrentTransformInverse,
true
);
const inv = ctx.mozCurrentTransformInverse;
if (inv) {
@ -2279,8 +2429,6 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
const ctx = this.ctx;
const width = img.width,
height = img.height;
const fillColor = this.current.fillColor;
const isPatternFill = this.current.patternFill;
const glyph = this.processingType3;
@ -2296,35 +2444,15 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
glyph.compiled(ctx);
return;
}
const mask = this._createMaskCanvas(img);
const maskCanvas = mask.canvas;
const maskCanvas = this.cachedCanvases.getCanvas(
"maskCanvas",
width,
height
);
const maskCtx = maskCanvas.context;
maskCtx.save();
putBinaryImageMask(maskCtx, img);
maskCtx.globalCompositeOperation = "source-in";
let patternTransform = null;
if (isPatternFill) {
patternTransform = getAdjustmentTransformation(
ctx.mozCurrentTransform,
width,
height
);
}
maskCtx.fillStyle = isPatternFill
? fillColor.getPattern(maskCtx, this, false, patternTransform)
: fillColor;
maskCtx.fillRect(0, 0, width, height);
maskCtx.restore();
this.paintInlineImageXObject(maskCanvas.canvas);
ctx.save();
// The mask is drawn with the transform applied. Reset the current
// transform to draw to the identity.
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.drawImage(maskCanvas, mask.offsetX, mask.offsetY);
ctx.restore();
}
paintImageMaskXObjectRepeat(
@ -2338,54 +2466,27 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
if (!this.contentVisible) {
return;
}
const width = imgData.width;
const height = imgData.height;
const fillColor = this.current.fillColor;
const isPatternFill = this.current.patternFill;
const maskCanvas = this.cachedCanvases.getCanvas(
"maskCanvas",
width,
height
);
const maskCtx = maskCanvas.context;
maskCtx.save();
putBinaryImageMask(maskCtx, imgData);
maskCtx.globalCompositeOperation = "source-in";
const ctx = this.ctx;
let patternTransform = null;
if (isPatternFill) {
patternTransform = getAdjustmentTransformation(
ctx.mozCurrentTransform,
width,
height
);
}
maskCtx.fillStyle = isPatternFill
? fillColor.getPattern(maskCtx, this, false, patternTransform)
: fillColor;
maskCtx.fillRect(0, 0, width, height);
maskCtx.restore();
ctx.save();
const currentTransform = ctx.mozCurrentTransform;
ctx.transform(scaleX, skewX, skewY, scaleY, 0, 0);
const mask = this._createMaskCanvas(imgData);
ctx.setTransform(1, 0, 0, 1, 0, 0);
for (let i = 0, ii = positions.length; i < ii; i += 2) {
ctx.save();
ctx.transform(
const trans = Util.transform(currentTransform, [
scaleX,
skewX,
skewY,
scaleY,
positions[i],
positions[i + 1]
);
ctx.scale(1, -1);
ctx.drawImage(maskCanvas.canvas, 0, 0, width, height, 0, -1, 1, 1);
ctx.restore();
positions[i + 1],
]);
const [x, y] = Util.applyTransform([0, 0], trans);
ctx.drawImage(mask.canvas, x, y);
}
ctx.restore();
}
paintImageMaskXObjectGroup(images) {
@ -2413,17 +2514,13 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
maskCtx.globalCompositeOperation = "source-in";
let patternTransform = null;
if (isPatternFill) {
patternTransform = getAdjustmentTransformation(
ctx.mozCurrentTransform,
width,
height
);
}
maskCtx.fillStyle = isPatternFill
? fillColor.getPattern(maskCtx, this, false, patternTransform)
? fillColor.getPattern(
maskCtx,
this,
ctx.mozCurrentTransformInverse,
false
)
: fillColor;
maskCtx.fillRect(0, 0, width, height);
@ -2491,17 +2588,7 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
// scale the image to the unit square
ctx.scale(1 / width, -1 / height);
const currentTransform = ctx.mozCurrentTransformInverse;
let widthScale = Math.max(
Math.hypot(currentTransform[0], currentTransform[1]),
1
);
let heightScale = Math.max(
Math.hypot(currentTransform[2], currentTransform[3]),
1
);
let imgToPaint, tmpCanvas, tmpCtx;
let imgToPaint;
// typeof check is needed due to node.js support, see issue #8489
if (
(typeof HTMLElement === "function" && imgData instanceof HTMLElement) ||
@ -2509,61 +2596,26 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
) {
imgToPaint = imgData;
} else {
tmpCanvas = this.cachedCanvases.getCanvas("inlineImage", width, height);
tmpCtx = tmpCanvas.context;
const tmpCanvas = this.cachedCanvases.getCanvas(
"inlineImage",
width,
height
);
const tmpCtx = tmpCanvas.context;
putBinaryImageData(tmpCtx, imgData, this.current.transferMaps);
imgToPaint = tmpCanvas.canvas;
}
let paintWidth = width,
paintHeight = height;
let tmpCanvasId = "prescale1";
// Vertical or horizontal scaling shall not be more than 2 to not lose the
// pixels during drawImage operation, painting on the temporary canvas(es)
// that are twice smaller in size.
while (
(widthScale > 2 && paintWidth > 1) ||
(heightScale > 2 && paintHeight > 1)
) {
let newWidth = paintWidth,
newHeight = paintHeight;
if (widthScale > 2 && paintWidth > 1) {
newWidth = Math.ceil(paintWidth / 2);
widthScale /= paintWidth / newWidth;
}
if (heightScale > 2 && paintHeight > 1) {
newHeight = Math.ceil(paintHeight / 2);
heightScale /= paintHeight / newHeight;
}
tmpCanvas = this.cachedCanvases.getCanvas(
tmpCanvasId,
newWidth,
newHeight
);
tmpCtx = tmpCanvas.context;
tmpCtx.clearRect(0, 0, newWidth, newHeight);
tmpCtx.drawImage(
imgToPaint,
0,
0,
paintWidth,
paintHeight,
0,
0,
newWidth,
newHeight
);
imgToPaint = tmpCanvas.canvas;
paintWidth = newWidth;
paintHeight = newHeight;
tmpCanvasId = tmpCanvasId === "prescale1" ? "prescale2" : "prescale1";
}
ctx.drawImage(
const scaled = this._scaleImage(
imgToPaint,
ctx.mozCurrentTransformInverse
);
ctx.drawImage(
scaled.img,
0,
0,
paintWidth,
paintHeight,
scaled.paintWidth,
scaled.paintHeight,
0,
-height,
width,
@ -2576,8 +2628,8 @@ const CanvasGraphics = (function CanvasGraphicsClosure() {
imgData,
left: position[0],
top: position[1],
width: width / currentTransform[0],
height: height / currentTransform[3],
width: width / ctx.mozCurrentTransformInverse[0],
height: height / ctx.mozCurrentTransformInverse[3],
});
}
this.restore();

View File

@ -72,7 +72,7 @@ class RadialAxialShadingPattern extends BaseShadingPattern {
this._matrix = IR[8];
}
getPattern(ctx, owner, shadingFill = false, patternTransform = null) {
getPattern(ctx, owner, inverse, shadingFill = false) {
const tmpCanvas = owner.cachedCanvases.getCanvas(
"pattern",
owner.ctx.canvas.width,
@ -121,11 +121,7 @@ class RadialAxialShadingPattern extends BaseShadingPattern {
tmpCtx.fill();
const pattern = ctx.createPattern(tmpCanvas.canvas, "repeat");
if (patternTransform) {
pattern.setTransform(patternTransform);
} else {
pattern.setTransform(createMatrix(ctx.mozCurrentTransformInverse));
}
pattern.setTransform(createMatrix(inverse));
return pattern;
}
}
@ -380,7 +376,7 @@ class MeshShadingPattern extends BaseShadingPattern {
};
}
getPattern(ctx, owner, shadingFill = false, patternTransform = null) {
getPattern(ctx, owner, inverse, shadingFill = false) {
applyBoundingBox(ctx, this._bbox);
let scale;
if (shadingFill) {
@ -535,9 +531,25 @@ class TilingPattern {
this.setFillAndStrokeStyleToContext(graphics, paintType, color);
let adjustedX0 = x0;
let adjustedY0 = y0;
let adjustedX1 = x1;
let adjustedY1 = y1;
// Some bounding boxes have negative x0/y0 cordinates which will cause the
// some of the drawing to be off of the canvas. To avoid this shift the
// bounding box over.
if (x0 < 0) {
adjustedX0 = 0;
adjustedX1 += Math.abs(x0);
}
if (y0 < 0) {
adjustedY0 = 0;
adjustedY1 += Math.abs(y0);
}
tmpCtx.translate(-(dimx.scale * adjustedX0), -(dimy.scale * adjustedY0));
graphics.transform(dimx.scale, 0, 0, dimy.scale, 0, 0);
this.clipBbox(graphics, bbox, x0, y0, x1, y1);
this.clipBbox(graphics, adjustedX0, adjustedY0, adjustedX1, adjustedY1);
graphics.baseTransform = graphics.ctx.mozCurrentTransform.slice();
@ -549,6 +561,8 @@ class TilingPattern {
canvas: tmpCanvas.canvas,
scaleX: dimx.scale,
scaleY: dimy.scale,
offsetX: adjustedX0,
offsetY: adjustedY0,
};
}
@ -569,14 +583,12 @@ class TilingPattern {
return { scale, size };
}
clipBbox(graphics, bbox, x0, y0, x1, y1) {
if (Array.isArray(bbox) && bbox.length === 4) {
const bboxWidth = x1 - x0;
const bboxHeight = y1 - y0;
graphics.ctx.rect(x0, y0, bboxWidth, bboxHeight);
graphics.clip();
graphics.endPath();
}
clipBbox(graphics, x0, y0, x1, y1) {
const bboxWidth = x1 - x0;
const bboxHeight = y1 - y0;
graphics.ctx.rect(x0, y0, bboxWidth, bboxHeight);
graphics.clip();
graphics.endPath();
}
setFillAndStrokeStyleToContext(graphics, paintType, color) {
@ -603,10 +615,9 @@ class TilingPattern {
}
}
getPattern(ctx, owner, shadingFill = false, patternTransform = null) {
ctx = this.ctx;
getPattern(ctx, owner, inverse, shadingFill = false) {
// PDF spec 8.7.2 NOTE 1: pattern's matrix is relative to initial matrix.
let matrix = ctx.mozCurrentTransformInverse;
let matrix = inverse;
if (!shadingFill) {
matrix = Util.transform(matrix, owner.baseTransform);
if (this.matrix) {
@ -619,6 +630,10 @@ class TilingPattern {
let domMatrix = createMatrix(matrix);
// Rescale and so that the ctx.createPattern call generates a pattern with
// the desired size.
domMatrix = domMatrix.translate(
temporaryPatternCanvas.offsetX,
temporaryPatternCanvas.offsetY
);
domMatrix = domMatrix.scale(
1 / temporaryPatternCanvas.scaleX,
1 / temporaryPatternCanvas.scaleY

View File

@ -206,6 +206,7 @@
!issue11403_reduced.pdf
!issue2074.pdf
!scan-bad.pdf
!issue13561_reduced.pdf
!bug847420.pdf
!bug860632.pdf
!bug894572.pdf

Binary file not shown.

View File

@ -878,6 +878,12 @@
"lastPage": 1,
"type": "eq"
},
{ "id": "issue13561_reduced",
"file": "pdfs/issue13561_reduced.pdf",
"md5": "e68c315d6349530180dd90f93027147e",
"rounds": 1,
"type": "eq"
},
{ "id": "issue5202",
"file": "pdfs/issue5202.pdf",
"md5": "bb9cc69211112e66aab40828086a4e5a",