Skip to content
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
3 changes: 3 additions & 0 deletions src/mermaid/render.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto'
import { Resvg } from '@resvg/resvg-js'
import { patchSvgdomStyle } from './style-polyfill.js'

// Patch JSON.stringify to handle circular references (elkjs debug logs)
const originalStringify = JSON.stringify
Expand Down Expand Up @@ -49,6 +50,8 @@ async function loadMermaid() {
win.setInterval = setInterval
win.clearInterval = clearInterval
win.console = console

patchSvgdomStyle(win)
}

// Load and register ELK layout engine
Expand Down
64 changes: 64 additions & 0 deletions src/mermaid/style-polyfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Polyfill CSSStyleDeclaration for svgdom elements.
*
* svgdom's element.style returns the element itself (circular ref), not a CSSStyleDeclaration.
* D3 (bundled in mermaid) calls element.style.removeProperty() during sequence diagram rendering.
* This shim provides minimal CSSStyleDeclaration methods that parse/manipulate the style attribute.
*/

interface StyleShim {
getPropertyValue(name: string): string
setProperty(name: string, value: string): void
removeProperty(name: string): string
}

interface PatchableElement {
getAttribute(name: string): string | null
setAttribute(name: string, value: string): void
_styleShim?: StyleShim
}

function createStyleShim(element: PatchableElement): StyleShim {
return {
getPropertyValue(name: string): string {
const style = element.getAttribute('style') || ''
const match = style.match(new RegExp(`${name}\\s*:\\s*([^;]+)`))
return match ? match[1].trim() : ''
},
setProperty(name: string, value: string): void {
const style = element.getAttribute('style') || ''
const regex = new RegExp(`${name}\\s*:[^;]+;?\\s*`)
const cleaned = style.replace(regex, '')
const separator = cleaned && !cleaned.endsWith(';') ? '; ' : ''
element.setAttribute('style', `${cleaned}${separator}${name}: ${value};`.trim())
},
removeProperty(name: string): string {
const style = element.getAttribute('style') || ''
const regex = new RegExp(`${name}\\s*:[^;]+;?\\s*`, 'g')
const oldValue = this.getPropertyValue(name)
element.setAttribute('style', style.replace(regex, '').trim())
return oldValue
},
}
}

/**
* Patch SVGElement.prototype.style to return a CSSStyleDeclaration shim.
* Must be called after svgdom creates the window but before mermaid renders.
*/
export function patchSvgdomStyle(win: Record<string, unknown>): void {
// biome-ignore lint/suspicious/noExplicitAny: accessing svgdom's SVGElement
const SVGElement = (win as any).SVGElement
if (!SVGElement) return

Object.defineProperty(SVGElement.prototype, 'style', {
get(this: PatchableElement) {
if (!this._styleShim) {
this._styleShim = createStyleShim(this)
}
return this._styleShim
},
set() {},
configurable: true,
})
}
24 changes: 22 additions & 2 deletions test/mermaid-elk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,28 @@ flowchart TB
expect(filename).toMatch(/^mermaid-[a-f0-9]{12}\.png$/)
})

// Note: sequence/state diagrams don't work with isomorphic-mermaid (svgdom limitations)
// flowcharts with ELK are our primary use case
it('renders sequence diagrams - regression test for CSSStyleDeclaration polyfill', async () => {
// D3 (bundled in mermaid) calls element.style.removeProperty() during sequence rendering
// svgdom doesn't implement CSSStyleDeclaration, so we polyfill it
// Without the polyfill: TypeError: this.style.removeProperty is not a function
const sequenceDiagram = `
sequenceDiagram
participant A as Service A
participant B as Service B
A->>B: Request
B-->>A: Response
`
const png = await renderMermaid(sequenceDiagram)

// Check PNG magic bytes
expect(png[0]).toBe(0x89)
expect(png[1]).toBe(0x50)
expect(png[2]).toBe(0x4e)
expect(png[3]).toBe(0x47)

// Sequence diagram should render to reasonable size
expect(png.length).toBeGreaterThan(5000)
})

it('does not crop tall diagrams - regression test for missing height attribute', async () => {
// This diagram has many nested subgraphs and is tall
Expand Down