Logo

Building a Custom SVG Chart Generator: Architecture Decisions and Lessons Learned

7 min read
Next.jsSheetJS

Table of Contents

Intro

Our designers were spending hours creating financial charts by hand. Each chart required manually positioning bars, calculating scales, aligning labels, and ensuring visual consistency across dozens of slides. A single quarterly report could take days of designer time.

I built a chart generator that reads Excel files and outputs styled SVG charts ready for export. Designers now upload their data and get production-ready charts in seconds. The tool paid for itself within the first week.

This isn't a tutorial. It's more of a reflection on the technical decisions I made along the way.

Choosing SheetJS Despite Its Vulnerabilities

For Excel parsing, I went with SheetJS (xlsx on npm). It's the most downloaded spreadsheet library (~18M downloads/month), but it has known security issues:

1npm audit
2xlsx *
3Severity: high
4Prototype Pollution - GHSA-4r6h-8v6p-xvw6
5ReDoS - GHSA-5pgg-2g8v-p4x9
6No fix available

The npm package is essentially unmaintained. So why use it?

Context matters. The Prototype Pollution vulnerability lets a malicious spreadsheet modify JavaScript's built-in object prototypes. On a server, this could be catastrophic (affecting all requests, potentially leading to RCE). But my app runs entirely client-side. The blast radius is one browser tab. Worst case: the user refreshes.

The ReDoS vulnerability could freeze the browser on a crafted file. Again, for a server processing uploads from untrusted users, this is a denial-of-service vector. For an internal tool where I know who's uploading files, it's a minor annoyance.

I considered ExcelJS (~13M downloads/month), which is actively maintained with no known vulnerabilities. But SheetJS's API was simpler for my read-only use case, and the risk profile was acceptable.

The lesson: Security decisions aren't absolute. They're about understanding your threat model and making informed trade-offs. "Has vulnerabilities" doesn't automatically mean "don't use."

Why Storybook Was Worth the Setup

I initially saw Storybook as overhead. Another tool to configure, another thing to maintain. But with 20+ SVG components that needed to work together precisely, I couldn't develop them effectively in the main app.

The problem with developing chart components in-app:

Storybook solved all of this. Each component gets its own isolated environment where I can:

The SVG Decorator Pattern

SVG components need a parent <svg> element. Rather than adding this to every story, I used Storybook's decorator pattern:

1const meta: Meta<typeof BarSegment> = {
2 title: 'Charts/Bar/BarSegment',
3 component: BarSegment,
4 decorators: [
5 (Story) => (
6 <svg width={200} height={300}>
7 <g transform="translate(20, 20)">
8 <Story />
9 </g>
10 </svg>
11 ),
12 ],
13}

For composed stories showing multiple components together, I'd create custom render functions that set up the full SVG context with realistic dimensions and transforms.

Catching Integration Bugs Early

The real payoff came when integrating components. I had an X-axis that looked perfect in isolation but misaligned with the bars in the actual chart. The issue? I was calculating label positions as barSlotWidth * index but the bars used index * (barSlotWidth + barGap).

Because I had a story that rendered axes and bars together, I caught this immediately instead of debugging it in the full app with real data.

Component Architecture: The Patterns That Emerged

I didn't start with a grand architecture plan. The patterns emerged iteratively as I identified friction points and refactored.

Pattern 1: Components Should Own Their Visual Elements

My initial version of Chart.tsx had too many responsibilities:

1// Chart.tsx coordinating everything
2<YAxisLine x={0} chartHeight={chartHeight} />
3<XAxis chartWidth={chartWidth} chartHeight={chartHeight} />
4<YAxis ticks={leftTicks} yScale={leftScale} />
5<AxisUnit unit="$B" axis="left" zeroPosition={...} alignPosition={-12} />
6<AxisUnit unit="FY" axis="x" zeroPosition={0} alignPosition={chartHeight + 25} />
7{labels.map((label, i) => (
8 <XAxisLabel x={getPosition(i)} y={chartHeight + 20} label={label} />
9))}

Chart.tsx was coordinating the axis line, the ticks, the unit label, and the x-axis labels, all things that conceptually belong to the axes themselves. This works, but it distributes axis logic across multiple locations.

The refined approach:

1<XAxis
2 chartWidth={chartWidth}
3 chartHeight={chartHeight}
4 labels={labels}
5 barSlotWidth={barSlotWidth}
6 barGap={barGap}
7 unit="FY"
8/>
9<YAxis
10 ticks={leftTicks}
11 yScale={leftScale}
12 chartHeight={chartHeight}
13 unit="$B"
14/>

Now XAxis internally renders its line, labels, and unit. YAxis renders its line, ticks, and unit. Chart.tsx just orchestrates the high-level layout.

Why this matters: When I needed to add a right Y-axis, I just added another <YAxis isRight />. The component knew not to render the axis line for the right side and to position ticks differently. If the axis line rendering was still in Chart.tsx, I'd have to add conditional logic there too.

Pattern 2: Nest Sub-Components Thoughtfully

BarSegment has two sub-components: SegmentLabel (the percentage text) and SegmentSeparator (the line between stacked segments). These are tightly coupled to BarSegment and don't make sense anywhere else.

1bar/BarSegment/
2├── BarSegment.tsx
3├── BarSegment.stories.tsx
4├── index.ts
5├── SegmentLabel/
6│ ├── SegmentLabel.tsx
7│ └── index.ts
8└── SegmentSeparator/
9 └── ...

By nesting them inside BarSegment's folder, I'm signaling that these are implementation details, not public components. If someone wants to use bar charts, they import Bar, not SegmentLabel.

PNG Export: Two Non-Obvious Gotchas

Gotcha 1: foreignObject Taints the Canvas

My first implementation of the legend used foreignObject to embed HTML inside SVG. It rendered correctly in the browser:

1<foreignObject x={0} y={0} width={400} height={50}>
2 <div style={{ display: 'flex', gap: '16px' }}>
3 {items.map((item) => (
4 <LegendItem key={item.id} {...item} />
5 ))}
6 </div>
7</foreignObject>

But when I tried to export to PNG, nothing. The canvas was "tainted" and toDataURL() threw a security error.

Why: foreignObject embeds external content (HTML) into the SVG. Browsers treat this as a potential security risk since the HTML could reference external resources, execute scripts, etc. So they taint the canvas to prevent data exfiltration.

The fix: Rewrite the legend using native SVG elements (<text>, <rect>, <g>). More verbose, but it exports cleanly.

Gotcha 2: External Fonts Also Taint the Canvas

After fixing the foreignObject issue, exports still failed. This time the culprit was fonts loaded via CSS @font-face.

The browser considers external font files as cross-origin resources. When the SVG references them (even indirectly through computed styles), the canvas gets tainted.

The fix: Embed fonts directly in the SVG as base64 data URLs:

1const embedFontInSvg = (svgElement: SVGSVGElement, fontBase64: string) => {
2 const clonedSvg = svgElement.cloneNode(true)
3 if (!(clonedSvg instanceof SVGSVGElement)) return null
4
5 const styleElement = document.createElementNS(
6 'http://www.w3.org/2000/svg',
7 'style',
8 )
9 styleElement.textContent = `
10 @font-face {
11 font-family: 'CustomFont';
12 src: url('${fontBase64}') format('woff');
13 }
14 `
15
16 clonedSvg.insertBefore(styleElement, clonedSvg.firstChild)
17
18 return new XMLSerializer().serializeToString(clonedSvg)
19}

One more detail: SVGs have transparent backgrounds by default. Add a white fill before drawing:

1ctx.fillStyle = '#ffffff'
2ctx.fillRect(0, 0, canvas.width, canvas.height)
3ctx.drawImage(img, 0, 0)

Takeaways

On library choices: Evaluate security vulnerabilities in context. A "high severity" CVE might be irrelevant for your specific use case.

On Storybook: The overhead is worth it for component-heavy projects. Being able to develop and test components in isolation fundamentally changes the development experience.

On architecture: Don't over-engineer upfront. Let patterns emerge from pain points. When something feels messy, that's a signal to refactor, not to add more abstraction.

On canvas exports: If you're doing SVG-to-PNG conversion, avoid foreignObject entirely and embed any custom fonts.

Related Articles