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 audit2xlsx *3Severity: high4Prototype Pollution - GHSA-4r6h-8v6p-xvw65ReDoS - GHSA-5pgg-2g8v-p4x96No 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:
- You need data to see anything render
- Small changes require navigating through the whole flow
- Debugging alignment issues means switching between code and browser constantly
- You can't easily test edge cases (empty data, huge numbers, long labels)
Storybook solved all of this. Each component gets its own isolated environment where I can:
- See it render immediately with mock data
- Test edge cases by tweaking props
- Verify visual details without app navigation
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 everything2<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<XAxis2 chartWidth={chartWidth}3 chartHeight={chartHeight}4 labels={labels}5 barSlotWidth={barSlotWidth}6 barGap={barGap}7 unit="FY"8/>9<YAxis10 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.tsx3├── BarSegment.stories.tsx4├── index.ts5├── SegmentLabel/6│ ├── SegmentLabel.tsx7│ └── index.ts8└── 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 null45 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 `1516 clonedSvg.insertBefore(styleElement, clonedSvg.firstChild)1718 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.
