Skip to content

Commit 85ff7b1

Browse files
authored
Merge pull request mozilla#14536 from calixteman/thin_line
Fix some issues with lineWidth < 1 after transform (bug 1753075, bug 1743245, bug 1710019)
2 parents 2cc98dc + 46369e4 commit 85ff7b1

File tree

5 files changed

+130
-83
lines changed

5 files changed

+130
-83
lines changed

src/display/canvas.js

Lines changed: 114 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,7 @@ class CanvasGraphics {
11161116
// the transformation must already be set in canvasCtx._transformMatrix.
11171117
addContextCurrentTransform(canvasCtx);
11181118
}
1119+
this._cachedScaleForStroking = null;
11191120
this._cachedGetSinglePixelWidth = null;
11201121
}
11211122

@@ -1165,10 +1166,6 @@ class CanvasGraphics {
11651166
this.viewportScale = viewport.scale;
11661167

11671168
this.baseTransform = this.ctx.mozCurrentTransform.slice();
1168-
this._combinedScaleFactor = Math.hypot(
1169-
this.baseTransform[0],
1170-
this.baseTransform[2]
1171-
);
11721169

11731170
if (this.imageLayer) {
11741171
this.imageLayer.beginLayout();
@@ -1425,6 +1422,9 @@ class CanvasGraphics {
14251422

14261423
// Graphics state
14271424
setLineWidth(width) {
1425+
if (width !== this.current.lineWidth) {
1426+
this._cachedScaleForStroking = null;
1427+
}
14281428
this.current.lineWidth = width;
14291429
this.ctx.lineWidth = width;
14301430
}
@@ -1633,13 +1633,15 @@ class CanvasGraphics {
16331633
// Ensure that the clipping path is reset (fixes issue6413.pdf).
16341634
this.pendingClip = null;
16351635

1636+
this._cachedScaleForStroking = null;
16361637
this._cachedGetSinglePixelWidth = null;
16371638
}
16381639
}
16391640

16401641
transform(a, b, c, d, e, f) {
16411642
this.ctx.transform(a, b, c, d, e, f);
16421643

1644+
this._cachedScaleForStroking = null;
16431645
this._cachedGetSinglePixelWidth = null;
16441646
}
16451647

@@ -1776,33 +1778,17 @@ class CanvasGraphics {
17761778
ctx.globalAlpha = this.current.strokeAlpha;
17771779
if (this.contentVisible) {
17781780
if (typeof strokeColor === "object" && strokeColor?.getPattern) {
1779-
const lineWidth = this.getSinglePixelWidth();
17801781
ctx.save();
17811782
ctx.strokeStyle = strokeColor.getPattern(
17821783
ctx,
17831784
this,
17841785
ctx.mozCurrentTransformInverse,
17851786
PathType.STROKE
17861787
);
1787-
// Prevent drawing too thin lines by enforcing a minimum line width.
1788-
ctx.lineWidth = Math.max(lineWidth, this.current.lineWidth);
1789-
ctx.stroke();
1788+
this.rescaleAndStroke(/* saveRestore */ false);
17901789
ctx.restore();
17911790
} else {
1792-
const lineWidth = this.getSinglePixelWidth();
1793-
if (lineWidth < 0 && -lineWidth >= this.current.lineWidth) {
1794-
// The current transform will transform a square pixel into a
1795-
// parallelogram where both heights are lower than 1 and not equal.
1796-
ctx.save();
1797-
ctx.resetTransform();
1798-
ctx.lineWidth = Math.floor(this._combinedScaleFactor);
1799-
ctx.stroke();
1800-
ctx.restore();
1801-
} else {
1802-
// Prevent drawing too thin lines by enforcing a minimum line width.
1803-
ctx.lineWidth = Math.max(lineWidth, this.current.lineWidth);
1804-
ctx.stroke();
1805-
}
1791+
this.rescaleAndStroke(/* saveRestore */ true);
18061792
}
18071793
}
18081794
if (consumePath) {
@@ -2027,7 +2013,7 @@ class CanvasGraphics {
20272013
this.moveText(0, this.current.leading);
20282014
}
20292015

2030-
paintChar(character, x, y, patternTransform, resetLineWidthToOne) {
2016+
paintChar(character, x, y, patternTransform) {
20312017
const ctx = this.ctx;
20322018
const current = this.current;
20332019
const font = current.font;
@@ -2063,10 +2049,6 @@ class CanvasGraphics {
20632049
fillStrokeMode === TextRenderingMode.STROKE ||
20642050
fillStrokeMode === TextRenderingMode.FILL_STROKE
20652051
) {
2066-
if (resetLineWidthToOne) {
2067-
ctx.resetTransform();
2068-
ctx.lineWidth = Math.floor(this._combinedScaleFactor);
2069-
}
20702052
ctx.stroke();
20712053
}
20722054
ctx.restore();
@@ -2081,16 +2063,7 @@ class CanvasGraphics {
20812063
fillStrokeMode === TextRenderingMode.STROKE ||
20822064
fillStrokeMode === TextRenderingMode.FILL_STROKE
20832065
) {
2084-
if (resetLineWidthToOne) {
2085-
ctx.save();
2086-
ctx.moveTo(x, y);
2087-
ctx.resetTransform();
2088-
ctx.lineWidth = Math.floor(this._combinedScaleFactor);
2089-
ctx.strokeText(character, 0, 0);
2090-
ctx.restore();
2091-
} else {
2092-
ctx.strokeText(character, x, y);
2093-
}
2066+
ctx.strokeText(character, x, y);
20942067
}
20952068
}
20962069

@@ -2181,7 +2154,6 @@ class CanvasGraphics {
21812154
}
21822155

21832156
let lineWidth = current.lineWidth;
2184-
let resetLineWidthToOne = false;
21852157
const scale = current.textMatrixScale;
21862158
if (scale === 0 || lineWidth === 0) {
21872159
const fillStrokeMode =
@@ -2190,9 +2162,7 @@ class CanvasGraphics {
21902162
fillStrokeMode === TextRenderingMode.STROKE ||
21912163
fillStrokeMode === TextRenderingMode.FILL_STROKE
21922164
) {
2193-
this._cachedGetSinglePixelWidth = null;
21942165
lineWidth = this.getSinglePixelWidth();
2195-
resetLineWidthToOne = lineWidth < 0;
21962166
}
21972167
} else {
21982168
lineWidth /= scale;
@@ -2260,13 +2230,7 @@ class CanvasGraphics {
22602230
// common case
22612231
ctx.fillText(character, scaledX, scaledY);
22622232
} else {
2263-
this.paintChar(
2264-
character,
2265-
scaledX,
2266-
scaledY,
2267-
patternTransform,
2268-
resetLineWidthToOne
2269-
);
2233+
this.paintChar(character, scaledX, scaledY, patternTransform);
22702234
if (accent) {
22712235
const scaledAccentX =
22722236
scaledX + (fontSize * accent.offset.x) / fontSizeScale;
@@ -2276,8 +2240,7 @@ class CanvasGraphics {
22762240
accent.fontChar,
22772241
scaledAccentX,
22782242
scaledAccentY,
2279-
patternTransform,
2280-
resetLineWidthToOne
2243+
patternTransform
22812244
);
22822245
}
22832246
}
@@ -2302,6 +2265,7 @@ class CanvasGraphics {
23022265
}
23032266
ctx.restore();
23042267
this.compose();
2268+
23052269
return undefined;
23062270
}
23072271

@@ -2325,6 +2289,7 @@ class CanvasGraphics {
23252289
if (isTextInvisible || fontSize === 0) {
23262290
return;
23272291
}
2292+
this._cachedScaleForStroking = null;
23282293
this._cachedGetSinglePixelWidth = null;
23292294

23302295
ctx.save();
@@ -3121,46 +3086,112 @@ class CanvasGraphics {
31213086
}
31223087

31233088
getSinglePixelWidth() {
3124-
if (this._cachedGetSinglePixelWidth === null) {
3125-
// If transform is [a b] then a pixel (square) is transformed
3126-
// [c d]
3127-
// into a parallelogram: its area is the abs value of the determinant.
3128-
// This parallelogram has 2 heights:
3129-
// - Area / |col_1|;
3130-
// - Area / |col_2|.
3131-
// so in order to get a height of at least 1, pixel height
3132-
// must be computed as followed:
3133-
// h = max(sqrt(a² + c²) / |det(M)|, sqrt(b² + d²) / |det(M)|).
3134-
// This is equivalent to:
3135-
// h = max(|line_1_inv(M)|, |line_2_inv(M)|)
3089+
if (!this._cachedGetSinglePixelWidth) {
31363090
const m = this.ctx.mozCurrentTransform;
3091+
if (m[1] === 0 && m[2] === 0) {
3092+
// Fast path
3093+
this._cachedGetSinglePixelWidth =
3094+
1 / Math.min(Math.abs(m[0]), Math.abs(m[3]));
3095+
} else {
3096+
const absDet = Math.abs(m[0] * m[3] - m[2] * m[1]);
3097+
const normX = Math.hypot(m[0], m[2]);
3098+
const normY = Math.hypot(m[1], m[3]);
3099+
this._cachedGetSinglePixelWidth = Math.max(normX, normY) / absDet;
3100+
}
3101+
}
3102+
return this._cachedGetSinglePixelWidth;
3103+
}
31373104

3138-
const absDet = Math.abs(m[0] * m[3] - m[2] * m[1]);
3139-
const sqNorm1 = m[0] ** 2 + m[2] ** 2;
3140-
const sqNorm2 = m[1] ** 2 + m[3] ** 2;
3141-
const pixelHeight = Math.sqrt(Math.max(sqNorm1, sqNorm2)) / absDet;
3142-
if (sqNorm1 !== sqNorm2 && this._combinedScaleFactor * pixelHeight > 1) {
3143-
// The parallelogram isn't a square and at least one height
3144-
// is lower than 1 so the resulting line width must be 1
3145-
// but it cannot be achieved with one scale: when scaling a pixel
3146-
// we'll get a rectangle (see issue #12295).
3147-
// For example with matrix [0.001 0, 0, 100], a pixel is transformed
3148-
// in a rectangle 0.001x100. If we just scale by 1000 (to have a 1)
3149-
// then we'll get a rectangle 1x1e5 which is wrong.
3150-
// In this case, we must reset the transform, set linewidth to 1
3151-
// and then stroke.
3152-
this._cachedGetSinglePixelWidth = -(
3153-
this._combinedScaleFactor * pixelHeight
3154-
);
3155-
} else if (absDet > Number.EPSILON) {
3156-
this._cachedGetSinglePixelWidth = pixelHeight;
3105+
getScaleForStroking() {
3106+
// A pixel has thicknessX = thicknessY = 1;
3107+
// A transformed pixel is a parallelogram and the thicknesses
3108+
// corresponds to the heights.
3109+
// The goal of this function is to rescale before setting the
3110+
// lineWidth in order to have both thicknesses greater or equal
3111+
// to 1 after transform.
3112+
if (!this._cachedScaleForStroking) {
3113+
const { lineWidth } = this.current;
3114+
const m = this.ctx.mozCurrentTransform;
3115+
let scaleX, scaleY;
3116+
3117+
if (m[1] === 0 && m[2] === 0) {
3118+
// Fast path
3119+
const normX = Math.abs(m[0]);
3120+
const normY = Math.abs(m[3]);
3121+
if (lineWidth === 0) {
3122+
scaleX = 1 / normX;
3123+
scaleY = 1 / normY;
3124+
} else {
3125+
const scaledXLineWidth = normX * lineWidth;
3126+
const scaledYLineWidth = normY * lineWidth;
3127+
scaleX = scaledXLineWidth < 1 ? 1 / scaledXLineWidth : 1;
3128+
scaleY = scaledYLineWidth < 1 ? 1 / scaledYLineWidth : 1;
3129+
}
31573130
} else {
3158-
// Matrix is non-invertible.
3159-
this._cachedGetSinglePixelWidth = 1;
3131+
// A pixel (base (x, y)) is transformed by M into a parallelogram:
3132+
// - its area is |det(M)|;
3133+
// - heightY (orthogonal to Mx) has a length: |det(M)| / norm(Mx);
3134+
// - heightX (orthogonal to My) has a length: |det(M)| / norm(My).
3135+
// heightX and heightY are the thicknesses of the transformed pixel
3136+
// and they must be both greater or equal to 1.
3137+
const absDet = Math.abs(m[0] * m[3] - m[2] * m[1]);
3138+
const normX = Math.hypot(m[0], m[1]);
3139+
const normY = Math.hypot(m[2], m[3]);
3140+
if (lineWidth === 0) {
3141+
scaleX = normY / absDet;
3142+
scaleY = normX / absDet;
3143+
} else {
3144+
const baseArea = lineWidth * absDet;
3145+
scaleX = normY > baseArea ? normY / baseArea : 1;
3146+
scaleY = normX > baseArea ? normX / baseArea : 1;
3147+
}
31603148
}
3149+
this._cachedScaleForStroking = [scaleX, scaleY];
31613150
}
3151+
return this._cachedScaleForStroking;
3152+
}
31623153

3163-
return this._cachedGetSinglePixelWidth;
3154+
// Rescale before stroking in order to have a final lineWidth
3155+
// with both thicknesses greater or equal to 1.
3156+
rescaleAndStroke(saveRestore) {
3157+
const { ctx } = this;
3158+
const { lineWidth } = this.current;
3159+
const [scaleX, scaleY] = this.getScaleForStroking();
3160+
3161+
ctx.lineWidth = lineWidth || 1;
3162+
3163+
if (scaleX === 1 && scaleY === 1) {
3164+
ctx.stroke();
3165+
return;
3166+
}
3167+
3168+
let savedMatrix, savedDashes, savedDashOffset;
3169+
if (saveRestore) {
3170+
savedMatrix = ctx.mozCurrentTransform.slice();
3171+
savedDashes = ctx.getLineDash().slice();
3172+
savedDashOffset = ctx.lineDashOffset;
3173+
}
3174+
3175+
ctx.scale(scaleX, scaleY);
3176+
3177+
// How the dashed line is rendered depends on the current transform...
3178+
// so we added a rescale to handle too thin lines and consequently
3179+
// the way the line is dashed will be modified.
3180+
// If scaleX === scaleY, the dashed lines will be rendered correctly
3181+
// else we'll have some bugs (but only with too thin lines).
3182+
// Here we take the max... why not taking the min... or something else.
3183+
// Anyway, as said it's buggy when scaleX !== scaleY.
3184+
const scale = Math.max(scaleX, scaleY);
3185+
ctx.setLineDash(ctx.getLineDash().map(x => x / scale));
3186+
ctx.lineDashOffset /= scale;
3187+
3188+
ctx.stroke();
3189+
3190+
if (saveRestore) {
3191+
ctx.setTransform(...savedMatrix);
3192+
ctx.setLineDash(savedDashes);
3193+
ctx.lineDashOffset = savedDashOffset;
3194+
}
31643195
}
31653196

31663197
getCanvasPosition(x, y) {

test/pdfs/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,3 +513,4 @@
513513
!issue14307.pdf
514514
!issue14497.pdf
515515
!issue14502.pdf
516+
!issue13211.pdf

test/pdfs/bug1753075.pdf.link

Lines changed: 1 addition & 0 deletions
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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6309,5 +6309,19 @@
63096309
"rounds": 1,
63106310
"link": true,
63116311
"type": "other"
6312+
},
6313+
{ "id": "bug1753075",
6314+
"file": "pdfs/bug1753075.pdf",
6315+
"md5": "12716fa2dc3e0b3a61d88fef10abc7cf",
6316+
"rounds": 1,
6317+
"link": true,
6318+
"lastPage": 1,
6319+
"type": "eq"
6320+
},
6321+
{ "id": "issue13211",
6322+
"file": "pdfs/issue13211.pdf",
6323+
"md5": "d193853e8a123dc50eeea593a4150b60",
6324+
"rounds": 1,
6325+
"type": "eq"
63126326
}
63136327
]

0 commit comments

Comments
 (0)