@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 +21 -0
- package/README.md +24 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +629 -0
- package/package.json +43 -0
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|