diff --git a/src/mermaid/render.ts b/src/mermaid/render.ts index 502a8d4..5a87c62 100644 --- a/src/mermaid/render.ts +++ b/src/mermaid/render.ts @@ -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 @@ -49,6 +50,8 @@ async function loadMermaid() { win.setInterval = setInterval win.clearInterval = clearInterval win.console = console + + patchSvgdomStyle(win) } // Load and register ELK layout engine diff --git a/src/mermaid/style-polyfill.ts b/src/mermaid/style-polyfill.ts new file mode 100644 index 0000000..39083f4 --- /dev/null +++ b/src/mermaid/style-polyfill.ts @@ -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): 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, + }) +} diff --git a/test/mermaid-elk.test.ts b/test/mermaid-elk.test.ts index f03aaec..9306cda 100644 --- a/test/mermaid-elk.test.ts +++ b/test/mermaid-elk.test.ts @@ -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