Skip to content

Commit f89f75c

Browse files
authored
feat: add xLinkHref, gradientTransform and gradientUnits support (#3112)
1 parent 80f72b9 commit f89f75c

File tree

16 files changed

+7717
-43
lines changed

16 files changed

+7717
-43
lines changed

.changeset/rude-eyes-share.md

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@react-pdf/stylesheet": minor
3+
"@react-pdf/renderer": minor
4+
"@react-pdf/layout": minor
5+
"@react-pdf/render": minor
6+
"@react-pdf/types": minor
7+
---
8+
9+
feat: add xLinkHref, gradientTransform and gradientUnits support

packages/examples/vite/src/examples/svg/Car.tsx

+7,186
Large diffs are not rendered by default.

packages/examples/vite/src/examples/svg/index.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Svg4 from './Svg4';
88
import Star from './Star';
99
import Heart from './Heart';
1010
import Pattern from './Pattern';
11+
import Car from './Car';
1112

1213
const styles = StyleSheet.create({
1314
page: {
@@ -28,6 +29,7 @@ const App = () => {
2829
<Svg4 />
2930
<Heart />
3031
<Pattern />
32+
<Car />
3133
</Page>
3234
</Document>
3335
);

packages/layout/src/steps/resolveSvg.ts

+46
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ const STYLE_PROPS = [
4343
'strokeLinejoin',
4444
'strokeLinecap',
4545
'strokeDasharray',
46+
'gradientUnits',
47+
'gradientTransform',
4648
];
4749

4850
const VERTICAL_PROPS = ['y', 'y1', 'y2', 'height', 'cy', 'ry'];
@@ -106,6 +108,7 @@ const parseProps =
106108
stopOpacity: parsePercent,
107109
stopColor: transformColor,
108110
transform: parseTransform(container),
111+
gradientTransform: parseTransform(container),
109112
},
110113
props,
111114
);
@@ -203,6 +206,48 @@ const resolveChildren =
203206
return Object.assign({}, node, { children });
204207
};
205208

209+
const buildXLinksIndex = (node: SafeSvgNode) => {
210+
const idIndex: Record<string, SafeNode> = {};
211+
const listToExplore: SafeNode[] = node.children?.slice(0) || [];
212+
213+
while (listToExplore.length > 0) {
214+
const child = listToExplore.shift();
215+
216+
if (child.props && 'id' in child.props) {
217+
idIndex[child.props.id] = child;
218+
}
219+
220+
if (child.children) listToExplore.push(...child.children);
221+
}
222+
223+
return idIndex;
224+
};
225+
226+
const replaceXLinks = (node: SafeNode, idIndex: Record<string, SafeNode>) => {
227+
if (node.props && 'xlinkHref' in node.props) {
228+
const linkedNode = idIndex[node.props.xlinkHref.replace(/^#/, '')];
229+
230+
// No node to extend from
231+
if (!linkedNode) return node;
232+
233+
const newProps = Object.assign({}, linkedNode.props, node.props);
234+
235+
delete newProps.xlinkHref;
236+
237+
return Object.assign({}, linkedNode, { props: newProps });
238+
}
239+
240+
const children = node.children?.map((child) => replaceXLinks(child, idIndex));
241+
242+
return Object.assign({}, node, { children });
243+
};
244+
245+
export const resolveXLinks = (node: SafeSvgNode): SafeSvgNode => {
246+
const idIndex = buildXLinksIndex(node);
247+
248+
return replaceXLinks(node, idIndex);
249+
};
250+
206251
const resolveSvgRoot = (node: SafeSvgNode, fontStore: FontStore) => {
207252
const container = getContainer(node);
208253

@@ -213,6 +258,7 @@ const resolveSvgRoot = (node: SafeSvgNode, fontStore: FontStore) => {
213258
pickStyleProps,
214259
inheritProps,
215260
resolveChildren(container),
261+
resolveXLinks,
216262
)(node);
217263
};
218264

packages/layout/src/types/base.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export type TextAnchor = 'start' | 'middle' | 'end';
9292

9393
export type StrokeLinecap = 'butt' | 'round' | 'square';
9494

95-
export type StrokeLinejoin = 'butt' | 'round' | 'square';
95+
export type StrokeLinejoin = 'butt' | 'round' | 'square' | 'miter' | 'bevel';
9696

9797
export type Visibility = 'visible' | 'hidden' | 'collapse';
9898

packages/layout/src/types/defs.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { RadialGradientNode, SafeRadialGradientNode } from './radial-gradient';
55

66
export type DefsNode = {
77
type: typeof P.Defs;
8-
props: never;
9-
style: never;
8+
props?: never;
9+
style?: never;
1010
box?: never;
1111
origin?: never;
1212
yogaNode?: never;

packages/layout/src/types/linear-gradient.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import * as P from '@react-pdf/primitives';
2+
import { Transform } from '@react-pdf/stylesheet';
23

34
import { SafeStopNode, StopNode } from './stop';
4-
55
interface LinearGradientProps {
66
id: string;
77
x1?: string | number;
88
x2?: string | number;
99
y1?: string | number;
1010
y2?: string | number;
11+
xlinkHref?: string;
12+
gradientTransform?: string;
13+
gradientUnits?: 'userSpaceOnUse' | 'objectBoundingBox';
1114
}
1215

1316
interface SafeLinearGradientProps {
@@ -16,6 +19,9 @@ interface SafeLinearGradientProps {
1619
x2?: number;
1720
y1?: number;
1821
y2?: number;
22+
xlinkHref?: string;
23+
gradientTransform?: Transform[];
24+
gradientUnits?: 'userSpaceOnUse' | 'objectBoundingBox';
1925
}
2026

2127
export type LinearGradientNode = {

packages/layout/src/types/radial-gradient.ts

+7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as P from '@react-pdf/primitives';
2+
import { Transform } from '@react-pdf/stylesheet';
23

34
import { SafeStopNode, StopNode } from './stop';
45

@@ -10,6 +11,9 @@ interface RadialGradientProps {
1011
fx?: string | number;
1112
fy?: string | number;
1213
r?: string | number;
14+
xlinkHref?: string;
15+
gradientTransform?: string;
16+
gradientUnits?: 'userSpaceOnUse' | 'objectBoundingBox';
1317
}
1418

1519
interface SafeRadialGradientProps {
@@ -20,6 +24,9 @@ interface SafeRadialGradientProps {
2024
fx?: number;
2125
fy?: number;
2226
r?: number;
27+
xlinkHref?: string;
28+
gradientTransform?: Transform[];
29+
gradientUnits?: 'userSpaceOnUse' | 'objectBoundingBox';
2330
}
2431

2532
export type RadialGradientNode = {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, test } from 'vitest';
2+
3+
import { resolveXLinks } from '../../src/steps/resolveSvg';
4+
import { SafeSvgNode } from '../../src/types';
5+
6+
describe('layout resolveSvg', () => {
7+
describe('resolve xlinks', () => {
8+
test('should replace xlinkHref with the correct node', () => {
9+
const node: SafeSvgNode = {
10+
type: 'SVG',
11+
props: {},
12+
style: {},
13+
children: [
14+
{
15+
type: 'DEFS',
16+
children: [
17+
{
18+
type: 'LINEAR_GRADIENT',
19+
props: { id: 'lg1' },
20+
children: [
21+
{
22+
type: 'STOP',
23+
props: { offset: 0, stopColor: 'red' },
24+
},
25+
{
26+
type: 'STOP',
27+
props: { offset: 100, stopColor: 'blue' },
28+
},
29+
],
30+
},
31+
{
32+
type: 'LINEAR_GRADIENT',
33+
props: { id: 'lg2', x1: 10, xlinkHref: '#lg1' },
34+
},
35+
],
36+
},
37+
],
38+
};
39+
40+
const result = resolveXLinks(node);
41+
42+
expect(result.children![0].type).toBe('DEFS');
43+
expect(result.children![0].children).toHaveLength(2);
44+
expect(result.children![0].children![1].props).toEqual({
45+
id: 'lg2',
46+
x1: 10,
47+
});
48+
expect(result.children![0].children![1].children).toHaveLength(2);
49+
});
50+
51+
test('should not replace xlinkHref if node does not exist', () => {
52+
const node: SafeSvgNode = {
53+
type: 'SVG',
54+
props: {},
55+
style: {},
56+
children: [
57+
{
58+
type: 'DEFS',
59+
children: [
60+
{
61+
type: 'LINEAR_GRADIENT',
62+
props: { id: 'lg2', x1: 10, xlinkHref: '#lg1' },
63+
},
64+
],
65+
},
66+
],
67+
};
68+
69+
const result = resolveXLinks(node);
70+
71+
expect(result.children![0].type).toBe('DEFS');
72+
expect(result.children![0].children).toHaveLength(1);
73+
expect(result.children![0].children![0].children).toBeFalsy();
74+
});
75+
});
76+
});

0 commit comments

Comments
 (0)