Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/fix-zenuml-svg-renderer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'mermaid': patch
'@mermaid-js/mermaid-zenuml': patch
---

fix: update @zenuml/core to v3.46.11 with native SVG renderer

- Fix vertical lifelines disappearing when printing (#6004)
- Fix SVG dimensions exceeding container boundaries (#7266)
- Fix invalid ZenUML syntax freezing the editor (#7154)
2 changes: 1 addition & 1 deletion packages/mermaid-zenuml/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
],
"license": "MIT",
"dependencies": {
"@zenuml/core": "^3.41.6"
"@zenuml/core": "^3.47.0"
},
"devDependencies": {
"mermaid": "workspace:^"
Expand Down
18 changes: 18 additions & 0 deletions packages/mermaid-zenuml/src/types/zenuml-core.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Override @zenuml/core types for nodenext module resolution.
// The package lacks "type": "module" so TS treats it as CJS,
// rejecting named imports. This declaration fixes that.
declare module '@zenuml/core' {
export interface RenderOptions {
theme?: 'theme-default' | 'theme-mermaid';
}

export interface RenderResult {
svg: string;
innerSvg: string;
width: number;
height: number;
viewBox: string;
}

export function renderToSvg(code: string, options?: RenderOptions): RenderResult;
}
98 changes: 98 additions & 0 deletions packages/mermaid-zenuml/src/zenumlRenderer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { vi } from 'vitest';
import { calculateSvgSizeAttrs } from './zenumlRenderer.js';

vi.mock('@zenuml/core', () => ({
renderToSvg: vi.fn((code: string) => ({
innerSvg: `<text>${code.trim()}</text>`,
width: 400,
height: 300,
viewBox: '0 0 400 300',
})),
}));

vi.mock('./mermaidUtils.js', () => ({
log: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
getConfig: vi.fn(() => ({
securityLevel: 'loose',
sequence: { useMaxWidth: true },
})),
}));

describe('calculateSvgSizeAttrs', function () {
it('should return responsive width when useMaxWidth is true', function () {
const attrs = calculateSvgSizeAttrs(133, 392, true);

expect(attrs.get('width')).toEqual('100%');
expect(attrs.get('style')).toEqual('max-width: 133px;');
expect(attrs.has('height')).toBe(false);
});

it('should return absolute dimensions when useMaxWidth is false', function () {
const attrs = calculateSvgSizeAttrs(133, 392, false);

expect(attrs.get('width')).toEqual('133');
expect(attrs.get('height')).toEqual('392');
expect(attrs.has('style')).toBe(false);
});
});

describe('draw', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '';
});

it('should render SVG content into the target element', async () => {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = 'test-id';
document.body.appendChild(svg);

const { draw } = await import('./zenumlRenderer.js');
await draw('zenuml\n Alice->Bob: hello', 'test-id');

expect(svg.innerHTML).toContain('Alice-');
expect(svg.getAttribute('viewBox')).toBe('0 0 400 300');
expect(svg.getAttribute('width')).toBe('100%');
expect(svg.getAttribute('style')).toBe('max-width: 400px;');
});

it('should set absolute dimensions when useMaxWidth is false', async () => {
const { getConfig } = await import('./mermaidUtils.js');
vi.mocked(getConfig).mockReturnValue({
securityLevel: 'loose',
sequence: { useMaxWidth: false },
});

const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = 'test-abs';
document.body.appendChild(svg);

const { draw } = await import('./zenumlRenderer.js');
await draw('zenuml\n A->B: msg', 'test-abs');

expect(svg.getAttribute('width')).toBe('400');
expect(svg.getAttribute('height')).toBe('300');
});

it('should handle missing SVG element gracefully', async () => {
const { draw } = await import('./zenumlRenderer.js');
const { log } = await import('./mermaidUtils.js');

await draw('zenuml\n A->B: msg', 'nonexistent');

expect(log.error).toHaveBeenCalledWith('Cannot find svg element');
});

it('should strip the zenuml prefix before rendering', async () => {
const { renderToSvg } = await import('@zenuml/core');

const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.id = 'test-strip';
document.body.appendChild(svg);

const { draw } = await import('./zenumlRenderer.js');
await draw('zenuml\n Alice->Bob: hello', 'test-strip');

expect(renderToSvg).toHaveBeenCalledWith('\n Alice->Bob: hello');
});
});
113 changes: 67 additions & 46 deletions packages/mermaid-zenuml/src/zenumlRenderer.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,86 @@
import { renderToSvg } from '@zenuml/core';
import { getConfig, log } from './mermaidUtils.js';
import ZenUml from '@zenuml/core';

const regexp = /^\s*zenuml/;

// Create a Zen UML container outside the svg first for rendering, otherwise the Zen UML diagram cannot be rendered properly
function createTemporaryZenumlContainer(id: string) {
const container = document.createElement('div');
container.id = `container-${id}`;
container.style.display = 'flex';
container.innerHTML = `<div id="zenUMLApp-${id}"></div>`;
const app = container.querySelector(`#zenUMLApp-${id}`)!;
return { container, app };
}

// Create a foreignObject to wrap the Zen UML container in the svg
function createForeignObject(id: string) {
const foreignObject = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
foreignObject.setAttribute('x', '0');
foreignObject.setAttribute('y', '0');
foreignObject.setAttribute('width', '100%');
foreignObject.setAttribute('height', '100%');
const { container, app } = createTemporaryZenumlContainer(id);
foreignObject.appendChild(container);
return { foreignObject, container, app };
}
export const calculateSvgSizeAttrs = (
width: number,
height: number,
useMaxWidth: boolean
): Map<string, string> => {
const attrs = new Map<string, string>();

if (useMaxWidth) {
attrs.set('width', '100%');
attrs.set('style', `max-width: ${width}px;`);
} else {
attrs.set('width', String(width));
attrs.set('height', String(height));
}

return attrs;
};

/**
* Draws a Zen UML in the tag with id: id based on the graph definition in text.
*
* @param text - The text of the diagram
* @param id - The id of the diagram which will be used as a DOM element id¨
* Resolves the root document and SVG element, handling sandbox mode.
* Follows the same pattern as mermaid's selectSvgElement utility.
*/
export const draw = async function (text: string, id: string) {
log.info('draw with Zen UML renderer', ZenUml);

text = text.replace(regexp, '');
const selectSvgElement = (id: string): SVGSVGElement | null => {
const { securityLevel } = getConfig();
// Handle root and Document for when rendering in sandbox mode
let sandboxElement: HTMLIFrameElement | null = null;
let root: Document = document;

if (securityLevel === 'sandbox') {
sandboxElement = document.getElementById('i' + id) as HTMLIFrameElement;
const sandboxElement = document.querySelector<HTMLIFrameElement>(`#i${id}`);
root = sandboxElement?.contentDocument ?? document;
}

const root = securityLevel === 'sandbox' ? sandboxElement?.contentWindow?.document : document;
return root.querySelector<SVGSVGElement>(`#${id}`);
};

const svgContainer = root?.querySelector(`svg#${id}`);
const configureSvgSize = (
svgEl: SVGSVGElement,
width: number,
height: number,
useMaxWidth: boolean
) => {
const attrs = calculateSvgSizeAttrs(width, height, useMaxWidth);

if (!root || !svgContainer) {
log.error('Cannot find root or svgContainer');
return;
svgEl.removeAttribute('height');
svgEl.style.removeProperty('max-width');

for (const [attr, value] of attrs) {
svgEl.setAttribute(attr, value);
}
};

/**
* Draws a ZenUML diagram in the SVG element with id: id based on the
* graph definition in text, using native SVG rendering.
*
* @param text - The text of the diagram
* @param id - The id of the diagram which will be used as a DOM element id
*/
export const draw = function (text: string, id: string): Promise<void> {
log.info('draw with ZenUML native SVG renderer');

const code = text.replace(regexp, '');
const config = getConfig();
const useMaxWidth = config.sequence?.useMaxWidth ?? true;

const svgEl = selectSvgElement(id);

if (!svgEl) {
log.error('Cannot find svg element');
return Promise.resolve();
}

const result = renderToSvg(code);

const { foreignObject, container, app } = createForeignObject(id);
svgContainer.appendChild(foreignObject);
const zenuml = new ZenUml(app);
// default is a theme name. More themes to be added and will be configurable in the future
await zenuml.render(text, { theme: 'default', mode: 'static' });
configureSvgSize(svgEl, result.width, result.height, useMaxWidth);
svgEl.setAttribute('viewBox', result.viewBox);
svgEl.innerHTML = result.innerSvg;

const { width, height } = window.getComputedStyle(container);
log.debug('zenuml diagram size', width, height);
svgContainer.setAttribute('style', `width: ${width}; height: ${height};`);
return Promise.resolve();
};

export default {
Expand Down
Loading
Loading