Skip to content

Commit d4ead47

Browse files
committed
Fix some issues with lineWidth < 1 after transform (bug 1753075)
- it aims to fix https://bugzilla.mozilla.org/show_bug.cgi?id=1753075 and issue #13211; - previously we were trying to adjust lineWidth to have something correct after the current transform is applied but this approach was not correct because finally the pixel is rescaled with the same factors in both directions. And sometimes those factors must be different (see bug 1753075). - So the idea of this patch is to apply a scale matrix to the current transform just before setting lineWidth and stroking. This scale matrix is computed in order to ensure that after transform, a pixel will have its two thickness greater than 1.
1 parent 8281e64 commit d4ead47

File tree

5 files changed

+149
-92
lines changed

5 files changed

+149
-92
lines changed

src/display/canvas.js

+133-92
Original file line numberDiff line numberDiff line change
@@ -1110,7 +1110,7 @@ class CanvasGraphics {
11101110
// the transformation must already be set in canvasCtx._transformMatrix.
11111111
addContextCurrentTransform(canvasCtx);
11121112
}
1113-
this._cachedGetSinglePixelWidth = null;
1113+
this._cachedScaleForStroking = null;
11141114
}
11151115

11161116
beginDrawing({
@@ -1159,10 +1159,6 @@ class CanvasGraphics {
11591159
this.viewportScale = viewport.scale;
11601160

11611161
this.baseTransform = this.ctx.mozCurrentTransform.slice();
1162-
this._combinedScaleFactor = Math.hypot(
1163-
this.baseTransform[0],
1164-
this.baseTransform[2]
1165-
);
11661162

11671163
if (this.imageLayer) {
11681164
this.imageLayer.beginLayout();
@@ -1419,6 +1415,9 @@ class CanvasGraphics {
14191415

14201416
// Graphics state
14211417
setLineWidth(width) {
1418+
if (width !== this.current.lineWidth) {
1419+
this._cachedScaleForStroking = null;
1420+
}
14221421
this.current.lineWidth = width;
14231422
this.ctx.lineWidth = width;
14241423
}
@@ -1608,14 +1607,14 @@ class CanvasGraphics {
16081607
// Ensure that the clipping path is reset (fixes issue6413.pdf).
16091608
this.pendingClip = null;
16101609

1611-
this._cachedGetSinglePixelWidth = null;
1610+
this._cachedScaleForStroking = null;
16121611
}
16131612
}
16141613

16151614
transform(a, b, c, d, e, f) {
16161615
this.ctx.transform(a, b, c, d, e, f);
16171616

1618-
this._cachedGetSinglePixelWidth = null;
1617+
this._cachedScaleForStroking = null;
16191618
}
16201619

16211620
// Path
@@ -1751,33 +1750,17 @@ class CanvasGraphics {
17511750
ctx.globalAlpha = this.current.strokeAlpha;
17521751
if (this.contentVisible) {
17531752
if (typeof strokeColor === "object" && strokeColor?.getPattern) {
1754-
const lineWidth = this.getSinglePixelWidth();
17551753
ctx.save();
17561754
ctx.strokeStyle = strokeColor.getPattern(
17571755
ctx,
17581756
this,
17591757
ctx.mozCurrentTransformInverse,
17601758
PathType.STROKE
17611759
);
1762-
// Prevent drawing too thin lines by enforcing a minimum line width.
1763-
ctx.lineWidth = Math.max(lineWidth, this.current.lineWidth);
1764-
ctx.stroke();
1760+
this.rescaleAndStrokeNoSave();
17651761
ctx.restore();
17661762
} else {
1767-
const lineWidth = this.getSinglePixelWidth();
1768-
if (lineWidth < 0 && -lineWidth >= this.current.lineWidth) {
1769-
// The current transform will transform a square pixel into a
1770-
// parallelogram where both heights are lower than 1 and not equal.
1771-
ctx.save();
1772-
ctx.resetTransform();
1773-
ctx.lineWidth = Math.floor(this._combinedScaleFactor);
1774-
ctx.stroke();
1775-
ctx.restore();
1776-
} else {
1777-
// Prevent drawing too thin lines by enforcing a minimum line width.
1778-
ctx.lineWidth = Math.max(lineWidth, this.current.lineWidth);
1779-
ctx.stroke();
1780-
}
1763+
this.rescaleAndStroke();
17811764
}
17821765
}
17831766
if (consumePath) {
@@ -2002,7 +1985,7 @@ class CanvasGraphics {
20021985
this.moveText(0, this.current.leading);
20031986
}
20041987

2005-
paintChar(character, x, y, patternTransform, resetLineWidthToOne) {
1988+
paintChar(character, x, y, patternTransform) {
20061989
const ctx = this.ctx;
20071990
const current = this.current;
20081991
const font = current.font;
@@ -2038,11 +2021,8 @@ class CanvasGraphics {
20382021
fillStrokeMode === TextRenderingMode.STROKE ||
20392022
fillStrokeMode === TextRenderingMode.FILL_STROKE
20402023
) {
2041-
if (resetLineWidthToOne) {
2042-
ctx.resetTransform();
2043-
ctx.lineWidth = Math.floor(this._combinedScaleFactor);
2044-
}
2045-
ctx.stroke();
2024+
this._cachedScaleForStroking = null;
2025+
this.rescaleAndStrokeNoSave();
20462026
}
20472027
ctx.restore();
20482028
} else {
@@ -2056,16 +2036,8 @@ class CanvasGraphics {
20562036
fillStrokeMode === TextRenderingMode.STROKE ||
20572037
fillStrokeMode === TextRenderingMode.FILL_STROKE
20582038
) {
2059-
if (resetLineWidthToOne) {
2060-
ctx.save();
2061-
ctx.moveTo(x, y);
2062-
ctx.resetTransform();
2063-
ctx.lineWidth = Math.floor(this._combinedScaleFactor);
2064-
ctx.strokeText(character, 0, 0);
2065-
ctx.restore();
2066-
} else {
2067-
ctx.strokeText(character, x, y);
2068-
}
2039+
this._cachedScaleForStroking = null;
2040+
this.rescaleAndStrokeText(character, x, y);
20692041
}
20702042
}
20712043

@@ -2156,18 +2128,15 @@ class CanvasGraphics {
21562128
}
21572129

21582130
let lineWidth = current.lineWidth;
2159-
let resetLineWidthToOne = false;
21602131
const scale = current.textMatrixScale;
2161-
if (scale === 0 || lineWidth === 0) {
2132+
if (scale === 0) {
21622133
const fillStrokeMode =
21632134
current.textRenderingMode & TextRenderingMode.FILL_STROKE_MASK;
21642135
if (
21652136
fillStrokeMode === TextRenderingMode.STROKE ||
21662137
fillStrokeMode === TextRenderingMode.FILL_STROKE
21672138
) {
2168-
this._cachedGetSinglePixelWidth = null;
2169-
lineWidth = this.getSinglePixelWidth();
2170-
resetLineWidthToOne = lineWidth < 0;
2139+
lineWidth = 0;
21712140
}
21722141
} else {
21732142
lineWidth /= scale;
@@ -2178,6 +2147,8 @@ class CanvasGraphics {
21782147
lineWidth /= fontSizeScale;
21792148
}
21802149

2150+
const savedCurrentLineWidth = current.lineWidth;
2151+
current.lineWidth = lineWidth;
21812152
ctx.lineWidth = lineWidth;
21822153

21832154
let x = 0,
@@ -2235,13 +2206,7 @@ class CanvasGraphics {
22352206
// common case
22362207
ctx.fillText(character, scaledX, scaledY);
22372208
} else {
2238-
this.paintChar(
2239-
character,
2240-
scaledX,
2241-
scaledY,
2242-
patternTransform,
2243-
resetLineWidthToOne
2244-
);
2209+
this.paintChar(character, scaledX, scaledY, patternTransform);
22452210
if (accent) {
22462211
const scaledAccentX =
22472212
scaledX + (fontSize * accent.offset.x) / fontSizeScale;
@@ -2251,8 +2216,7 @@ class CanvasGraphics {
22512216
accent.fontChar,
22522217
scaledAccentX,
22532218
scaledAccentY,
2254-
patternTransform,
2255-
resetLineWidthToOne
2219+
patternTransform
22562220
);
22572221
}
22582222
}
@@ -2277,6 +2241,9 @@ class CanvasGraphics {
22772241
}
22782242
ctx.restore();
22792243
this.compose();
2244+
2245+
current.lineWidth = savedCurrentLineWidth;
2246+
22802247
return undefined;
22812248
}
22822249

@@ -2300,7 +2267,7 @@ class CanvasGraphics {
23002267
if (isTextInvisible || fontSize === 0) {
23012268
return;
23022269
}
2303-
this._cachedGetSinglePixelWidth = null;
2270+
this._cachedScaleForStroking = null;
23042271

23052272
ctx.save();
23062273
ctx.transform.apply(ctx, current.textMatrix);
@@ -3103,47 +3070,121 @@ class CanvasGraphics {
31033070
ctx.beginPath();
31043071
}
31053072

3106-
getSinglePixelWidth() {
3107-
if (this._cachedGetSinglePixelWidth === null) {
3108-
// If transform is [a b] then a pixel (square) is transformed
3109-
// [c d]
3110-
// into a parallelogram: its area is the abs value of the determinant.
3111-
// This parallelogram has 2 heights:
3112-
// - Area / |col_1|;
3113-
// - Area / |col_2|.
3114-
// so in order to get a height of at least 1, pixel height
3115-
// must be computed as followed:
3116-
// h = max(sqrt(a² + c²) / |det(M)|, sqrt(b² + d²) / |det(M)|).
3117-
// This is equivalent to:
3118-
// h = max(|line_1_inv(M)|, |line_2_inv(M)|)
3073+
getScaleForStroking() {
3074+
// A pixel has thicknessX = thicknessY = 1;
3075+
// A transformed pixel is a parallelogram and the thicknesses
3076+
// corresponds to the heights.
3077+
// The goal of this function is to rescale before setting the
3078+
// lineWidth in order to have both thicknesses greater or equal
3079+
// to 1 after transform.
3080+
if (!this._cachedScaleForStroking) {
3081+
const { lineWidth } = this.current;
31193082
const m = this.ctx.mozCurrentTransform;
3120-
3121-
const absDet = Math.abs(m[0] * m[3] - m[2] * m[1]);
3122-
const sqNorm1 = m[0] ** 2 + m[2] ** 2;
3123-
const sqNorm2 = m[1] ** 2 + m[3] ** 2;
3124-
const pixelHeight = Math.sqrt(Math.max(sqNorm1, sqNorm2)) / absDet;
3125-
if (sqNorm1 !== sqNorm2 && this._combinedScaleFactor * pixelHeight > 1) {
3126-
// The parallelogram isn't a square and at least one height
3127-
// is lower than 1 so the resulting line width must be 1
3128-
// but it cannot be achieved with one scale: when scaling a pixel
3129-
// we'll get a rectangle (see issue #12295).
3130-
// For example with matrix [0.001 0, 0, 100], a pixel is transformed
3131-
// in a rectangle 0.001x100. If we just scale by 1000 (to have a 1)
3132-
// then we'll get a rectangle 1x1e5 which is wrong.
3133-
// In this case, we must reset the transform, set linewidth to 1
3134-
// and then stroke.
3135-
this._cachedGetSinglePixelWidth = -(
3136-
this._combinedScaleFactor * pixelHeight
3137-
);
3138-
} else if (absDet > Number.EPSILON) {
3139-
this._cachedGetSinglePixelWidth = pixelHeight;
3083+
let scaleX, scaleY;
3084+
3085+
// We must take into account that outputScale is a part of the
3086+
// current transform.
3087+
if (m[1] === 0 && m[2] === 0) {
3088+
// Fast path
3089+
const normX = Math.abs(m[0]);
3090+
const normY = Math.abs(m[3]);
3091+
const scaledXLineWidth = normX * lineWidth;
3092+
const scaledYLineWidth = normY * lineWidth;
3093+
scaleX =
3094+
scaledXLineWidth < this.outputScaleX
3095+
? this.outputScaleX / scaledXLineWidth
3096+
: 1;
3097+
scaleY =
3098+
scaledYLineWidth < this.outputScaleY
3099+
? this.outputScaleY / scaledYLineWidth
3100+
: 1;
31403101
} else {
3141-
// Matrix is non-invertible.
3142-
this._cachedGetSinglePixelWidth = 1;
3102+
// A pixel (base (x, y)) is transformed by M into a parallelogram:
3103+
// - its area is |det(M)|;
3104+
// - heightY (orthogonal to Mx) has a length: |det(M)| / norm(Mx);
3105+
// - heightX (orthogonal to My) has a length: |det(M)| / norm(My).
3106+
// heightX and heightY are the thicknesses of the transformed pixel
3107+
// and they must be both greater or equal to 1.
3108+
const absDet = Math.abs(m[0] * m[3] - m[2] * m[1]);
3109+
const normX = Math.hypot(m[0], m[1]);
3110+
const normY = Math.hypot(m[2], m[3]);
3111+
const baseArea = lineWidth * absDet;
3112+
const scaledNormY = normY * this.outputScaleX;
3113+
const scaledNormX = normX * this.outputScaleY;
3114+
scaleX = scaledNormY > baseArea ? scaledNormY / baseArea : 1;
3115+
scaleY = scaledNormX > baseArea ? scaledNormX / baseArea : 1;
31433116
}
3117+
this._cachedScaleForStroking = [scaleX, scaleY];
3118+
}
3119+
3120+
return this._cachedScaleForStroking;
3121+
}
3122+
3123+
// Rescale before stroking in order to have a final lineWidth
3124+
// with both thicknesses greater or equal to 1.
3125+
rescaleAndStroke() {
3126+
const { ctx } = this;
3127+
const { lineWidth } = this.current;
3128+
const [scaleX, scaleY] = this.getScaleForStroking();
3129+
if (scaleX === 1 && scaleY === 1) {
3130+
ctx.lineWidth = lineWidth;
3131+
ctx.stroke();
3132+
return;
31443133
}
31453134

3146-
return this._cachedGetSinglePixelWidth;
3135+
const savedMatrix = ctx.mozCurrentTransform.slice();
3136+
3137+
if (lineWidth === 0) {
3138+
ctx.resetTransform();
3139+
ctx.lineWidth = 1;
3140+
} else {
3141+
ctx.scale(scaleX, scaleY);
3142+
ctx.lineWidth = lineWidth;
3143+
}
3144+
ctx.stroke();
3145+
ctx.setTransform(...savedMatrix);
3146+
}
3147+
3148+
// Same as above, but it's expected to be called between a ctx.save()
3149+
// and a ctx.restore() (so no need save/restore the currentTransform).
3150+
rescaleAndStrokeNoSave() {
3151+
const { ctx } = this;
3152+
let { lineWidth } = this.current;
3153+
if (lineWidth === 0) {
3154+
ctx.resetTransform();
3155+
lineWidth = 1;
3156+
} else {
3157+
const [scaleX, scaleY] = this.getScaleForStroking();
3158+
if (scaleX !== 1 || scaleY !== 1) {
3159+
ctx.scale(scaleX, scaleY);
3160+
}
3161+
}
3162+
3163+
ctx.lineWidth = lineWidth;
3164+
ctx.stroke();
3165+
}
3166+
3167+
rescaleAndStrokeText(text, x, y) {
3168+
const { ctx } = this;
3169+
const { lineWidth } = this.current;
3170+
const savedMatrix = ctx.mozCurrentTransform.slice();
3171+
3172+
if (lineWidth === 0) {
3173+
ctx.resetTransform();
3174+
ctx.lineWidth = 1;
3175+
ctx.setTransform(...savedMatrix);
3176+
ctx.translate(x, y);
3177+
} else {
3178+
ctx.translate(x, y);
3179+
const [scaleX, scaleY] = this.getScaleForStroking();
3180+
if (scaleX !== 1 || scaleY !== 1) {
3181+
ctx.scale(scaleX, scaleY);
3182+
}
3183+
3184+
ctx.lineWidth = lineWidth;
3185+
}
3186+
ctx.strokeText(text, 0, 0);
3187+
ctx.setTransform(...savedMatrix);
31473188
}
31483189

31493190
getCanvasPosition(x, y) {

test/pdfs/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -510,3 +510,4 @@
510510
!issue14307.pdf
511511
!issue14497.pdf
512512
!issue14502.pdf
513+
!issue13211.pdf

test/pdfs/bug1753075.pdf.link

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
https://bugzilla.mozilla.org/attachment.cgi?id=9262522

test/pdfs/issue13211.pdf

105 KB
Binary file not shown.

test/test_manifest.json

+14
Original file line numberDiff line numberDiff line change
@@ -6256,5 +6256,19 @@
62566256
"value": "PDF.js PDF.js PDF.js"
62576257
}
62586258
}
6259+
},
6260+
{ "id": "bug1753075",
6261+
"file": "pdfs/bug1753075.pdf",
6262+
"md5": "12716fa2dc3e0b3a61d88fef10abc7cf",
6263+
"rounds": 1,
6264+
"link": true,
6265+
"lastPage": 1,
6266+
"type": "eq"
6267+
},
6268+
{ "id": "issue13211",
6269+
"file": "pdfs/issue13211.pdf",
6270+
"md5": "d193853e8a123dc50eeea593a4150b60",
6271+
"rounds": 1,
6272+
"type": "eq"
62596273
}
62606274
]

0 commit comments

Comments
 (0)