Skip to content

Commit a738f01

Browse files
authored
fix(render): deformed SVG paths with chained smooth quadratic (T) commands (#3519)
1 parent 06dfada commit a738f01

3 files changed

Lines changed: 118 additions & 1 deletion

File tree

.changeset/tidy-moons-shake.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@react-pdf/render': patch
3+
---
4+
5+
Fix deformed SVG paths with chained smooth quadratic (T) commands by expanding them to explicit Q commands before handing them to pdfkit
Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,74 @@
11
import { SafePathNode } from '@react-pdf/layout';
2+
import absPath from 'abs-svg-path';
3+
import parsePath from 'parse-svg-path';
4+
25
import { Context } from '../types';
36

7+
/**
8+
* pdfkit mishandles chained smooth quadratic commands (T): after drawing it
9+
* reflects the control point a second time, so every T after the first uses a
10+
* stale control point and curves render deformed. Expand T into explicit Q
11+
* commands with the correct reflection before handing the path to pdfkit.
12+
*/
13+
type Segment = (string | number)[];
14+
15+
const expandSmoothQuadratics = (d: string): string => {
16+
const segments: Segment[] = absPath(parsePath(d));
17+
18+
let x = 0;
19+
let y = 0;
20+
let startX = 0;
21+
let startY = 0;
22+
let quadX: number | null = null;
23+
let quadY: number | null = null;
24+
25+
const out = segments.map((segment: Segment) => {
26+
let seg = segment;
27+
const command = seg[0];
28+
29+
if (command === 'T') {
30+
const cx = quadX === null ? x : 2 * x - quadX;
31+
const cy = quadY === null ? y : 2 * y - quadY;
32+
seg = ['Q', cx, cy, seg[1], seg[2]];
33+
quadX = cx;
34+
quadY = cy;
35+
} else if (command === 'Q') {
36+
quadX = seg[1] as number;
37+
quadY = seg[2] as number;
38+
} else {
39+
quadX = null;
40+
quadY = null;
41+
}
42+
43+
if (command === 'M') {
44+
startX = seg[1] as number;
45+
startY = seg[2] as number;
46+
}
47+
48+
if (command === 'H') {
49+
x = seg[1] as number;
50+
} else if (command === 'V') {
51+
y = seg[1] as number;
52+
} else if (command === 'Z') {
53+
x = startX;
54+
y = startY;
55+
} else {
56+
x = seg[seg.length - 2] as number;
57+
y = seg[seg.length - 1] as number;
58+
}
59+
60+
return seg;
61+
});
62+
63+
return out.map((seg: Segment) => seg[0] + seg.slice(1).join(' ')).join('');
64+
};
65+
466
const renderPath = (ctx: Context, node: SafePathNode) => {
567
const d = node.props?.d;
668

7-
if (d) ctx.path(node.props.d);
69+
if (!d) return;
70+
71+
ctx.path(/[Tt]/.test(d) ? expandSmoothQuadratics(d) : d);
872
};
973

1074
export default renderPath;
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { describe, expect, test } from 'vitest';
2+
3+
import * as P from '@react-pdf/primitives';
4+
5+
import createCTX from '../ctx';
6+
import renderPath from '../../src/primitives/renderPath';
7+
import { SafePathNode } from '@react-pdf/layout';
8+
9+
const render = (d: string) => {
10+
const ctx = createCTX();
11+
const node: SafePathNode = { type: P.Path, props: { d }, style: {} };
12+
13+
renderPath(ctx, node);
14+
15+
return ctx.path.mock.calls;
16+
};
17+
18+
describe('primitive renderPath', () => {
19+
test('should not render empty path', () => {
20+
const calls = render('');
21+
expect(calls).toHaveLength(0);
22+
});
23+
24+
test('should pass paths without smooth quadratics through untouched', () => {
25+
const d = 'M10 10L20 20Q30 30 40 40Z';
26+
expect(render(d)).toEqual([[d]]);
27+
});
28+
29+
test('should expand chained T commands with reflected control points', () => {
30+
expect(render('M0 0Q10 20 20 0T40 0T60 0')).toEqual([
31+
['M0 0Q10 20 20 0Q30 -20 40 0Q50 20 60 0'],
32+
]);
33+
});
34+
35+
test('should use current point as control when T does not follow Q', () => {
36+
expect(render('M0 0L10 10T30 10')).toEqual([['M0 0L10 10Q10 10 30 10']]);
37+
});
38+
39+
test('should absolutize relative t commands', () => {
40+
expect(render('M0 0Q10 20 20 0t20 0')).toEqual([
41+
['M0 0Q10 20 20 0Q30 -20 40 0'],
42+
]);
43+
});
44+
45+
test('should track current point through H, V and Z when expanding', () => {
46+
expect(render('M0 0H10V10ZT20 20')).toEqual([['M0 0H10V10ZQ0 0 20 20']]);
47+
});
48+
});

0 commit comments

Comments
 (0)