Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add xLinkHref, gradientTransform and gradientUnits support #3112

Merged
merged 4 commits into from
Mar 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/rude-eyes-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@react-pdf/stylesheet": minor
"@react-pdf/renderer": minor
"@react-pdf/layout": minor
"@react-pdf/render": minor
"@react-pdf/types": minor
---

feat: add xLinkHref, gradientTransform and gradientUnits support
7,186 changes: 7,186 additions & 0 deletions packages/examples/vite/src/examples/svg/Car.tsx

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/examples/vite/src/examples/svg/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Svg4 from './Svg4';
import Star from './Star';
import Heart from './Heart';
import Pattern from './Pattern';
import Car from './Car';

const styles = StyleSheet.create({
page: {
Expand All @@ -28,6 +29,7 @@ const App = () => {
<Svg4 />
<Heart />
<Pattern />
<Car />
</Page>
</Document>
);
Expand Down
46 changes: 46 additions & 0 deletions packages/layout/src/steps/resolveSvg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ const STYLE_PROPS = [
'strokeLinejoin',
'strokeLinecap',
'strokeDasharray',
'gradientUnits',
'gradientTransform',
];

const VERTICAL_PROPS = ['y', 'y1', 'y2', 'height', 'cy', 'ry'];
Expand Down Expand Up @@ -106,6 +108,7 @@ const parseProps =
stopOpacity: parsePercent,
stopColor: transformColor,
transform: parseTransform(container),
gradientTransform: parseTransform(container),
},
props,
);
Expand Down Expand Up @@ -203,6 +206,48 @@ const resolveChildren =
return Object.assign({}, node, { children });
};

const buildXLinksIndex = (node: SafeSvgNode) => {
const idIndex: Record<string, SafeNode> = {};
const listToExplore: SafeNode[] = node.children?.slice(0) || [];

while (listToExplore.length > 0) {
const child = listToExplore.shift();

if (child.props && 'id' in child.props) {
idIndex[child.props.id] = child;
}

if (child.children) listToExplore.push(...child.children);
}

return idIndex;
};

const replaceXLinks = (node: SafeNode, idIndex: Record<string, SafeNode>) => {
if (node.props && 'xlinkHref' in node.props) {
const linkedNode = idIndex[node.props.xlinkHref.replace(/^#/, '')];

// No node to extend from
if (!linkedNode) return node;

const newProps = Object.assign({}, linkedNode.props, node.props);

delete newProps.xlinkHref;

return Object.assign({}, linkedNode, { props: newProps });
}

const children = node.children?.map((child) => replaceXLinks(child, idIndex));

return Object.assign({}, node, { children });
};

export const resolveXLinks = (node: SafeSvgNode): SafeSvgNode => {
const idIndex = buildXLinksIndex(node);

return replaceXLinks(node, idIndex);
};

const resolveSvgRoot = (node: SafeSvgNode, fontStore: FontStore) => {
const container = getContainer(node);

Expand All @@ -213,6 +258,7 @@ const resolveSvgRoot = (node: SafeSvgNode, fontStore: FontStore) => {
pickStyleProps,
inheritProps,
resolveChildren(container),
resolveXLinks,
)(node);
};

Expand Down
2 changes: 1 addition & 1 deletion packages/layout/src/types/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export type TextAnchor = 'start' | 'middle' | 'end';

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

export type StrokeLinejoin = 'butt' | 'round' | 'square';
export type StrokeLinejoin = 'butt' | 'round' | 'square' | 'miter' | 'bevel';

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

Expand Down
4 changes: 2 additions & 2 deletions packages/layout/src/types/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { RadialGradientNode, SafeRadialGradientNode } from './radial-gradient';

export type DefsNode = {
type: typeof P.Defs;
props: never;
style: never;
props?: never;
style?: never;
box?: never;
origin?: never;
yogaNode?: never;
Expand Down
8 changes: 7 additions & 1 deletion packages/layout/src/types/linear-gradient.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import * as P from '@react-pdf/primitives';
import { Transform } from '@react-pdf/stylesheet';

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

interface LinearGradientProps {
id: string;
x1?: string | number;
x2?: string | number;
y1?: string | number;
y2?: string | number;
xlinkHref?: string;
gradientTransform?: string;
gradientUnits?: 'userSpaceOnUse' | 'objectBoundingBox';
}

interface SafeLinearGradientProps {
Expand All @@ -16,6 +19,9 @@ interface SafeLinearGradientProps {
x2?: number;
y1?: number;
y2?: number;
xlinkHref?: string;
gradientTransform?: Transform[];
gradientUnits?: 'userSpaceOnUse' | 'objectBoundingBox';
}

export type LinearGradientNode = {
Expand Down
7 changes: 7 additions & 0 deletions packages/layout/src/types/radial-gradient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as P from '@react-pdf/primitives';
import { Transform } from '@react-pdf/stylesheet';

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

Expand All @@ -10,6 +11,9 @@ interface RadialGradientProps {
fx?: string | number;
fy?: string | number;
r?: string | number;
xlinkHref?: string;
gradientTransform?: string;
gradientUnits?: 'userSpaceOnUse' | 'objectBoundingBox';
}

interface SafeRadialGradientProps {
Expand All @@ -20,6 +24,9 @@ interface SafeRadialGradientProps {
fx?: number;
fy?: number;
r?: number;
xlinkHref?: string;
gradientTransform?: Transform[];
gradientUnits?: 'userSpaceOnUse' | 'objectBoundingBox';
}

export type RadialGradientNode = {
Expand Down
76 changes: 76 additions & 0 deletions packages/layout/tests/steps/resolveSvg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, test } from 'vitest';

import { resolveXLinks } from '../../src/steps/resolveSvg';
import { SafeSvgNode } from '../../src/types';

describe('layout resolveSvg', () => {
describe('resolve xlinks', () => {
test('should replace xlinkHref with the correct node', () => {
const node: SafeSvgNode = {
type: 'SVG',
props: {},
style: {},
children: [
{
type: 'DEFS',
children: [
{
type: 'LINEAR_GRADIENT',
props: { id: 'lg1' },
children: [
{
type: 'STOP',
props: { offset: 0, stopColor: 'red' },
},
{
type: 'STOP',
props: { offset: 100, stopColor: 'blue' },
},
],
},
{
type: 'LINEAR_GRADIENT',
props: { id: 'lg2', x1: 10, xlinkHref: '#lg1' },
},
],
},
],
};

const result = resolveXLinks(node);

expect(result.children![0].type).toBe('DEFS');
expect(result.children![0].children).toHaveLength(2);
expect(result.children![0].children![1].props).toEqual({
id: 'lg2',
x1: 10,
});
expect(result.children![0].children![1].children).toHaveLength(2);
});

test('should not replace xlinkHref if node does not exist', () => {
const node: SafeSvgNode = {
type: 'SVG',
props: {},
style: {},
children: [
{
type: 'DEFS',
children: [
{
type: 'LINEAR_GRADIENT',
props: { id: 'lg2', x1: 10, xlinkHref: '#lg1' },
},
],
},
],
};

const result = resolveXLinks(node);

expect(result.children![0].type).toBe('DEFS');
expect(result.children![0].children).toHaveLength(1);
expect(result.children![0].children![0].children).toBeFalsy();
});
});
});
Loading