@pfdsl/graphviz-exporter 0.0.1

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 takasek
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,24 @@
1
+ # @pfdsl/graphviz-exporter
2
+
3
+ Renders [PFDSL](https://github.com/takasek/pfdsl) graphs to DOT / SVG / PDF / PNG.
4
+
5
+ ## Requirements
6
+
7
+ Node.js ≥ 18 (ESM only).
8
+
9
+ ## API
10
+
11
+ ```ts
12
+ import { exportDot, exportDiffDot, svgToBinary } from "@pfdsl/graphviz-exporter";
13
+
14
+ // Render a graph as a DOT string
15
+ const dot = exportDot(graph, frontmatter, options);
16
+
17
+ // Render a diff as a DOT string
18
+ const dot = exportDiffDot(graph, frontmatter, options);
19
+
20
+ // Convert an SVG string to PDF or PNG (requires @hpcc-js/wasm)
21
+ const pdf = await svgToBinary(svgString, "pdf");
22
+ ```
23
+
24
+ See [`ExportOptions`](src/index.ts) for available options.
@@ -0,0 +1,16 @@
1
+ import { Graph, Frontmatter } from '@pfdsl/core';
2
+
3
+ interface ExportOptions {
4
+ /** Override rankdir; defaults to frontmatter.layout.direction or 'LR'. */
5
+ rankdir?: "LR" | "RL" | "TB" | "BT";
6
+ /** Color for feedback edges. Default '#888888'. */
7
+ feedbackColor?: string;
8
+ /** Title for the graph; defaults to frontmatter.title. */
9
+ graphLabel?: string;
10
+ }
11
+ declare function exportDot(graph: Graph, frontmatter?: Frontmatter | null, options?: ExportOptions): string;
12
+ declare function exportDiffDot(a: Graph, fmA: Frontmatter | null, b: Graph, fmB: Frontmatter | null, options?: ExportOptions): string;
13
+ type BinaryFormat = "pdf" | "png";
14
+ declare function svgToBinary(svg: string, format: BinaryFormat): Promise<Buffer>;
15
+
16
+ export { type BinaryFormat, type ExportOptions, exportDiffDot, exportDot, svgToBinary };
package/dist/index.js ADDED
@@ -0,0 +1,629 @@
1
+ // src/index.ts
2
+ import { diffGraphs, resolveMeta as resolveMeta2 } from "@pfdsl/core";
3
+
4
+ // src/label.ts
5
+ var MIN_WRAP_RATIO = 0.3;
6
+ var LINE_HEAD_FORBIDDEN = /[、。,.)}\]」』】!?!?]/;
7
+ var LINE_END_FORBIDDEN = /[({[「『【]/;
8
+ var BREAK_CHARS = /[、。,.,.\s()()「」『』【】[\]=]/;
9
+ var FONT_SIZE = 14;
10
+ var CHAR_EM = {
11
+ " ": 250,
12
+ "!": 333,
13
+ '"': 408,
14
+ "#": 500,
15
+ $: 500,
16
+ "%": 833,
17
+ "&": 778,
18
+ "'": 180,
19
+ "(": 333,
20
+ ")": 333,
21
+ "*": 500,
22
+ "+": 564,
23
+ ",": 250,
24
+ "-": 333,
25
+ ".": 250,
26
+ "/": 278,
27
+ "0": 500,
28
+ "1": 500,
29
+ "2": 500,
30
+ "3": 500,
31
+ "4": 500,
32
+ "5": 500,
33
+ "6": 500,
34
+ "7": 500,
35
+ "8": 500,
36
+ "9": 500,
37
+ ":": 278,
38
+ ";": 278,
39
+ "<": 564,
40
+ "=": 564,
41
+ ">": 564,
42
+ "?": 444,
43
+ "@": 921,
44
+ A: 722,
45
+ B: 667,
46
+ C: 667,
47
+ D: 722,
48
+ E: 611,
49
+ F: 556,
50
+ G: 722,
51
+ H: 722,
52
+ I: 333,
53
+ J: 389,
54
+ K: 722,
55
+ L: 611,
56
+ M: 889,
57
+ N: 722,
58
+ O: 722,
59
+ P: 556,
60
+ Q: 722,
61
+ R: 667,
62
+ S: 556,
63
+ T: 611,
64
+ U: 722,
65
+ V: 722,
66
+ W: 944,
67
+ X: 722,
68
+ Y: 722,
69
+ Z: 611,
70
+ "[": 333,
71
+ "\\": 278,
72
+ "]": 333,
73
+ "^": 469,
74
+ _: 500,
75
+ "`": 333,
76
+ a: 444,
77
+ b: 500,
78
+ c: 444,
79
+ d: 500,
80
+ e: 444,
81
+ f: 333,
82
+ g: 500,
83
+ h: 500,
84
+ i: 278,
85
+ j: 278,
86
+ k: 500,
87
+ l: 278,
88
+ m: 778,
89
+ n: 500,
90
+ o: 500,
91
+ p: 500,
92
+ q: 500,
93
+ r: 333,
94
+ s: 389,
95
+ t: 278,
96
+ u: 500,
97
+ v: 500,
98
+ w: 722,
99
+ x: 500,
100
+ y: 500,
101
+ z: 444,
102
+ "{": 480,
103
+ "|": 200,
104
+ "}": 480,
105
+ "~": 541
106
+ };
107
+ function measureTextWidth(text) {
108
+ let w = 0;
109
+ for (const ch of text) {
110
+ const cp = ch.codePointAt(0) ?? 0;
111
+ if (cp >= 12352 && cp <= 12447 || // hiragana
112
+ cp >= 12448 && cp <= 12543 || // katakana
113
+ cp >= 19968 && cp <= 40959 || // CJK unified
114
+ cp >= 63744 && cp <= 64255 || // CJK compatibility
115
+ cp >= 65280 && cp <= 65519) {
116
+ w += FONT_SIZE;
117
+ } else {
118
+ w += (CHAR_EM[ch] ?? 500) / 1e3 * FONT_SIZE;
119
+ }
120
+ }
121
+ return w;
122
+ }
123
+ function wrapLabel(text, maxWidthPx) {
124
+ if (measureTextWidth(text) <= maxWidthPx) return text;
125
+ const lines = [];
126
+ let currentLine = "";
127
+ for (let i = 0; i < text.length; i++) {
128
+ const char = text[i];
129
+ const testLine = currentLine + char;
130
+ if (measureTextWidth(testLine) > maxWidthPx && currentLine.length > 0) {
131
+ let breakIndex = -1;
132
+ if (!BREAK_CHARS.test(char)) {
133
+ for (let j = currentLine.length - 1; j >= 0; j--) {
134
+ const breakChar = currentLine[j];
135
+ if (BREAK_CHARS.test(breakChar)) {
136
+ if (LINE_END_FORBIDDEN.test(breakChar)) continue;
137
+ const widthToBreak = measureTextWidth(
138
+ currentLine.substring(0, j + 1)
139
+ );
140
+ if (widthToBreak > maxWidthPx * MIN_WRAP_RATIO) {
141
+ breakIndex = j;
142
+ break;
143
+ }
144
+ }
145
+ }
146
+ }
147
+ if (breakIndex >= 0) {
148
+ const breakChar = currentLine[breakIndex];
149
+ if (LINE_HEAD_FORBIDDEN.test(breakChar)) {
150
+ lines.push(currentLine.substring(0, breakIndex + 1));
151
+ currentLine = currentLine.substring(breakIndex + 1) + char;
152
+ } else {
153
+ lines.push(currentLine.substring(0, breakIndex));
154
+ currentLine = currentLine.substring(breakIndex + 1) + char;
155
+ }
156
+ } else {
157
+ lines.push(currentLine);
158
+ currentLine = char;
159
+ }
160
+ } else {
161
+ currentLine = testLine;
162
+ }
163
+ }
164
+ if (currentLine) lines.push(currentLine);
165
+ return lines.join("\n");
166
+ }
167
+
168
+ // src/node-attrs.ts
169
+ import { resolveMeta, STYLE_ATTRS } from "@pfdsl/core";
170
+ var DEFAULT_FEEDBACK_COLOR = "#888888";
171
+ var QUOTE_BACKSLASH_RE = /\\/g;
172
+ var QUOTE_DQUOTE_RE = /"/g;
173
+ var QUOTE_NEWLINE_RE = /\n/g;
174
+ var CJK_RE = /[ -鿿豈-﫿!-⦆]/u;
175
+ function quote(s) {
176
+ return '"' + s.replace(QUOTE_BACKSLASH_RE, "\\\\").replace(QUOTE_DQUOTE_RE, '\\"').replace(QUOTE_NEWLINE_RE, "\\n") + '"';
177
+ }
178
+ function calcMinWidth(label) {
179
+ if (!CJK_RE.test(label)) return void 0;
180
+ let maxUnits = 0;
181
+ for (const line of label.split("\n")) {
182
+ let units = 0;
183
+ for (const ch of line) {
184
+ const cp = ch.codePointAt(0) ?? 0;
185
+ const isCjk = cp >= 12288 && cp <= 40959 || cp >= 63744 && cp <= 64255 || cp >= 65281 && cp <= 65376;
186
+ units += isCjk ? 2 : 1;
187
+ }
188
+ maxUnits = Math.max(maxUnits, units);
189
+ }
190
+ return Math.max(0.75, maxUnits * 0.1 + 0.3);
191
+ }
192
+ function darkenHex(color, factor = 0.7) {
193
+ const m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(color);
194
+ if (m6) {
195
+ const r = Math.round(parseInt(m6[1], 16) * factor);
196
+ const g = Math.round(parseInt(m6[2], 16) * factor);
197
+ const b = Math.round(parseInt(m6[3], 16) * factor);
198
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
199
+ }
200
+ const m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(color);
201
+ if (m3) {
202
+ const r = Math.round(parseInt(m3[1] + m3[1], 16) * factor);
203
+ const g = Math.round(parseInt(m3[2] + m3[2], 16) * factor);
204
+ const b = Math.round(parseInt(m3[3] + m3[3], 16) * factor);
205
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
206
+ }
207
+ return void 0;
208
+ }
209
+ function buildXlabel(id, kind, fm) {
210
+ if (!fm) return void 0;
211
+ const parts = [];
212
+ if (kind === "artifact") {
213
+ const meta = fm.artifact?.[id];
214
+ if (meta?.status) parts.push(meta.status);
215
+ for (const tag of meta?.tags ?? []) parts.push(tag);
216
+ } else if (kind === "process") {
217
+ const meta = fm.process?.[id];
218
+ for (const tag of meta?.tags ?? []) parts.push(tag);
219
+ }
220
+ return parts.length > 0 ? parts.join(", ") : void 0;
221
+ }
222
+ function resolveStyleAttrs(id, kind, fm) {
223
+ if (!fm) return {};
224
+ if (kind === "group") return {};
225
+ const meta = resolveMeta(fm, kind, id);
226
+ const styleAttrs = {};
227
+ const tags = meta?.tags ?? [];
228
+ for (let i = tags.length - 1; i >= 0; i--) {
229
+ const tag = tags[i];
230
+ if (tag !== void 0)
231
+ Object.assign(styleAttrs, fm.tag?.[tag]?.style ?? {});
232
+ }
233
+ if (kind === "artifact") {
234
+ const status = meta?.status;
235
+ if (status) Object.assign(styleAttrs, fm.statusStyles?.[status] ?? {});
236
+ }
237
+ return styleAttrs;
238
+ }
239
+ function nodeAttrs(id, kind, fm, boundaryArtifacts = /* @__PURE__ */ new Set()) {
240
+ const shape = kind === "process" ? "ellipse" : "box";
241
+ const meta = resolveMeta(fm, kind, id);
242
+ const nodeLabel = meta?.label;
243
+ const description = meta?.description;
244
+ const ameta = kind === "artifact" ? meta : void 0;
245
+ const criteria = ameta?.criteria;
246
+ const locationRaw = ameta?.location;
247
+ const locationArray = typeof locationRaw === "string" ? [locationRaw] : Array.isArray(locationRaw) && locationRaw.length > 0 ? locationRaw : void 0;
248
+ const location = locationArray?.join(", ");
249
+ const revises = ameta?.revises;
250
+ const maxWidth = typeof fm?.layout?.maxWidth === "number" ? fm.layout.maxWidth : void 0;
251
+ const wrappedNodeLabel = nodeLabel && maxWidth ? wrapLabel(nodeLabel, maxWidth) : nodeLabel;
252
+ const label = wrappedNodeLabel ? `${id}
253
+ ${wrappedNodeLabel}` : id;
254
+ const wrappingOccurred = wrappedNodeLabel !== nodeLabel;
255
+ const originalLabel = nodeLabel ?? id;
256
+ const tooltipParts = [originalLabel];
257
+ if (description) tooltipParts.push(`
258
+
259
+ ${description}`);
260
+ const KNOWN_TOOLTIP_SKIP = /* @__PURE__ */ new Set([
261
+ "label",
262
+ // shown as node label
263
+ "description",
264
+ // rendered first with double newline
265
+ "status",
266
+ // shown as node color and xlabel
267
+ "tags",
268
+ // shown as xlabel
269
+ "group",
270
+ // shown as cluster border
271
+ "parts",
272
+ // structural — child nodes are visible in graph
273
+ "location",
274
+ // appended last with dedicated formatting
275
+ "boundary"
276
+ // subflow id remapping — not human-readable as-is
277
+ ]);
278
+ const knownFields = [];
279
+ if (criteria) knownFields.push(["criteria", criteria]);
280
+ if (revises) knownFields.push(["revises", revises]);
281
+ if (typeof meta?.owner === "string") knownFields.push(["owner", meta.owner]);
282
+ if (typeof meta?.command === "string")
283
+ knownFields.push(["command", meta.command]);
284
+ if (typeof meta?.subflow === "string")
285
+ knownFields.push(["subflow", meta.subflow]);
286
+ const extraFields = meta ? Object.entries(meta).filter(([k, v]) => {
287
+ if (KNOWN_TOOLTIP_SKIP.has(k)) return false;
288
+ if (knownFields.some(([kf]) => kf === k)) return false;
289
+ if (typeof v === "string") return true;
290
+ if (Array.isArray(v) && v.length > 0 && v.every((i) => typeof i === "string"))
291
+ return true;
292
+ return false;
293
+ }).map(([k, v]) => [
294
+ k,
295
+ Array.isArray(v) ? v.join(", ") : v
296
+ ]) : [];
297
+ for (const [key, val] of [...knownFields, ...extraFields]) {
298
+ const formatted = val.includes("\n") ? `
299
+ ${key}:
300
+ ${val.split("\n").map((l) => ` ${l}`).join("\n")}` : `
301
+ ${key}: ${val}`;
302
+ tooltipParts.push(formatted);
303
+ }
304
+ if (location) tooltipParts.push(`
305
+ location: ${location}`);
306
+ const tooltip = tooltipParts.length > 1 ? tooltipParts.join("") : wrappingOccurred ? originalLabel : void 0;
307
+ const styleAttrs = resolveStyleAttrs(id, kind, fm);
308
+ const xlabel = buildXlabel(id, kind, fm);
309
+ const firstLoc = locationArray?.[0];
310
+ const singleUrl = locationArray?.length === 1 && firstLoc?.includes("://") ? firstLoc : void 0;
311
+ const minWidth = calcMinWidth(label);
312
+ const attrs = [`shape=${shape}`, `label=${quote(label)}`];
313
+ if (tooltip !== void 0) attrs.push(`tooltip=${quote(tooltip)}`);
314
+ if (singleUrl) attrs.push(`href=${quote(singleUrl)}`);
315
+ if (minWidth !== void 0) attrs.push(`width=${minWidth.toFixed(2)}`);
316
+ if (xlabel !== void 0) attrs.push(`xlabel=${quote(xlabel)}`);
317
+ for (const key of STYLE_ATTRS) {
318
+ const v = styleAttrs[key];
319
+ if (v !== void 0) attrs.push(`${key}=${quote(v)}`);
320
+ }
321
+ if (kind === "artifact" && boundaryArtifacts.has(id) && styleAttrs.penwidth === void 0) {
322
+ attrs.push(`penwidth="2"`);
323
+ }
324
+ if (kind === "process" && typeof meta?.subflow === "string") {
325
+ attrs.push(`peripheries="2"`);
326
+ }
327
+ return `[${attrs.join(", ")}]`;
328
+ }
329
+
330
+ // src/index.ts
331
+ function exportDot(graph, frontmatter = null, options = {}) {
332
+ const rankdir = options.rankdir ?? frontmatter?.layout?.direction ?? "LR";
333
+ const feedbackColor = options.feedbackColor ?? DEFAULT_FEEDBACK_COLOR;
334
+ const graphLabel = options.graphLabel ?? frontmatter?.title;
335
+ const lines = [];
336
+ lines.push("digraph PFDSL {");
337
+ lines.push(` rankdir=${rankdir};`);
338
+ lines.push(" newrank=true;");
339
+ if (graphLabel !== void 0) {
340
+ lines.push(` label=${quote(String(graphLabel))};`);
341
+ lines.push(' labelloc="t";');
342
+ }
343
+ lines.push("");
344
+ const nodeGroup = /* @__PURE__ */ new Map();
345
+ if (frontmatter?.group) {
346
+ for (const [id, meta] of Object.entries(frontmatter.artifact ?? {})) {
347
+ if (meta.group !== void 0) nodeGroup.set(id, meta.group);
348
+ }
349
+ for (const [id, meta] of Object.entries(frontmatter.process ?? {})) {
350
+ if (meta.group !== void 0) nodeGroup.set(id, meta.group);
351
+ }
352
+ }
353
+ const hasIncoming = /* @__PURE__ */ new Set();
354
+ const hasOutgoing = /* @__PURE__ */ new Set();
355
+ for (const e of graph.primaryEdges) {
356
+ hasIncoming.add(e.to);
357
+ hasOutgoing.add(e.from);
358
+ }
359
+ const boundaryArtifacts = /* @__PURE__ */ new Set();
360
+ for (const [id, kind] of graph.nodes) {
361
+ if (kind === "artifact" && (!hasIncoming.has(id) || !hasOutgoing.has(id))) {
362
+ boundaryArtifacts.add(id);
363
+ }
364
+ }
365
+ const nodeIds = [...graph.nodes.keys()].sort();
366
+ const groupedNodes = /* @__PURE__ */ new Map();
367
+ const ungroupedIds = [];
368
+ for (const id of nodeIds) {
369
+ if (graph.nodes.get(id) === "group") continue;
370
+ const gid = nodeGroup.get(id);
371
+ if (gid !== void 0 && frontmatter?.group?.[gid] !== void 0) {
372
+ if (!groupedNodes.has(gid)) groupedNodes.set(gid, []);
373
+ groupedNodes.get(gid).push(id);
374
+ } else {
375
+ ungroupedIds.push(id);
376
+ }
377
+ }
378
+ const groupDefs = frontmatter?.group ?? {};
379
+ const groupChildren = /* @__PURE__ */ new Map();
380
+ const rootGroups = [];
381
+ for (const gid of Object.keys(groupDefs).sort()) {
382
+ const parentId = groupDefs[gid]?.parent;
383
+ if (parentId !== void 0 && groupDefs[parentId] !== void 0) {
384
+ if (!groupChildren.has(parentId)) groupChildren.set(parentId, []);
385
+ groupChildren.get(parentId).push(gid);
386
+ } else {
387
+ rootGroups.push(gid);
388
+ }
389
+ }
390
+ function emitGroupBlock(gid, indent) {
391
+ const gm = groupDefs[gid];
392
+ const inner = `${indent} `;
393
+ lines.push(`${indent}subgraph cluster_${gid} {`);
394
+ if (gm.label !== void 0)
395
+ lines.push(`${inner}label=${quote(String(gm.label))};`);
396
+ if (gm.color !== void 0) {
397
+ const fillColor = String(gm.color);
398
+ const strokeColor = darkenHex(fillColor) ?? fillColor;
399
+ lines.push(`${inner}color=${quote(strokeColor)};`);
400
+ lines.push(`${inner}style="filled";`);
401
+ lines.push(`${inner}fillcolor=${quote(fillColor)};`);
402
+ }
403
+ for (const childGid of (groupChildren.get(gid) ?? []).sort()) {
404
+ emitGroupBlock(childGid, inner);
405
+ }
406
+ for (const id of groupedNodes.get(gid) ?? []) {
407
+ lines.push(
408
+ `${inner}${quote(id)} ${nodeAttrs(id, graph.nodes.get(id), frontmatter, boundaryArtifacts)};`
409
+ );
410
+ }
411
+ lines.push(`${indent}}`);
412
+ }
413
+ for (const gid of rootGroups) {
414
+ if (groupedNodes.has(gid) || (groupChildren.get(gid)?.length ?? 0) > 0) {
415
+ emitGroupBlock(gid, " ");
416
+ }
417
+ }
418
+ for (const id of ungroupedIds) {
419
+ const kind = graph.nodes.get(id);
420
+ lines.push(
421
+ ` ${quote(id)} ${nodeAttrs(id, kind, frontmatter, boundaryArtifacts)};`
422
+ );
423
+ }
424
+ if (graph.primaryEdges.length > 0 || graph.feedbackEdges.length > 0) {
425
+ lines.push("");
426
+ }
427
+ for (const e of graph.primaryEdges) {
428
+ lines.push(` ${quote(e.from)} -> ${quote(e.to)};`);
429
+ }
430
+ for (const e of graph.feedbackEdges) {
431
+ lines.push(
432
+ ` ${quote(e.artifact)} -> ${quote(e.process)} [style=dashed, color=${quote(feedbackColor)}, constraint=false];`
433
+ );
434
+ }
435
+ lines.push("}");
436
+ return `${lines.join("\n")}
437
+ `;
438
+ }
439
+ function exportDiffDot(a, fmA, b, fmB, options = {}) {
440
+ const report = diffGraphs(a, b, fmA, fmB);
441
+ const added = new Set(report.addedNodes);
442
+ const removed = new Set(report.removedNodes);
443
+ const changed = new Set(report.changedNodes);
444
+ const primaryEdgeMap = /* @__PURE__ */ new Map();
445
+ for (const e of a.primaryEdges) {
446
+ const key = `${e.from} -> ${e.to}`;
447
+ if (!primaryEdgeMap.has(key))
448
+ primaryEdgeMap.set(key, { from: e.from, to: e.to, status: "unchanged" });
449
+ }
450
+ for (const e of b.primaryEdges) {
451
+ const key = `${e.from} -> ${e.to}`;
452
+ if (!primaryEdgeMap.has(key))
453
+ primaryEdgeMap.set(key, { from: e.from, to: e.to, status: "unchanged" });
454
+ }
455
+ const addedEdgesSet = new Set(report.addedEdges);
456
+ const removedEdgesSet = new Set(report.removedEdges);
457
+ for (const [key, val] of primaryEdgeMap) {
458
+ if (addedEdgesSet.has(key)) val.status = "added";
459
+ else if (removedEdgesSet.has(key)) val.status = "removed";
460
+ }
461
+ const feedbackEdgeMap = /* @__PURE__ */ new Map();
462
+ for (const e of a.feedbackEdges) {
463
+ const key = `${e.artifact} -> ${e.process}`;
464
+ if (!feedbackEdgeMap.has(key))
465
+ feedbackEdgeMap.set(key, {
466
+ artifact: e.artifact,
467
+ process: e.process,
468
+ status: "unchanged"
469
+ });
470
+ }
471
+ for (const e of b.feedbackEdges) {
472
+ const key = `${e.artifact} -> ${e.process}`;
473
+ if (!feedbackEdgeMap.has(key))
474
+ feedbackEdgeMap.set(key, {
475
+ artifact: e.artifact,
476
+ process: e.process,
477
+ status: "unchanged"
478
+ });
479
+ }
480
+ const addedFeedbackSet = new Set(report.addedFeedback);
481
+ const removedFeedbackSet = new Set(report.removedFeedback);
482
+ for (const [key, val] of feedbackEdgeMap) {
483
+ if (addedFeedbackSet.has(key)) val.status = "added";
484
+ else if (removedFeedbackSet.has(key)) val.status = "removed";
485
+ }
486
+ const visibleNodes = /* @__PURE__ */ new Set([...added, ...removed, ...changed]);
487
+ for (const [, val] of primaryEdgeMap) {
488
+ if (val.status === "added" || val.status === "removed") {
489
+ visibleNodes.add(val.from);
490
+ visibleNodes.add(val.to);
491
+ }
492
+ }
493
+ for (const [, val] of feedbackEdgeMap) {
494
+ if (val.status === "added" || val.status === "removed") {
495
+ visibleNodes.add(val.artifact);
496
+ visibleNodes.add(val.process);
497
+ }
498
+ }
499
+ const visiblePrimaryEdges = [...primaryEdgeMap.entries()].filter(([, val]) => val.status === "added" || val.status === "removed").sort(([a2], [b2]) => a2.localeCompare(b2));
500
+ const visibleFeedbackEdges = [...feedbackEdgeMap.entries()].filter(([, val]) => val.status === "added" || val.status === "removed").sort(([a2], [b2]) => a2.localeCompare(b2));
501
+ const rankdir = options.rankdir ?? fmB?.layout?.direction ?? fmA?.layout?.direction ?? "LR";
502
+ const title = options.graphLabel ?? fmB?.title;
503
+ const graphLabel = title ? `${title} \u2014 diff` : "diff";
504
+ const legend = "green = added \xB7 red = removed \xB7 yellow = changed";
505
+ const fullLabel = `${graphLabel}
506
+ ${legend}`;
507
+ const lines = [];
508
+ lines.push("digraph PFDSL {");
509
+ lines.push(` rankdir=${rankdir};`);
510
+ lines.push(" newrank=true;");
511
+ lines.push(` label=${quote(fullLabel)};`);
512
+ lines.push(' labelloc="t";');
513
+ lines.push("");
514
+ if (added.size === 0 && removed.size === 0 && changed.size === 0) {
515
+ lines.push(
516
+ ' "_nodiff" [shape=note, label="No structural or metadata changes"];'
517
+ );
518
+ lines.push("}");
519
+ return `${lines.join("\n")}
520
+ `;
521
+ }
522
+ const maxWidth = typeof fmB?.layout?.maxWidth === "number" ? fmB.layout.maxWidth : typeof fmA?.layout?.maxWidth === "number" ? fmA.layout.maxWidth : void 0;
523
+ for (const id of [...visibleNodes].sort()) {
524
+ const kind = b.nodes.get(id) ?? a.nodes.get(id);
525
+ if (kind === void 0) continue;
526
+ const fm = removed.has(id) ? fmA : fmB;
527
+ const meta = resolveMeta2(fm, kind, id);
528
+ const nodeLabel = meta?.label;
529
+ const wrappedLabel = nodeLabel !== void 0 && maxWidth !== void 0 ? wrapLabel(nodeLabel, maxWidth) : nodeLabel;
530
+ const label = wrappedLabel ? `${id}
531
+ ${wrappedLabel}` : id;
532
+ const shape = kind === "process" ? "ellipse" : "box";
533
+ const minWidth = calcMinWidth(label);
534
+ let styleAttrs;
535
+ if (added.has(id)) {
536
+ styleAttrs = 'style="filled", fillcolor="#c3e6cb", color="#28a745"';
537
+ } else if (removed.has(id)) {
538
+ styleAttrs = 'style="filled", fillcolor="#f5c6cb", color="#dc3545"';
539
+ } else if (changed.has(id)) {
540
+ styleAttrs = 'style="filled", fillcolor="#ffeeba", color="#e0a800"';
541
+ } else {
542
+ styleAttrs = 'style="filled", fillcolor="#f5f5f5", color="#bbbbbb", fontcolor="#777777"';
543
+ }
544
+ const attrs = [`shape=${shape}`, `label=${quote(label)}`];
545
+ if (minWidth !== void 0) attrs.push(`width=${minWidth.toFixed(2)}`);
546
+ attrs.push(styleAttrs);
547
+ lines.push(` ${quote(id)} [${attrs.join(", ")}];`);
548
+ }
549
+ if (visiblePrimaryEdges.length > 0) {
550
+ lines.push("");
551
+ for (const [, val] of visiblePrimaryEdges) {
552
+ if (val.status === "added") {
553
+ lines.push(
554
+ ` ${quote(val.from)} -> ${quote(val.to)} [color="#28a745"];`
555
+ );
556
+ } else {
557
+ lines.push(
558
+ ` ${quote(val.from)} -> ${quote(val.to)} [color="#dc3545", style=dashed];`
559
+ );
560
+ }
561
+ }
562
+ }
563
+ if (visibleFeedbackEdges.length > 0) {
564
+ if (visiblePrimaryEdges.length === 0) lines.push("");
565
+ for (const [, val] of visibleFeedbackEdges) {
566
+ if (val.status === "added") {
567
+ lines.push(
568
+ ` ${quote(val.artifact)} -> ${quote(val.process)} [style=dashed, color="#28a745", constraint=false];`
569
+ );
570
+ } else {
571
+ lines.push(
572
+ ` ${quote(val.artifact)} -> ${quote(val.process)} [style=dashed, color="#dc3545", constraint=false];`
573
+ );
574
+ }
575
+ }
576
+ }
577
+ lines.push("}");
578
+ return `${lines.join("\n")}
579
+ `;
580
+ }
581
+ async function svgToBinary(svg, format) {
582
+ const puppeteer = await import("puppeteer").catch(() => {
583
+ throw new Error(
584
+ `PDF/PNG export requires puppeteer. Install it with:
585
+ npm install puppeteer`
586
+ );
587
+ });
588
+ const viewBoxMatch = svg.match(/viewBox="([^"]+)"/);
589
+ const parts = viewBoxMatch?.[1]?.split(/\s+/).map(Number);
590
+ const width = parts?.[2] ?? 1200;
591
+ const height = parts?.[3] ?? 800;
592
+ const sandboxArgs = process.platform === "linux" ? ["--no-sandbox", "--disable-setuid-sandbox"] : [];
593
+ const browser = await puppeteer.default.launch({
594
+ headless: true,
595
+ args: sandboxArgs
596
+ });
597
+ try {
598
+ const page = await browser.newPage();
599
+ await page.setContent(
600
+ `<!DOCTYPE html><html><head><style>*{margin:0;padding:0;box-sizing:border-box}html,body{width:${width}px;height:${height}px;overflow:hidden}svg{display:block;width:${width}px;height:${height}px}</style></head><body>${svg}</body></html>`,
601
+ { waitUntil: "load" }
602
+ );
603
+ if (format === "pdf") {
604
+ return await page.pdf({
605
+ width: `${width}px`,
606
+ height: `${height}px`,
607
+ printBackground: true,
608
+ margin: { top: 0, right: 0, bottom: 0, left: 0 },
609
+ pageRanges: "1"
610
+ });
611
+ }
612
+ await page.setViewport({
613
+ width: Math.ceil(width),
614
+ height: Math.ceil(height),
615
+ deviceScaleFactor: 1
616
+ });
617
+ return await page.screenshot({ type: "png" });
618
+ } finally {
619
+ try {
620
+ await browser.close();
621
+ } catch {
622
+ }
623
+ }
624
+ }
625
+ export {
626
+ exportDiffDot,
627
+ exportDot,
628
+ svgToBinary
629
+ };
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@pfdsl/graphviz-exporter",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "exports": {
8
+ ".": {
9
+ "import": "./dist/index.js",
10
+ "types": "./dist/index.d.ts"
11
+ }
12
+ },
13
+ "dependencies": {
14
+ "@pfdsl/core": "0.0.1"
15
+ },
16
+ "peerDependencies": {
17
+ "puppeteer": ">=20.0.0"
18
+ },
19
+ "peerDependenciesMeta": {
20
+ "puppeteer": {
21
+ "optional": true
22
+ }
23
+ },
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^20.0.0",
35
+ "tsup": "^8.0.0",
36
+ "vitest": "^1.6.0"
37
+ },
38
+ "scripts": {
39
+ "build": "tsup src/index.ts --format esm --dts",
40
+ "test": "vitest run",
41
+ "typecheck": "tsc --noEmit"
42
+ }
43
+ }