From 5f3bcb0b5cd6b108c0a8ea223ead1c26b1775b0d Mon Sep 17 00:00:00 2001 From: Manitej Date: Thu, 28 May 2026 10:16:45 +0530 Subject: [PATCH] fix(layout): parse inherited rgba/rgb SVG stroke and fill colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inherited stroke/fill properties from parent `` elements with functional color syntax (`rgba()`, `rgb()`) were not being parsed to hex before reaching the PDF renderer. This happened because `inheritProps` ran after `resolveChildren(container)` in the compose chain, so children were parsed before receiving the parent's raw color strings — leaving untransformed `rgba(...)` values that PDFKit cannot render. Swap the order so `inheritProps` propagates raw color strings to children first, then `resolveChildren` parses them through `transformColorSafe` during child resolution. Hex and named colors (already pass-through) are unaffected. --- packages/layout/src/steps/resolveSvg.ts | 2 +- .../layout/tests/steps/resolveSvg.test.ts | 150 ++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) diff --git a/packages/layout/src/steps/resolveSvg.ts b/packages/layout/src/steps/resolveSvg.ts index 32a159baf..847c2047a 100644 --- a/packages/layout/src/steps/resolveSvg.ts +++ b/packages/layout/src/steps/resolveSvg.ts @@ -391,8 +391,8 @@ const resolveSvgRoot = (node: SafeSvgNode, fontStore: FontStore) => { parseText(fontStore), parseSvgProps, pickStyleProps, - inheritProps, resolveChildren(container), + inheritProps, resolveXLinks, )(node); }; diff --git a/packages/layout/tests/steps/resolveSvg.test.ts b/packages/layout/tests/steps/resolveSvg.test.ts index 61e37a5f4..af6160a68 100644 --- a/packages/layout/tests/steps/resolveSvg.test.ts +++ b/packages/layout/tests/steps/resolveSvg.test.ts @@ -249,4 +249,154 @@ describe('layout resolveSvg', () => { expect(svgNode.style.height).toBe(100); }); }); + + describe('SVG prop inheritance', () => { + const createMockPageNode = (children: SafeNode[]): SafePageNode => + ({ + type: P.Page, + props: {}, + style: {}, + children, + }) as SafePageNode; + + test('should parse inherited rgba() stroke to hex', () => { + const svgNode: SafeSvgNode = { + type: P.Svg, + props: { stroke: 'rgba(115, 123, 117, 1)', width: 24, height: 24 }, + style: {}, + children: [ + { + type: P.Path, + props: { d: 'M4 21H20' }, + style: {}, + }, + ], + }; + + const pageNode = createMockPageNode([svgNode]); + const result = resolveSvg(pageNode, null as any) as SafePageNode; + const svg = result.children?.[0] as SafeSvgNode; + const path = svg.children?.[0] as SafeNode; + + expect(path?.props.stroke).toBe('#737B75'); + }); + + test('should parse inherited rgb() stroke to hex', () => { + const svgNode: SafeSvgNode = { + type: P.Svg, + props: { stroke: 'rgb(255, 0, 0)', width: 24, height: 24 }, + style: {}, + children: [ + { + type: P.Path, + props: { d: 'M4 21H20' }, + style: {}, + }, + ], + }; + + const pageNode = createMockPageNode([svgNode]); + const result = resolveSvg(pageNode, null as any) as SafePageNode; + const svg = result.children?.[0] as unknown as SafeSvgNode; + const path = svg.children?.[0] as SafeNode; + + expect(path?.props.stroke).toBe('#FF0000'); + }); + + test('should parse inherited rgba() fill to hex', () => { + const svgNode: SafeSvgNode = { + type: P.Svg, + props: { fill: 'rgba(115, 123, 117, 0.5)', width: 24, height: 24 }, + style: {}, + children: [ + { + type: P.Path, + props: { d: 'M4 21H20' }, + style: {}, + }, + ], + }; + + const pageNode = createMockPageNode([svgNode]); + const result = resolveSvg(pageNode, null as any) as SafePageNode; + const svg = result.children?.[0] as unknown as SafeSvgNode; + const path = svg.children?.[0] as SafeNode; + + expect(path?.props.fill).toBe('#737B75'); + }); + + test('should keep inherited hex stroke as-is', () => { + const svgNode: SafeSvgNode = { + type: P.Svg, + props: { stroke: '#737B75', width: 24, height: 24 }, + style: {}, + children: [ + { + type: P.Path, + props: { d: 'M4 21H20' }, + style: {}, + }, + ], + }; + + const pageNode = createMockPageNode([svgNode]); + const result = resolveSvg(pageNode, null as any) as SafePageNode; + const svg = result.children?.[0] as unknown as SafeSvgNode; + const path = svg.children?.[0] as SafeNode; + + expect(path?.props.stroke).toBe('#737B75'); + }); + + test('child explicit stroke should override inherited stroke', () => { + const svgNode: SafeSvgNode = { + type: P.Svg, + props: { stroke: 'rgba(115, 123, 117, 1)', width: 24, height: 24 }, + style: {}, + children: [ + { + type: P.Path, + props: { d: 'M4 21H20', stroke: '#FF0000' }, + style: {}, + }, + ], + }; + + const pageNode = createMockPageNode([svgNode]); + const result = resolveSvg(pageNode, null as any) as SafePageNode; + const svg = result.children?.[0] as unknown as SafeSvgNode; + const path = svg.children?.[0] as SafeNode; + + expect(path?.props.stroke).toBe('#FF0000'); + }); + + test('should parse inherited rgba() props through nested G element', () => { + const svgNode: SafeSvgNode = { + type: P.Svg, + props: { stroke: 'rgba(115, 123, 117, 1)', width: 24, height: 24 }, + style: {}, + children: [ + { + type: P.G, + props: {}, + style: {}, + children: [ + { + type: P.Path, + props: { d: 'M4 21H20' }, + style: {}, + }, + ], + }, + ], + }; + + const pageNode = createMockPageNode([svgNode]); + const result = resolveSvg(pageNode, null as any) as SafePageNode; + const svg = result.children?.[0] as unknown as SafeSvgNode; + const g = svg.children?.[0] as SafeNode; + const path = g.children?.[0] as SafeNode; + + expect(path?.props.stroke).toBe('#737B75'); + }); + }); });