mermaid-render 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """Cross-platform Mermaid to SVG / PNG / PDF / connected Visio conversion."""
2
+ from .api import convert, mermaid_to_vsdx, mermaid_to_svg, mermaid_to_png, mermaid_to_pdf, svg_to_vsdx
3
+ from .vsdx import build_vsdx
4
+ from .semantic import build_connected_vsdx
5
+
6
+ __all__ = ["convert", "mermaid_to_vsdx", "mermaid_to_svg", "mermaid_to_png", "mermaid_to_pdf", "svg_to_vsdx", "build_vsdx", "build_connected_vsdx"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
mermaid_render/api.py ADDED
@@ -0,0 +1,220 @@
1
+ """Mermaid rendering in bundled Chromium: SVG, PNG, PDF and connected Visio."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ from importlib.resources import files
6
+ from pathlib import Path
7
+ from urllib.parse import unquote, urlsplit
8
+
9
+ from .assets import ensure_browser, ensure_mermaid
10
+ from .semantic import build_connected_vsdx
11
+ from .vsdx import build_vsdx
12
+
13
+ LOCAL_MERMAID_ORIGIN = "https://mermaid-render.local"
14
+
15
+
16
+ def _serve_local_mermaid(page, dist: Path) -> str:
17
+ """Resolve virtual HTTPS ES module URLs to runtime files (offline)."""
18
+ dist = dist.resolve()
19
+
20
+ def serve(route):
21
+ relative = unquote(urlsplit(route.request.url).path).lstrip("/")
22
+ target = (dist / relative).resolve()
23
+ try:
24
+ target.relative_to(dist)
25
+ except ValueError:
26
+ route.abort("blockedbyclient")
27
+ return
28
+ if not target.is_file():
29
+ route.fulfill(status=404, body="Module not found", headers={"Access-Control-Allow-Origin": "*"})
30
+ return
31
+ content_type = (
32
+ "text/javascript" if target.suffix in (".js", ".mjs") else
33
+ "application/json" if target.suffix == ".json" else
34
+ "application/octet-stream"
35
+ )
36
+ route.fulfill(path=str(target), content_type=content_type,
37
+ headers={"Access-Control-Allow-Origin": "*"})
38
+
39
+ page.route(f"{LOCAL_MERMAID_ORIGIN}/**", serve)
40
+ return f"{LOCAL_MERMAID_ORIGIN}/mermaid.esm.min.mjs"
41
+
42
+
43
+ def _browser(playwright):
44
+ return playwright.chromium.launch(headless=True, executable_path=str(ensure_browser()))
45
+
46
+
47
+ def svg_to_vsdx(
48
+ svg: str, output: str | Path | None = None, *, title: str = "SVG Diagram",
49
+ ) -> bytes:
50
+ """Geometry-only SVG to VSDX. SVG does not provide semantic glue relationships."""
51
+ from playwright.sync_api import sync_playwright
52
+
53
+ with sync_playwright() as p:
54
+ browser = _browser(p)
55
+ try:
56
+ page = browser.new_page(viewport={"width": 1600, "height": 1200})
57
+ page.set_content("<!doctype html><html><body><div id='root'></div></body></html>")
58
+ page.locator("#root").evaluate("(el, svg) => { el.innerHTML = svg; }", svg)
59
+ capture = files("mermaid_render").joinpath("capture.js").read_text(encoding="utf-8")
60
+ drawing = page.evaluate(capture)
61
+ if not drawing or not drawing.get("items"):
62
+ raise ValueError("SVG contained no supported visible shapes or text")
63
+ result = build_vsdx(drawing, title=title)
64
+ finally:
65
+ browser.close()
66
+ if output is not None:
67
+ Path(output).write_bytes(result)
68
+ return result
69
+
70
+
71
+
72
+ def _render_image(page, *, output_format: str, background: str, scale: float) -> bytes:
73
+ """Render the Mermaid SVG using Chromium, like mermaid-cli.
74
+
75
+ PNG is a screenshot of the SVG at the requested device pixel ratio.
76
+ PDF uses Chromium's vector-aware print-to-PDF, sized to the SVG viewBox.
77
+ """
78
+ if not background or not isinstance(background, str):
79
+ raise ValueError("background must be a non-empty CSS color or 'transparent'")
80
+ dimensions = page.locator("#mount svg").evaluate("""svg => {
81
+ const vb = svg.viewBox.baseVal;
82
+ const box = svg.getBBox();
83
+ const width = vb && vb.width > 0 ? vb.width : box.width;
84
+ const height = vb && vb.height > 0 ? vb.height : box.height;
85
+ if (!(width > 0 && height > 0)) throw new Error('Mermaid SVG has invalid dimensions');
86
+ svg.setAttribute('width', width.toString());
87
+ svg.setAttribute('height', height.toString());
88
+ svg.style.cssText += `;display:block;width:${width}px;height:${height}px;max-width:none;`;
89
+ return {width, height};
90
+ }""")
91
+ width, height = dimensions["width"], dimensions["height"]
92
+ # Print/screenshot must not inherit Chromium's default 8px body margin.
93
+ page.add_style_tag(content=(
94
+ "html,body,#mount{margin:0;padding:0;overflow:visible;} "
95
+ "*{-webkit-print-color-adjust:exact;print-color-adjust:exact;}"
96
+ ))
97
+ transparent = background.lower() == "transparent"
98
+ if not transparent:
99
+ page.locator("#mount svg").evaluate("(svg, color) => svg.style.backgroundColor = color", background)
100
+ if output_format == "png":
101
+ # Page context sets device_scale_factor before rendering Mermaid.
102
+ return page.locator("#mount svg").screenshot(
103
+ type="png", scale="device", omit_background=transparent,
104
+ animations="disabled",
105
+ )
106
+ # Page size uses CSS pixels (96 CSS px/in); SVG paths stay vector in PDF.
107
+ page.add_style_tag(content=(
108
+ f"@page {{ size: {width}px {height}px; margin: 0; }} "
109
+ "html,body,#mount { width: max-content; height: max-content; }"
110
+ ))
111
+ page.emulate_media(media="screen")
112
+ return page.pdf(prefer_css_page_size=True, print_background=True,
113
+ margin={"top": "0", "bottom": "0", "left": "0", "right": "0"})
114
+
115
+
116
+ def _render_mermaid(
117
+ source: str, *, format: str, title: str, theme: str,
118
+ background: str, scale: float,
119
+ ) -> bytes:
120
+ from playwright.sync_api import sync_playwright
121
+
122
+ dist = ensure_mermaid()
123
+ with sync_playwright() as p:
124
+ browser = _browser(p)
125
+ try:
126
+ page = browser.new_page(
127
+ viewport={"width": 1600, "height": 1200},
128
+ device_scale_factor=scale if format == "png" else 1.0,
129
+ )
130
+ page.set_content("<!doctype html><html><body><div id='mount'></div></body></html>")
131
+ page.route("**/*", lambda route: route.abort("blockedbyclient"))
132
+ engine = _serve_local_mermaid(page, dist)
133
+ page.evaluate("""(moduleUrl) => {
134
+ window.__mermaidReady = false;
135
+ window.__mermaidLoadError = null;
136
+ import(moduleUrl).then(m => {
137
+ window.__mermaid = m.default;
138
+ window.__mermaidReady = true;
139
+ }).catch(e => { window.__mermaidLoadError = String(e); });
140
+ }""", engine)
141
+ try:
142
+ page.wait_for_function("window.__mermaidReady || window.__mermaidLoadError", timeout=30000)
143
+ except Exception as exc:
144
+ raise RuntimeError("Mermaid could not load from its installed support files") from exc
145
+ error = page.evaluate("window.__mermaidLoadError")
146
+ if error:
147
+ raise RuntimeError(f"Mermaid renderer failed to load: {error}.")
148
+ svg = page.evaluate("""async ({source, theme}) => {
149
+ const m = window.__mermaid;
150
+ m.initialize({startOnLoad:false,securityLevel:'strict',theme,
151
+ htmlLabels:false,flowchart:{htmlLabels:false}});
152
+ const {svg} = await m.render('mermaid_render_graph', source);
153
+ document.getElementById('mount').innerHTML = svg;
154
+ window.__mermaidSource = source;
155
+ return svg;
156
+ }""", {"source": source, "theme": theme})
157
+ if format == "svg":
158
+ result = svg.encode("utf-8")
159
+ elif format in {"png", "pdf"}:
160
+ result = _render_image(page, output_format=format, background=background, scale=scale)
161
+ else:
162
+ graph_js = files("mermaid_render").joinpath("graph_capture.js").read_text(encoding="utf-8")
163
+ graph = page.evaluate(graph_js)
164
+ # Do not silently emit a Visio drawing with disconnected nodes.
165
+ if not graph.get("nodes") or not graph.get("edges"):
166
+ raise ValueError("Connected VSDX requires a flowchart containing nodes and edges")
167
+ result = build_connected_vsdx(graph["nodes"], graph["edges"], title=title)
168
+ finally:
169
+ browser.close()
170
+ return result
171
+
172
+
173
+ def convert(
174
+ source: str, output: str | Path | None = None, *, format: str | None = None,
175
+ title: str = "Mermaid Diagram", theme: str = "default",
176
+ background: str = "white", scale: float = 1.0,
177
+ ) -> bytes:
178
+ """Render Mermaid to SVG, PNG, PDF or editable connected Visio VSDX.
179
+
180
+ The extension selects the format unless explicitly specified. Without an
181
+ output path, connected VSDX remains the default. PNG uses ``scale`` as a
182
+ pixel density factor (1 = 96 dpi, 2 = 192 dpi). PDF remains vector based.
183
+ Connected VSDX currently supports Mermaid flowcharts only.
184
+ """
185
+ if not math.isfinite(scale) or not (0.1 <= scale <= 4):
186
+ raise ValueError("scale must be between 0.1 and 4")
187
+ if format is None:
188
+ format = Path(output).suffix.lower().lstrip(".") if output else "vsdx"
189
+ format = format.lower().lstrip(".")
190
+ if format == "visio":
191
+ format = "vsdx"
192
+ if format not in {"svg", "png", "pdf", "vsdx"}:
193
+ raise ValueError("format must be 'svg', 'png', 'pdf', 'visio', or 'vsdx'")
194
+ if output is not None and Path(output).suffix.lower() != f".{format}":
195
+ raise ValueError(f"Output filename must end with .{format}")
196
+ result = _render_mermaid(source, format=format, title=title, theme=theme,
197
+ background=background, scale=scale)
198
+ if output is not None:
199
+ Path(output).write_bytes(result)
200
+ return result
201
+
202
+
203
+ def mermaid_to_vsdx(source: str, output: str | Path | None = None, **kwargs) -> bytes:
204
+ """Convert Mermaid flowchart to VSDX with glued native Visio connectors."""
205
+ return convert(source, output, format="vsdx", **kwargs)
206
+
207
+
208
+ def mermaid_to_svg(source: str, output: str | Path | None = None, **kwargs) -> bytes:
209
+ """Render Mermaid to SVG using the official browser-based Mermaid runtime."""
210
+ return convert(source, output, format="svg", **kwargs)
211
+
212
+
213
+ def mermaid_to_png(source: str, output: str | Path | None = None, **kwargs) -> bytes:
214
+ """Render Mermaid to PNG in Chromium."""
215
+ return convert(source, output, format="png", **kwargs)
216
+
217
+
218
+ def mermaid_to_pdf(source: str, output: str | Path | None = None, **kwargs) -> bytes:
219
+ """Render Mermaid to vector PDF via Chromium print-to-PDF."""
220
+ return convert(source, output, format="pdf", **kwargs)
@@ -0,0 +1,143 @@
1
+ """Resolve bundled runtimes or install them in persistent per-user support data."""
2
+ from __future__ import annotations
3
+
4
+ import importlib.metadata
5
+ import json
6
+ import os
7
+ import platform
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import tarfile
12
+ import tempfile
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+ from filelock import FileLock
17
+ from platformdirs import user_data_path
18
+
19
+ from .vendor import DEFAULT_VERSION, download_mermaid
20
+
21
+ RELEASES_URL = 'https://github.com/himbeles/mermaid-render/releases'
22
+ SHELL_NAMES = {'chrome-headless-shell', 'chrome-headless-shell.exe', 'headless_shell'}
23
+
24
+
25
+ class RuntimeSetupError(RuntimeError):
26
+ """Runtime installation failed, with an actionable offline alternative."""
27
+
28
+
29
+ def data_root() -> Path:
30
+ return user_data_path('mermaid-render', appauthor=False)
31
+
32
+
33
+ def package_root() -> Path:
34
+ return Path(__file__).resolve().parent
35
+
36
+
37
+ def browser_version() -> str:
38
+ return importlib.metadata.version('playwright')
39
+
40
+
41
+ def find_shell(browsers: Path) -> Path:
42
+ candidates = sorted(p for p in browsers.glob('chromium_headless_shell-*/*/*')
43
+ if p.is_file() and p.name in SHELL_NAMES)
44
+ if len(candidates) != 1:
45
+ raise RuntimeError(f'Expected exactly one Chromium Headless Shell in {browsers}, got: {candidates}')
46
+ return candidates[0]
47
+
48
+
49
+ def browser_in(directory: Path) -> Path | None:
50
+ manifest = directory / 'BROWSER_INFO.json'
51
+ if not manifest.is_file():
52
+ return None
53
+ info = json.loads(manifest.read_text(encoding='utf-8'))
54
+ relative = Path(info['executable'])
55
+ if relative.is_absolute() or '..' in relative.parts:
56
+ raise ValueError('Invalid Chromium executable path')
57
+ executable = (directory / relative).resolve()
58
+ executable.relative_to(directory.resolve())
59
+ if info.get('playwright_version') != browser_version() or not executable.is_file():
60
+ return None
61
+ if os.name != 'nt' and not os.access(executable, os.X_OK):
62
+ return None
63
+ return executable
64
+
65
+
66
+ def bundled_browser() -> Path | None:
67
+ return browser_in(package_root() / 'browsers')
68
+
69
+
70
+ def mermaid_in(directory: Path) -> Path | None:
71
+ manifest = directory / 'VERSION.json'
72
+ if not manifest.is_file() or not (directory / 'mermaid.esm.min.mjs').is_file():
73
+ return None
74
+ info = json.loads(manifest.read_text(encoding='utf-8'))
75
+ return directory if info.get('version') == DEFAULT_VERSION else None
76
+
77
+
78
+ def install_browser(destination: Path) -> None:
79
+ env = os.environ.copy()
80
+ env['PLAYWRIGHT_BROWSERS_PATH'] = str(destination)
81
+ env['PLAYWRIGHT_SKIP_BROWSER_GC'] = '1'
82
+ result = subprocess.run(
83
+ [sys.executable, '-m', 'playwright', 'install', '--only-shell', 'chromium'],
84
+ env=env, capture_output=True, text=True, timeout=300,
85
+ )
86
+ if result.returncode:
87
+ raise RuntimeError((result.stderr or result.stdout).strip() or 'Playwright browser download failed')
88
+ shell = find_shell(destination)
89
+ if os.name != 'nt':
90
+ shell.chmod(shell.stat().st_mode | 0o111)
91
+ (destination / 'BROWSER_INFO.json').write_text(json.dumps({
92
+ 'executable': shell.relative_to(destination).as_posix(),
93
+ 'playwright_version': browser_version(),
94
+ }, indent=2) + '\n', encoding='utf-8')
95
+
96
+
97
+ def _ensure(name: str, bundled: Path, target: Path, ready: Callable, install: Callable) -> Path:
98
+ try:
99
+ found = ready(bundled) or ready(target)
100
+ if found:
101
+ return found
102
+ root = data_root()
103
+ root.mkdir(parents=True, exist_ok=True)
104
+ with FileLock(str(root / '.setup.lock'), timeout=360):
105
+ # Another process may have finished while we waited for its lock.
106
+ found = ready(bundled) or ready(target)
107
+ if found:
108
+ return found
109
+ target.parent.mkdir(parents=True, exist_ok=True)
110
+ print(f'Installing {name} support files in {target}...', file=sys.stderr)
111
+ with tempfile.TemporaryDirectory(prefix='.install-', dir=target.parent) as temporary:
112
+ staged = Path(temporary) / 'payload'
113
+ staged.mkdir()
114
+ install(staged)
115
+ if not ready(staged):
116
+ raise RuntimeError(f'{name} installation is incomplete')
117
+ # Only an incomplete installation can reach this point. Keep it
118
+ # until the replacement is fully downloaded and validated.
119
+ if target.exists():
120
+ shutil.rmtree(target)
121
+ staged.replace(target)
122
+ return ready(target)
123
+ except (OSError, RuntimeError, ValueError, KeyError, tarfile.TarError, subprocess.SubprocessError) as exc:
124
+ raise RuntimeSetupError(
125
+ f'Could not prepare {name}: {exc}\n'
126
+ f'For offline use, download the bundled wheel for your OS from {RELEASES_URL} '
127
+ 'and install it with: uv tool install --force /path/to/bundled.whl'
128
+ ) from exc
129
+
130
+
131
+ def ensure_mermaid() -> Path:
132
+ return _ensure('Mermaid', package_root() / 'runtime',
133
+ data_root() / 'mermaid' / DEFAULT_VERSION, mermaid_in, download_mermaid)
134
+
135
+
136
+ def ensure_browser() -> Path:
137
+ key = f'playwright-{browser_version()}-{platform.system().lower()}-{platform.machine().lower()}'
138
+ return _ensure('Chromium', package_root() / 'browsers',
139
+ data_root() / 'browsers' / key, browser_in, install_browser)
140
+
141
+
142
+ def setup_runtime() -> tuple[Path, Path]:
143
+ return ensure_mermaid(), ensure_browser()
@@ -0,0 +1,139 @@
1
+ /* SVG DOM -> serializable display list. Port of mermaid-to-visio/svgCapture.ts
2
+ * Executes in a real browser where getBBox/getCTM/getComputedStyle work.
3
+ */
4
+ (() => {
5
+ const SKIP = new Set(['defs','marker','clippath','symbol','pattern','mask']);
6
+ const f = (n, fallback=0) => Number.isFinite(parseFloat(n)) ? parseFloat(n) : fallback;
7
+ const num = (el, key) => f(el.getAttribute(key));
8
+ function color(s) {
9
+ if (!s || s === 'none' || s === 'transparent') return null;
10
+ if (s.startsWith('#')) {
11
+ if (s.length === 4) return '#' + [...s.slice(1)].map(x=>x+x).join('').toUpperCase();
12
+ return s.toUpperCase();
13
+ }
14
+ const match = s.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+))?\s*\)/);
15
+ if (match) {
16
+ if (match[4] !== undefined && Number(match[4]) === 0) return null;
17
+ return '#' + match.slice(1,4).map(x => Math.max(0,Math.min(255, Math.round(Number(x)))).toString(16).padStart(2,'0')).join('').toUpperCase();
18
+ }
19
+ return s; // Visio may not accept named colors; browser normally resolves to rgb
20
+ }
21
+ const pt = (x,y) => ({x,y});
22
+ function xyToRoot(el, x, y) {
23
+ const m = el.getCTM();
24
+ return m ? pt(m.a*x+m.c*y+m.e, m.b*x+m.d*y+m.f) : pt(x,y);
25
+ }
26
+ function simplify(points, tolerance=0.35) {
27
+ if (points.length<=2) return points;
28
+ const out=[points[0]];
29
+ for (let i=1;i<points.length-1;i++) {
30
+ const a=out[out.length-1], b=points[i], c=points[i+1];
31
+ const dx=c.x-a.x, dy=c.y-a.y, length=Math.hypot(dx,dy)||1;
32
+ if (Math.abs((b.x-a.x)*dy-(b.y-a.y)*dx)/length>tolerance) out.push(b);
33
+ }
34
+ out.push(points[points.length-1]);
35
+ return out;
36
+ }
37
+ function ellipse(cx,cy,rx,ry) {
38
+ return Array.from({length:48},(_,i)=>{
39
+ const a=i/48*2*Math.PI;
40
+ return pt(cx+rx*Math.cos(a),cy+ry*Math.sin(a));
41
+ });
42
+ }
43
+ function pathPoints(el) {
44
+ let total;
45
+ try { total=el.getTotalLength(); } catch { return null; }
46
+ if (!(total>0) || !Number.isFinite(total)) return null;
47
+ const step=Math.max(1.5,total/600), pts=[];
48
+ for (let t=0;t<total;t+=step) {
49
+ const p=el.getPointAtLength(t); pts.push(pt(p.x,p.y));
50
+ }
51
+ const end=el.getPointAtLength(total);
52
+ pts.push(pt(end.x,end.y));
53
+ return pts;
54
+ }
55
+ function hidden(el) {
56
+ const s=getComputedStyle(el);
57
+ return s.display==='none'||s.visibility==='hidden'||f(s.opacity,1)===0;
58
+ }
59
+ function poly(el) {
60
+ const name=el.tagName.toLowerCase();
61
+ let local=null, closed=false;
62
+ if (name==='rect') {
63
+ const x=num(el,'x'), y=num(el,'y'), w=num(el,'width'), h=num(el,'height');
64
+ if(w<=0||h<=0) return null;
65
+ local=[pt(x,y),pt(x+w,y),pt(x+w,y+h),pt(x,y+h)];closed=true;
66
+ } else if(name==='circle') {
67
+ const r=num(el,'r');if(r<=0)return null;
68
+ local=ellipse(num(el,'cx'),num(el,'cy'),r,r);closed=true;
69
+ } else if(name==='ellipse') {
70
+ const rx=num(el,'rx'),ry=num(el,'ry');if(rx<=0||ry<=0)return null;
71
+ local=ellipse(num(el,'cx'),num(el,'cy'),rx,ry);closed=true;
72
+ } else if(name==='line') {
73
+ local=[pt(num(el,'x1'),num(el,'y1')),pt(num(el,'x2'),num(el,'y2'))];
74
+ } else if(name==='polyline'||name==='polygon') {
75
+ const raw=el.getAttribute('points')||'';
76
+ const nums=raw.trim().split(/[\s,]+/).map(Number).filter(Number.isFinite);
77
+ local=[];for(let i=0;i+1<nums.length;i+=2)local.push(pt(nums[i],nums[i+1]));
78
+ closed=name==='polygon';
79
+ } else if(name==='path') {
80
+ local=pathPoints(el);closed=/z\s*$/i.test(el.getAttribute('d')||'');
81
+ }
82
+ if(!local||local.length<2)return null;
83
+ const s=getComputedStyle(el), fill=color(s.fill), stroke=color(s.stroke);
84
+ if(!fill&&!stroke)return null;
85
+ const points=simplify(local.map(p=>xyToRoot(el,p.x,p.y)));
86
+ if(points.length<2)return null;
87
+ return {kind:'poly',points,closed,fill,stroke,strokeWidthPx:f(s.strokeWidth,1),
88
+ dash: !!s.strokeDasharray && s.strokeDasharray!=='none'&&s.strokeDasharray!=='0',
89
+ beginArrow: !!s.markerStart && s.markerStart !== 'none',
90
+ endArrow: !!s.markerEnd && s.markerEnd !== 'none'};
91
+ }
92
+ function textItem(el, value) {
93
+ value=value.replace(/\s+/g,' ').trim();if(!value)return null;
94
+ let bbox;
95
+ try {bbox=el.getBBox();}catch{return null;}
96
+ if(bbox.width===0&&bbox.height===0)return null;
97
+ const corners=[xyToRoot(el,bbox.x,bbox.y),xyToRoot(el,bbox.x+bbox.width,bbox.y),
98
+ xyToRoot(el,bbox.x+bbox.width,bbox.y+bbox.height),xyToRoot(el,bbox.x,bbox.y+bbox.height)];
99
+ const xs=corners.map(p=>p.x), ys=corners.map(p=>p.y);
100
+ const s=getComputedStyle(el);
101
+ let leaf=el;
102
+ while(true) {
103
+ const inner=[...leaf.children].find(c=>c.tagName.toLowerCase()==='tspan');
104
+ if(!inner)break;leaf=inner;
105
+ }
106
+ const ls=getComputedStyle(leaf);
107
+ const weight=ls.fontWeight||s.fontWeight||'400';
108
+ return {kind:'text',text:value,x:Math.min(...xs),y:Math.min(...ys),
109
+ width:Math.max(...xs)-Math.min(...xs),height:Math.max(...ys)-Math.min(...ys),
110
+ fontPx:f(s.fontSize,12),fontFamily:(s.fontFamily||'Arial').split(',')[0].replace(/["']/g,'').trim(),
111
+ bold:weight==='bold'||parseInt(weight,10)>=600,italic:(ls.fontStyle||s.fontStyle)==='italic',
112
+ anchor:['middle','end'].includes(s.textAnchor)?s.textAnchor:'start',fill:color(ls.fill)||color(s.fill)||'#000000'};
113
+ }
114
+ function capture(svg) {
115
+ const vb=(svg.getAttribute('viewBox')||'').trim().split(/[\s,]+/).map(Number);
116
+ const b=vb.length===4 && vb.every(Number.isFinite) ? {width:vb[2],height:vb[3]} : svg.getBBox();
117
+ svg.setAttribute('width',String(b.width));svg.setAttribute('height',String(b.height));
118
+ Object.assign(svg.style,{width:`${b.width}px`,height:`${b.height}px`,maxWidth:'none'});
119
+ const items=[];
120
+ function walk(el) {
121
+ const name=el.tagName.toLowerCase();
122
+ if(SKIP.has(name) || (el instanceof SVGGraphicsElement && hidden(el)))return;
123
+ if(name==='text') {
124
+ const lines=[...el.children].filter(c=>c.tagName.toLowerCase()==='tspan'&&c.hasAttribute('x'));
125
+ if(lines.length>=2) {
126
+ for(const l of lines){const t=textItem(l,l.textContent||'');if(t)items.push(t);}
127
+ } else {
128
+ const t=textItem(el,el.textContent||'');if(t)items.push(t);
129
+ }
130
+ return;
131
+ }
132
+ const p=poly(el);if(p)items.push(p);
133
+ for(const child of el.children)walk(child);
134
+ }
135
+ for(const child of svg.children)walk(child);
136
+ return {width:b.width,height:b.height,items};
137
+ }
138
+ return capture(document.querySelector('svg'));
139
+ })()
mermaid_render/cli.py ADDED
@@ -0,0 +1,48 @@
1
+ """Cross-platform command-line entry point."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from pathlib import Path
6
+
7
+ from .api import convert
8
+ from .assets import RuntimeSetupError, setup_runtime
9
+
10
+
11
+ def main() -> None:
12
+ import sys
13
+
14
+ if sys.argv[1:] == ["setup"]:
15
+ try:
16
+ mermaid, browser = setup_runtime()
17
+ except RuntimeSetupError as exc:
18
+ raise SystemExit(str(exc)) from exc
19
+ print(f"Runtime ready: Mermaid at {mermaid}; Chromium at {browser}")
20
+ return
21
+ parser = argparse.ArgumentParser(description="Render Mermaid as SVG, PNG, PDF or connected Visio VSDX",
22
+ epilog="Missing runtime files install automatically. Use mermaid-render setup to prepare them in advance.")
23
+ parser.add_argument("input", type=Path, help="Mermaid .mmd/.mermaid file")
24
+ parser.add_argument("-o", "--output", type=Path, help="Output .svg, .png, .pdf or .vsdx file")
25
+ parser.add_argument("-f", "--format", choices=["svg", "png", "pdf", "visio", "vsdx"], help="Output format (otherwise inferred from -o, default vsdx)")
26
+ parser.add_argument("--title", help="Diagram title for Visio")
27
+ parser.add_argument("--theme", default="default", help="Mermaid theme")
28
+ parser.add_argument("--background", default="white", help="PNG/PDF background color or transparent (PNG)")
29
+ parser.add_argument("--scale", type=float, default=1.0, help="PNG pixel density factor (default 1 = 96 dpi)")
30
+ args = parser.parse_args()
31
+ fmt = args.format or (args.output.suffix.lower().lstrip(".") if args.output else "vsdx")
32
+ fmt = "vsdx" if fmt == "visio" else fmt
33
+ if fmt not in {"svg", "png", "pdf", "vsdx"}:
34
+ parser.error("output extension must be .svg, .png, .pdf or .vsdx")
35
+ output = args.output or args.input.with_suffix(f".{fmt}")
36
+ if args.input.suffix.lower() not in {".mmd", ".mermaid"}:
37
+ parser.error("input must be Mermaid (.mmd/.mermaid)")
38
+ try:
39
+ convert(args.input.read_text(encoding="utf-8"), output, format=fmt,
40
+ title=args.title or args.input.stem, theme=args.theme,
41
+ background=args.background, scale=args.scale)
42
+ except RuntimeSetupError as exc:
43
+ parser.exit(1, f"{exc}\n")
44
+ print(f"Created {output}")
45
+
46
+
47
+ if __name__ == "__main__":
48
+ main()
@@ -0,0 +1,95 @@
1
+ /* Recover graph structure from Mermaid's own flowchart DB and geometry from SVG.
2
+ * Evaluated in Chromium after mermaid.render() has inserted an SVG in #mount.
3
+ * A missing node mapping is an error: never silently return disconnected shapes.
4
+ */
5
+ (async () => {
6
+ const m = window.__mermaid;
7
+ const source = window.__mermaidSource;
8
+ const diagram = await m.mermaidAPI.getDiagramFromText(source);
9
+ if (!/^flowchart/.test(diagram.type)) {
10
+ throw new Error(`Connected VSDX export supports Mermaid flowcharts, not ${diagram.type}`);
11
+ }
12
+ const db = diagram.db;
13
+ const data = typeof db.getData === 'function' ? db.getData() : {
14
+ nodes: [...db.getVertices().values()],
15
+ edges: db.getEdges().map(e => ({start:e.start,end:e.end,label:e.text,type:e.type}))
16
+ };
17
+ if (!data.nodes?.length) throw new Error('Mermaid flowchart has no nodes');
18
+ const svg = document.querySelector('#mount svg');
19
+ if (!svg) throw new Error('Mermaid did not render an SVG');
20
+ const vb = (svg.getAttribute('viewBox') || '').trim().split(/[\s,]+/).map(Number);
21
+ if (vb.length === 4 && vb.every(Number.isFinite)) {
22
+ svg.setAttribute('width', String(vb[2]));
23
+ svg.setAttribute('height', String(vb[3]));
24
+ Object.assign(svg.style, {width:`${vb[2]}px`,height:`${vb[3]}px`,maxWidth:'none'});
25
+ }
26
+ const geometry = (el) => {
27
+ const b = el.getBBox(), t = el.getCTM();
28
+ if (!t) throw new Error('Could not measure SVG node');
29
+ const corners = [[b.x,b.y],[b.x+b.width,b.y],[b.x+b.width,b.y+b.height],[b.x,b.y+b.height]];
30
+ const pts = corners.map(([x,y]) => ({x:t.a*x+t.c*y+t.e,y:t.b*x+t.d*y+t.f}));
31
+ const xs = pts.map(p=>p.x), ys=pts.map(p=>p.y);
32
+ return {x:Math.min(...xs),y:Math.min(...ys),width:Math.max(...xs)-Math.min(...xs),height:Math.max(...ys)-Math.min(...ys)};
33
+ };
34
+ const candidates = [...svg.querySelectorAll('g.node, g.cluster, [data-id]')].filter(el => el instanceof SVGGraphicsElement);
35
+ function elementFor(id) {
36
+ const matches = candidates.filter(el => {
37
+ const domId = el.getAttribute('id') || '';
38
+ // Mermaid 12 prefixes generated IDs with the containing SVG's ID.
39
+ // Remove that exact prefix before matching; node IDs can contain dashes.
40
+ const prefix = `${svg.id}-`;
41
+ const localId = svg.id && domId.startsWith(prefix) ? domId.slice(prefix.length) : domId;
42
+ return el.getAttribute('data-id') === id || domId === id ||
43
+ localId.match(/^flowchart-(.*)-\d+$/)?.[1] === id;
44
+ });
45
+ return matches.find(el => el.classList.contains('node')) ||
46
+ matches.find(el => el.classList.contains('cluster')) || matches[0];
47
+ }
48
+ function formatLabel(x, fallback) {
49
+ if (typeof x === 'string') return x;
50
+ if (typeof x === 'number') return String(x);
51
+ if (x && typeof x === 'object' && typeof x.text === 'string') return x.text;
52
+ return fallback;
53
+ }
54
+ const cssColor = (value, fallback) => {
55
+ if (!value || value === 'none' || value === 'transparent') return fallback;
56
+ if (value.startsWith('#')) return value;
57
+ const parts = value.match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/);
58
+ if (parts) return '#' + parts.slice(1,4).map(n => (+n).toString(16).padStart(2,'0')).join('').toUpperCase();
59
+ return fallback;
60
+ };
61
+ const nodes = data.nodes.map(n => {
62
+ const id = String(n.id), el = elementFor(id);
63
+ if (!el) throw new Error(`Cannot locate SVG element for Mermaid node '${id}'`);
64
+ const dim = geometry(el);
65
+ if (!(dim.width > 0 && dim.height > 0)) throw new Error(`Invalid bounds for Mermaid node '${id}'`);
66
+ const glyph = el.querySelector('.basic, .label-container, rect, polygon, ellipse, circle, path');
67
+ const cs = getComputedStyle(glyph || el);
68
+ const textEl = el.querySelector('text, foreignObject');
69
+ const fontCss = textEl ? getComputedStyle(textEl) : cs;
70
+ const label = (el.querySelector('.nodeLabel, .label, text')?.textContent || '').trim() ||
71
+ formatLabel(n.label, id);
72
+ return {
73
+ id, ...dim, text:label, shape: String(n.shape || n.type || ''),
74
+ corner_radius: glyph?.tagName.toLowerCase() === 'rect' ? glyph.rx.baseVal.value : 0,
75
+ fill: cssColor(cs.fill, '#E5EFFA'),
76
+ stroke: cssColor(cs.stroke, '#4472C4'),
77
+ font_color: cssColor(fontCss.fill, '#172D4A'),
78
+ font_size: Number.parseFloat(fontCss.fontSize) || 14,
79
+ is_group: Boolean(n.isGroup)
80
+ };
81
+ });
82
+ const edges = (data.edges || []).map(e => {
83
+ const source = String(e.start ?? e.source ?? ''), target = String(e.end ?? e.target ?? '');
84
+ return {
85
+ source, target, text:formatLabel(e.label, ''),
86
+ // Mermaid getData supplies arrowTypeEnd or arrowTypeStart for actual directedness.
87
+ // 'arrow_open' is an undirected line in older diagram models.
88
+ arrow: e.arrowTypeEnd !== undefined ? e.arrowTypeEnd !== 'none' : e.type !== 'arrow_open',
89
+ start_arrow: e.arrowTypeStart !== undefined && e.arrowTypeStart !== 'none',
90
+ dash: e.pattern === 'dotted' || e.pattern === 'dashed',
91
+ stroke: '#4472C4'
92
+ };
93
+ });
94
+ return {nodes,edges};
95
+ })()