d2-to-drawio 1.0.0

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Moawiah Jamal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # d2-to-drawio
2
+
3
+ [![npm version](https://img.shields.io/npm/v/d2-to-drawio)](https://www.npmjs.com/package/d2-to-drawio)
4
+ [![CI](https://github.com/Moawiah188/d2-to-drawio/actions/workflows/ci.yml/badge.svg)](https://github.com/Moawiah188/d2-to-drawio/actions/workflows/ci.yml)
5
+ [![license: MIT](https://img.shields.io/npm/l/d2-to-drawio)](https://github.com/Moawiah188/d2-to-drawio/blob/main/LICENSE)
6
+ [![node](https://img.shields.io/node/v/d2-to-drawio)](https://nodejs.org)
7
+
8
+ Convert D2 diagrams to draw.io files: keep authoring diagrams as code in [D2](https://d2lang.com), and hand fully editable drawio / [diagrams.net](https://www.diagrams.net) files to everyone else. Shapes, containers, edges, styles, and positions all come across as native draw.io elements, not as a pasted image.
9
+
10
+ ```
11
+ npx d2-to-drawio examples/basic.d2 -o basic.drawio
12
+ ```
13
+
14
+ ## What you get
15
+
16
+ ```d2
17
+ direction: right
18
+
19
+ user: Visitor { shape: person }
20
+ app: "Web App"
21
+ db: "Postgres" { shape: cylinder }
22
+
23
+ user -> app: "browses"
24
+ app -> db: "reads / writes"
25
+ ```
26
+
27
+ ```
28
+ npx d2-to-drawio examples/basic.d2 -o basic.drawio
29
+ ```
30
+
31
+ The result, opened in draw.io, every element selectable and editable:
32
+
33
+ ![basic example rendered in draw.io](https://raw.githubusercontent.com/Moawiah188/d2-to-drawio/main/docs/assets/basic.png)
34
+
35
+ A richer example with containers, a queue, crow's foot cardinality, and tooltips ([source](https://github.com/Moawiah188/d2-to-drawio/blob/main/examples/microservices.d2)):
36
+
37
+ ![microservices example rendered in draw.io](https://raw.githubusercontent.com/Moawiah188/d2-to-drawio/main/docs/assets/microservices.png)
38
+
39
+ Open the committed outputs directly in your browser:
40
+
41
+ - [Open basic.drawio in app.diagrams.net](https://app.diagrams.net/#Uhttps%3A%2F%2Fraw.githubusercontent.com%2FMoawiah188%2Fd2-to-drawio%2Fmain%2Fexamples%2Fbasic.drawio)
42
+ - [Open microservices.drawio in app.diagrams.net](https://app.diagrams.net/#Uhttps%3A%2F%2Fraw.githubusercontent.com%2FMoawiah188%2Fd2-to-drawio%2Fmain%2Fexamples%2Fmicroservices.drawio)
43
+
44
+ <!-- PLACEHOLDER: drop a real screenshot of one of these files open in the app.diagrams.net editor (with the draw.io UI visible) here, e.g. docs/assets/editor-screenshot.png, and reference it with an absolute raw.githubusercontent.com URL. -->
45
+
46
+ ## Why this exists
47
+
48
+ Plenty of teams standardize on draw.io and Confluence for documentation, while the engineers drawing the systems would rather write diagrams as code, version them, and review them in pull requests. Exporting D2 to SVG or PNG hands the team a static picture nobody can edit; switching the whole team to D2 rarely happens. This tool removes that one-way door: engineers keep the `.d2` source of truth, everyone else gets a native `.drawio` file they can open, edit, and paste into Confluence.
49
+
50
+ ## Quick start
51
+
52
+ ```
53
+ npm install -g d2-to-drawio
54
+ d2-to-drawio diagram.d2 -o diagram.drawio
55
+ ```
56
+
57
+ Or without installing:
58
+
59
+ ```
60
+ npx d2-to-drawio diagram.d2 -o diagram.drawio
61
+ ```
62
+
63
+ Or from stdin to stdout:
64
+
65
+ ```
66
+ cat diagram.d2 | d2-to-drawio > diagram.drawio
67
+ ```
68
+
69
+ ## CLI reference
70
+
71
+ ```
72
+ Usage: d2-to-drawio [options] [input.d2]
73
+
74
+ Reads from the input file, or from stdin when no file is given (or "-").
75
+ Writes to --output, or to stdout.
76
+
77
+ Options:
78
+ -o, --output <file> write the .drawio XML to a file instead of stdout
79
+ --layout <name> layout engine: dagre (default) or elk
80
+ --theme <id> D2 theme id (default 0)
81
+ --waypoints preserve D2 edge routes as fixed waypoints
82
+ --strict fail when input uses features that would degrade
83
+ -q, --quiet suppress warnings on stderr
84
+ -h, --help show this help
85
+ -v, --version print the version
86
+ ```
87
+
88
+ Behavior notes:
89
+
90
+ - `@imports` resolve automatically: for file input, every `.d2` under the input file's directory is available to the compiler; for stdin, imports resolve against the current directory.
91
+ - Unsupported D2 features never crash a conversion. Each degrades to the closest visual and prints one warning line to stderr. `--strict` turns those warnings into a failing exit instead.
92
+ - By default draw.io re-routes edges orthogonally, which keeps them fully editable. `--waypoints` pins d2's exact computed routes instead.
93
+ - Exit codes: 0 success, 1 conversion or input error, 2 usage error.
94
+
95
+ ## Library API
96
+
97
+ ```js
98
+ import { convert, convertFile, dispose } from 'd2-to-drawio';
99
+
100
+ const xml = await convert('a -> b: hello');
101
+ // or, resolving imports relative to the file:
102
+ const xml2 = await convertFile('diagram.d2', { layout: 'elk' });
103
+
104
+ await dispose();
105
+ ```
106
+
107
+ - `convert(d2Source, options)` returns a Promise of the `.drawio` XML string.
108
+ - `convertFile(inputPath, options)` reads the file and feeds sibling `.d2` files to the compiler so relative imports work.
109
+ - `options`: `layout` ('dagre' | 'elk'), `themeID` (number), `strict` (boolean), `waypoints` (boolean), `onWarning` (callback receiving `{code, message}`), `fsMap`/`inputPath` (virtual filesystem for imports when using `convert`).
110
+ - `dispose()` releases the compiler's worker thread. Call it when done, or your process will stay alive. The next `convert` after a `dispose` transparently starts a fresh engine.
111
+ - Errors: malformed D2 rejects with `D2SyntaxError` (message carries `file:line:column`); strict-mode degradations reject with `UnsupportedFeatureError` (carrying the warning list).
112
+
113
+ Output is deterministic: identical input produces byte-identical output, so generated files diff cleanly in version control.
114
+
115
+ ## Feature support
116
+
117
+ The full, fixture-backed matrix lives in [docs/FEATURE-MATRIX.md](https://github.com/Moawiah188/d2-to-drawio/blob/main/docs/FEATURE-MATRIX.md). Summary:
118
+
119
+ | Area | Support |
120
+ |---|---|
121
+ | Shape catalog (rectangle, square, page, parallelogram, document, cylinder, queue, package, step, callout, stored_data, person, diamond, oval, circle, hexagon, cloud, image, text) | Full |
122
+ | Containers, any nesting depth, grid diagrams | Full |
123
+ | Connections: `->` `<-` `<->` `--`, chains, parallel edges, self-loops | Full |
124
+ | Arrowheads incl. crow's foot ER markers, arrowhead labels | Full |
125
+ | Styles: fill, stroke, stroke-width, stroke-dash, opacity, shadow, fonts, bold/italic/underline | Full |
126
+ | Themes: all 20 official palettes | Full |
127
+ | Compiler-level features: vars, globs, classes, imports, overrides, null | Full |
128
+ | Boards (layers, scenarios, steps) | Full, one draw.io page per board |
129
+ | Tooltips and links | Full |
130
+ | sql_table, class (UML) | Partial: formatted single shapes, not row-by-row cells |
131
+ | Sequence diagrams | Partial: exact d2 geometry, but fixed (does not re-route) |
132
+ | Markdown / LaTeX / code labels | Partial: plain text / MathJax / monospace box |
133
+ | multiple, 3d, double-border, fill-pattern, animated, sketch | Unsupported, warned |
134
+
135
+ ## Limitations
136
+
137
+ - The compiler dependency (`@terrastruct/d2`, the official WASM build of D2) is around 60 MB installed and takes a couple of seconds to start. One-shot CLI runs pay that startup; batch conversions should use the library API, which reuses the engine across calls.
138
+ - sql_table and class shapes are single shapes with formatted labels. Editing individual rows as draw.io table cells is on the roadmap.
139
+ - Sequence diagram messages and lifelines keep d2's exact geometry and will not re-route if you move the actors.
140
+ - Links between D2 boards (`link: layers.x`) do not become page links in draw.io yet.
141
+ - The TALA layout engine is not part of the embedded compiler; `dagre` and `elk` are available.
142
+
143
+ ## How it works
144
+
145
+ The official D2 compiler (as a WASM build) parses the source and runs the real layout engine, producing fully positioned shapes and routed connections. This tool walks that output, rebuilds the container tree, and translates shapes, styles, and theme colors into draw.io's mxGraph cell model. It then emits deterministic, uncompressed `.drawio` XML that draw.io opens like any hand-drawn file.
146
+
147
+ ## Roadmap
148
+
149
+ Only things actually planned:
150
+
151
+ - Native draw.io table cells for sql_table and class shapes.
152
+ - draw.io sketch style for D2 sketch mode.
153
+ - Page links for D2 board links.
154
+ - Icons on regular shapes (currently only `shape: image` carries an image).
155
+
156
+ ## Related projects
157
+
158
+ As of July 2026 this is the only dedicated D2 to draw.io converter I am aware of. Related tools:
159
+
160
+ - [D2](https://github.com/terrastruct/d2): the diagram language itself.
161
+ - [draw.io / diagrams.net](https://github.com/jgraph/drawio): the editor this tool targets.
162
+ - [@whitebite/diagram-converter](https://www.npmjs.com/package/@whitebite/diagram-converter): a universal multi-format diagram converter whose published build includes a D2 parser and drawio generator via a shared intermediate representation. A generalist; this tool goes deeper on D2 fidelity (layout preservation, themes, special shapes, degradation warnings).
163
+ - [mmd2drawio](https://github.com/youyoubilly/mmd2drawio): the same idea for Mermaid.
164
+ - The SVG detour: D2 can export SVG that draw.io imports as a static image. Works, but nothing is editable; that gap is the point of this project (see also [jgraph/drawio#3764](https://github.com/jgraph/drawio/issues/3764), an open request for D2 input in draw.io itself).
165
+
166
+ ## Contributing
167
+
168
+ See [CONTRIBUTING.md](https://github.com/Moawiah188/d2-to-drawio/blob/main/CONTRIBUTING.md). The short version: fixture first, then the mapping code, then a row in the feature matrix. Bug reports with a minimal `.d2` snippet are gold.
169
+
170
+ ## License
171
+
172
+ [MIT](https://github.com/Moawiah188/d2-to-drawio/blob/main/LICENSE). The `@terrastruct/d2` dependency is MPL-2.0, used unmodified.
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ // CLI for d2-to-drawio: convert D2 diagram source to draw.io XML.
3
+
4
+ import { parseArgs } from 'node:util';
5
+ import { readFileSync, writeFileSync } from 'node:fs';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { dirname, join } from 'node:path';
8
+ import process from 'node:process';
9
+ import {
10
+ convert,
11
+ convertFile,
12
+ buildImportFs,
13
+ dispose,
14
+ D2SyntaxError,
15
+ UnsupportedFeatureError,
16
+ } from '../src/index.js';
17
+
18
+ const HELP = `Usage: d2-to-drawio [options] [input.d2]
19
+
20
+ Convert a D2 diagram (d2lang.com) to an editable draw.io / diagrams.net file.
21
+
22
+ Reads from the input file, or from stdin when no file is given (or "-").
23
+ Writes to --output, or to stdout.
24
+
25
+ Options:
26
+ -o, --output <file> write the .drawio XML to a file instead of stdout
27
+ --layout <name> layout engine: dagre (default) or elk
28
+ --theme <id> D2 theme id (default 0)
29
+ --waypoints preserve D2 edge routes as fixed waypoints
30
+ --strict fail when input uses features that would degrade
31
+ -q, --quiet suppress warnings on stderr
32
+ -h, --help show this help
33
+ -v, --version print the version
34
+
35
+ Examples:
36
+ d2-to-drawio diagram.d2 -o diagram.drawio
37
+ cat diagram.d2 | d2-to-drawio > diagram.drawio
38
+ `;
39
+
40
+ function version() {
41
+ const pkg = JSON.parse(
42
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8')
43
+ );
44
+ return pkg.version;
45
+ }
46
+
47
+ function usageError(msg) {
48
+ process.stderr.write(`error: ${msg}\n\n${HELP}`);
49
+ process.exit(2);
50
+ }
51
+
52
+ async function readStdin() {
53
+ const chunks = [];
54
+ for await (const chunk of process.stdin) chunks.push(chunk);
55
+ return Buffer.concat(chunks).toString('utf8');
56
+ }
57
+
58
+ async function main() {
59
+ let args;
60
+ try {
61
+ args = parseArgs({
62
+ allowPositionals: true,
63
+ options: {
64
+ output: { type: 'string', short: 'o' },
65
+ layout: { type: 'string' },
66
+ theme: { type: 'string' },
67
+ waypoints: { type: 'boolean' },
68
+ strict: { type: 'boolean' },
69
+ quiet: { type: 'boolean', short: 'q' },
70
+ help: { type: 'boolean', short: 'h' },
71
+ version: { type: 'boolean', short: 'v' },
72
+ },
73
+ });
74
+ } catch (err) {
75
+ usageError(err.message);
76
+ }
77
+ const { values, positionals } = args;
78
+
79
+ if (values.help) {
80
+ process.stdout.write(HELP);
81
+ return 0;
82
+ }
83
+ if (values.version) {
84
+ process.stdout.write(`${version()}\n`);
85
+ return 0;
86
+ }
87
+ if (positionals.length > 1) usageError('expected at most one input file');
88
+ if (values.layout && values.layout !== 'dagre' && values.layout !== 'elk') {
89
+ usageError(`unknown layout "${values.layout}" (expected dagre or elk)`);
90
+ }
91
+ let themeID;
92
+ if (values.theme !== undefined) {
93
+ themeID = Number(values.theme);
94
+ if (!Number.isInteger(themeID) || themeID < 0) usageError(`invalid theme id "${values.theme}"`);
95
+ }
96
+
97
+ // layout and themeID stay undefined unless given, so an in-file
98
+ // vars.d2-config block keeps control of them.
99
+ const convertOptions = {
100
+ layout: values.layout,
101
+ themeID,
102
+ strict: Boolean(values.strict),
103
+ waypoints: Boolean(values.waypoints),
104
+ onWarning: values.quiet ? undefined : (w) => process.stderr.write(`warning: ${w.message}\n`),
105
+ };
106
+
107
+ const inputPath = positionals[0];
108
+ let xml;
109
+ try {
110
+ if (!inputPath || inputPath === '-') {
111
+ if (process.stdin.isTTY) usageError('no input file given and stdin is a terminal');
112
+ const source = await readStdin();
113
+ // Imports in piped input resolve against the current directory.
114
+ xml = await convert(source, { ...convertOptions, fsMap: buildImportFs(process.cwd()) });
115
+ } else {
116
+ xml = await convertFile(inputPath, convertOptions);
117
+ }
118
+ } catch (err) {
119
+ if (err && (err.code === 'ENOENT' || err.code === 'EACCES' || err.code === 'EISDIR')) {
120
+ process.stderr.write(`error: cannot read "${inputPath}": ${err.message}\n`);
121
+ return 1;
122
+ }
123
+ if (err instanceof D2SyntaxError || err instanceof UnsupportedFeatureError) {
124
+ process.stderr.write(`error: ${err.message}\n`);
125
+ return 1;
126
+ }
127
+ throw err;
128
+ }
129
+
130
+ if (values.output) {
131
+ try {
132
+ writeFileSync(values.output, xml, 'utf8');
133
+ } catch (err) {
134
+ process.stderr.write(`error: cannot write "${values.output}": ${err.message}\n`);
135
+ return 1;
136
+ }
137
+ } else {
138
+ process.stdout.write(xml);
139
+ }
140
+ return 0;
141
+ }
142
+
143
+ main()
144
+ .then(async (code) => {
145
+ await dispose();
146
+ // The engine's worker thread would otherwise keep the process alive.
147
+ process.exit(code);
148
+ })
149
+ .catch(async (err) => {
150
+ process.stderr.write(`error: ${err && err.message ? err.message : err}\n`);
151
+ await dispose();
152
+ process.exit(1);
153
+ });
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "d2-to-drawio",
3
+ "version": "1.0.0",
4
+ "scripts": {
5
+ "test": "node --test test/unit.test.mjs test/snapshot.test.mjs test/cli.test.mjs",
6
+ "snapshots": "node test/run-fixtures.mjs",
7
+ "coverage": "node --test --experimental-test-coverage test/unit.test.mjs test/snapshot.test.mjs test/cli.test.mjs"
8
+ },
9
+ "keywords": [
10
+ "d2",
11
+ "d2lang",
12
+ "drawio",
13
+ "draw.io",
14
+ "diagrams.net",
15
+ "diagram",
16
+ "converter",
17
+ "diagrams-as-code",
18
+ "cli",
19
+ "flowchart",
20
+ "architecture-diagram",
21
+ "text-to-diagram",
22
+ "mxgraph",
23
+ "export"
24
+ ],
25
+ "author": "Moawiah Jamal",
26
+ "license": "MIT",
27
+ "description": "Convert D2 diagrams to editable draw.io / diagrams.net files (.drawio XML) from the CLI or Node.js",
28
+ "type": "module",
29
+ "devDependencies": {
30
+ "fast-xml-parser": "^5.10.0"
31
+ },
32
+ "dependencies": {
33
+ "@terrastruct/d2": "^0.1.33"
34
+ },
35
+ "bin": {
36
+ "d2-to-drawio": "bin/d2-to-drawio.js"
37
+ },
38
+ "engines": {
39
+ "node": ">=22"
40
+ },
41
+ "exports": {
42
+ ".": "./src/index.js",
43
+ "./package.json": "./package.json"
44
+ },
45
+ "homepage": "https://github.com/Moawiah188/d2-to-drawio#readme",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/Moawiah188/d2-to-drawio.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/Moawiah188/d2-to-drawio/issues"
52
+ },
53
+ "files": [
54
+ "src",
55
+ "bin",
56
+ "README.md",
57
+ "LICENSE"
58
+ ]
59
+ }
package/src/compile.js ADDED
@@ -0,0 +1,97 @@
1
+ // Wrapper around the official D2 compiler (@terrastruct/d2, WASM).
2
+ //
3
+ // The engine runs in a worker thread that keeps the Node event loop alive,
4
+ // so this module manages a lazy singleton and exposes dispose() to release
5
+ // it. Library callers must await dispose() (or exit the process) when done.
6
+
7
+ import { D2 } from '@terrastruct/d2';
8
+
9
+ /** Error thrown when the D2 source does not compile. */
10
+ export class D2SyntaxError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = 'D2SyntaxError';
14
+ this.code = 'D2_SYNTAX';
15
+ }
16
+ }
17
+
18
+ let instance = null;
19
+
20
+ function getInstance() {
21
+ if (!instance) instance = new D2();
22
+ return instance;
23
+ }
24
+
25
+ /**
26
+ * The engine reports compile problems as a JSON array of
27
+ * {range, errmsg} objects serialized into the error message. Flatten that
28
+ * into d2's own "file:line:col: description" lines, never a stack trace.
29
+ */
30
+ function formatCompileError(err) {
31
+ const raw = String(err && err.message ? err.message : err).trim();
32
+ try {
33
+ const parsed = JSON.parse(raw);
34
+ if (Array.isArray(parsed)) {
35
+ const msgs = parsed.map((e) => e.errmsg ?? JSON.stringify(e));
36
+ if (msgs.length > 0) return msgs.join('\n');
37
+ }
38
+ } catch {
39
+ // not JSON: fall through to the raw message
40
+ }
41
+ return raw;
42
+ }
43
+
44
+ /**
45
+ * Compile D2 source to the positioned diagram IR.
46
+ *
47
+ * @param {string} source D2 source text
48
+ * @param {object} [options]
49
+ * @param {'dagre'|'elk'} [options.layout] layout engine (default dagre)
50
+ * @param {number} [options.themeID] D2 theme id (default 0)
51
+ * @param {Record<string, string>} [options.fsMap] virtual filesystem for imports:
52
+ * relative path -> file content. Required for sources that use @imports.
53
+ * @param {string} [options.inputPath] entry filename inside fsMap (and the name
54
+ * used in error messages). Defaults to "input.d2".
55
+ * @returns {Promise<object>} the CompileResponse ({ diagram, graph, ... })
56
+ */
57
+ export async function compile(source, options = {}) {
58
+ const d2 = getInstance();
59
+ // Only forward explicitly-set options so an in-file `vars.d2-config`
60
+ // block keeps control of anything the caller leaves unset.
61
+ const compileOptions = {};
62
+ if (options.layout !== undefined) compileOptions.layout = options.layout;
63
+ if (options.themeID !== undefined) compileOptions.themeID = options.themeID;
64
+ if (options.sketch !== undefined) compileOptions.sketch = options.sketch;
65
+ if (options.pad !== undefined) compileOptions.pad = options.pad;
66
+
67
+ const inputPath = options.inputPath ?? 'input.d2';
68
+ const fs = { ...(options.fsMap ?? {}), [inputPath]: source };
69
+
70
+ try {
71
+ return await d2.compile({ fs, inputPath, options: compileOptions });
72
+ } catch (err) {
73
+ throw new D2SyntaxError(formatCompileError(err));
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Release the engine worker so the process can exit naturally.
79
+ * Safe to call multiple times; compile() after dispose() re-creates it.
80
+ */
81
+ export async function dispose() {
82
+ if (!instance) return;
83
+ const d2 = instance;
84
+ instance = null;
85
+ // The published API has no terminate method. The worker handle appears on
86
+ // the instance once init completes, so wait for readiness first: a worker
87
+ // still starting up would otherwise leak and keep the process alive.
88
+ try {
89
+ await d2.ready;
90
+ } catch {
91
+ // init failed: there is no worker to stop
92
+ }
93
+ const worker = d2.worker;
94
+ if (worker && typeof worker.terminate === 'function') {
95
+ await worker.terminate();
96
+ }
97
+ }
package/src/emit.js ADDED
@@ -0,0 +1,128 @@
1
+ // draw.io XML emission.
2
+ //
3
+ // Input is a list of pages, each holding an ordered list of cells (vertices
4
+ // and edges). Output is an uncompressed .drawio mxfile string.
5
+ //
6
+ // Invariants enforced here, per the draw.io format contract:
7
+ // mxfile > diagram > mxGraphModel > root, root cells "0" and "1",
8
+ // every vertex carries mxGeometry with numeric x/y/width/height,
9
+ // cells with a tooltip or link are wrapped in an <object> element that
10
+ // carries the id and label while the inner mxCell carries neither.
11
+ //
12
+ // Attribute order is fixed so identical input produces byte-identical output.
13
+
14
+ /** Escape text for an XML attribute value. */
15
+ export function escAttr(s) {
16
+ return String(s)
17
+ .replace(/&/g, '&amp;')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;')
21
+ .replace(/\r?\n/g, '&#10;');
22
+ }
23
+
24
+ /** Escape text destined for HTML content inside a draw.io html=1 label. */
25
+ export function escHtml(s) {
26
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
27
+ }
28
+
29
+ /**
30
+ * Build a draw.io html=1 label from plain text: HTML-escape it and turn
31
+ * newlines into <br>. draw.io re-parses the cell value as HTML, so raw text
32
+ * must be escaped once here (and once more as an XML attribute at emit).
33
+ */
34
+ export function htmlLabel(s) {
35
+ return escHtml(s).replace(/\r?\n/g, '<br>');
36
+ }
37
+
38
+ /** Format a coordinate: round to 2 decimals, no trailing zeros. */
39
+ export function num(n) {
40
+ return String(Math.round(n * 100) / 100);
41
+ }
42
+
43
+ function vertexXml(cell) {
44
+ // Edge-label children use relative geometry along their parent edge.
45
+ if (cell.edgeLabel) {
46
+ return (
47
+ `<mxCell id="${escAttr(cell.id)}" value="${escAttr(cell.value)}" style="${escAttr(
48
+ cell.style
49
+ )}" vertex="1" connectable="0" parent="${escAttr(cell.parent)}">` +
50
+ `<mxGeometry x="${num(cell.rx)}" relative="1" as="geometry"/></mxCell>`
51
+ );
52
+ }
53
+ const geom = `<mxGeometry x="${num(cell.x)}" y="${num(cell.y)}" width="${num(cell.w)}" height="${num(cell.h)}" as="geometry"/>`;
54
+ const core = (id, value) =>
55
+ `<mxCell${id}${value} style="${escAttr(cell.style)}" vertex="1" parent="${escAttr(cell.parent)}">${geom}</mxCell>`;
56
+
57
+ if (cell.tooltip || cell.link) {
58
+ // Metadata wrapper: id and label move to the <object>; the inner mxCell
59
+ // carries neither (draw.io's own codec does the same id hoisting).
60
+ let attrs = ` label="${escAttr(cell.value)}"`;
61
+ if (cell.tooltip) attrs += ` tooltip="${escAttr(cell.tooltip)}"`;
62
+ if (cell.link) attrs += ` link="${escAttr(cell.link)}"`;
63
+ return `<object${attrs} id="${escAttr(cell.id)}">${core('', '')}</object>`;
64
+ }
65
+ return core(` id="${escAttr(cell.id)}"`, ` value="${escAttr(cell.value)}"`);
66
+ }
67
+
68
+ function edgeXml(cell) {
69
+ // Fixed endpoints (sourcePoint/targetPoint) carry edges that have no cell
70
+ // to anchor to, e.g. sequence-diagram lifelines and messages.
71
+ let inner = '';
72
+ if (cell.sourcePoint) {
73
+ inner += `<mxPoint x="${num(cell.sourcePoint.x)}" y="${num(cell.sourcePoint.y)}" as="sourcePoint"/>`;
74
+ }
75
+ if (cell.targetPoint) {
76
+ inner += `<mxPoint x="${num(cell.targetPoint.x)}" y="${num(cell.targetPoint.y)}" as="targetPoint"/>`;
77
+ }
78
+ if (cell.points && cell.points.length > 0) {
79
+ const pts = cell.points.map((p) => `<mxPoint x="${num(p.x)}" y="${num(p.y)}"/>`).join('');
80
+ inner += `<Array as="points">${pts}</Array>`;
81
+ }
82
+ const geom = inner
83
+ ? `<mxGeometry relative="1" as="geometry">${inner}</mxGeometry>`
84
+ : `<mxGeometry relative="1" as="geometry"/>`;
85
+
86
+ const endpoints =
87
+ (cell.source ? ` source="${escAttr(cell.source)}"` : '') +
88
+ (cell.target ? ` target="${escAttr(cell.target)}"` : '');
89
+ const core = (id, value) =>
90
+ `<mxCell${id}${value} style="${escAttr(cell.style)}" edge="1" parent="${escAttr(
91
+ cell.parent
92
+ )}"${endpoints}>${geom}</mxCell>`;
93
+
94
+ if (cell.tooltip || cell.link) {
95
+ let attrs = ` label="${escAttr(cell.value)}"`;
96
+ if (cell.tooltip) attrs += ` tooltip="${escAttr(cell.tooltip)}"`;
97
+ if (cell.link) attrs += ` link="${escAttr(cell.link)}"`;
98
+ return `<object${attrs} id="${escAttr(cell.id)}">${core('', '')}</object>`;
99
+ }
100
+ return core(` id="${escAttr(cell.id)}"`, ` value="${escAttr(cell.value)}"`);
101
+ }
102
+
103
+ /**
104
+ * Emit a complete uncompressed .drawio file.
105
+ *
106
+ * @param {Array<{name: string, cells: Array<object>, background?: string}>} pages
107
+ * @returns {string}
108
+ */
109
+ export function emit(pages) {
110
+ const diagrams = pages.map((page, i) => {
111
+ const cells = page.cells
112
+ .map((c) => (c.edge ? edgeXml(c) : vertexXml(c)))
113
+ .join('');
114
+ const background =
115
+ page.background && page.background !== 'none'
116
+ ? ` background="${escAttr(page.background)}"`
117
+ : '';
118
+ const math = page.math ? '1' : '0';
119
+ return (
120
+ `<diagram id="page-${i + 1}" name="${escAttr(page.name)}">` +
121
+ `<mxGraphModel dx="800" dy="600" grid="0" gridSize="10" guides="1" tooltips="1" connect="1" ` +
122
+ `arrows="1" fold="1" page="0" pageScale="1" math="${math}" shadow="0"${background}>` +
123
+ `<root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root>` +
124
+ `</mxGraphModel></diagram>`
125
+ );
126
+ });
127
+ return `<mxfile host="app.diagrams.net" type="device">${diagrams.join('')}</mxfile>\n`;
128
+ }
package/src/ids.js ADDED
@@ -0,0 +1,21 @@
1
+ // Deterministic draw.io cell id allocation.
2
+ //
3
+ // Ids derive from the D2 key: readable, stable across runs, deduplicated
4
+ // with numeric suffixes. "0" and "1" are reserved for the draw.io root cells.
5
+
6
+ export function createIdAllocator() {
7
+ const used = new Set(['0', '1']);
8
+ const byKey = new Map();
9
+
10
+ return function idFor(key) {
11
+ if (byKey.has(key)) return byKey.get(key);
12
+ let base = String(key).replace(/[^A-Za-z0-9_]/g, '_');
13
+ if (!base || /^\d+$/.test(base)) base = `n_${base}`;
14
+ let id = base;
15
+ let n = 2;
16
+ while (used.has(id)) id = `${base}_${n++}`;
17
+ used.add(id);
18
+ byKey.set(key, id);
19
+ return id;
20
+ };
21
+ }