@knowvah/dot-engine 1.1.1 → 1.2.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.
Files changed (61) hide show
  1. package/README.md +32 -17
  2. package/dist/api.js +24 -0
  3. package/dist/api.js.map +2 -2
  4. package/dist/common/arrows.d.ts.map +1 -1
  5. package/dist/common/edge-label-init.d.ts.map +1 -1
  6. package/dist/common/htmltable-emit.d.ts +0 -8
  7. package/dist/common/htmltable-emit.d.ts.map +1 -1
  8. package/dist/common/ps-fontalias.d.ts +22 -9
  9. package/dist/common/ps-fontalias.d.ts.map +1 -1
  10. package/dist/common/splines-clip.d.ts.map +1 -1
  11. package/dist/gvc/device.d.ts.map +1 -1
  12. package/dist/gvc/job.d.ts +4 -0
  13. package/dist/gvc/job.d.ts.map +1 -1
  14. package/dist/index.js +1981 -1533
  15. package/dist/index.js.map +4 -4
  16. package/dist/layout/dot/index.d.ts +1 -1
  17. package/dist/layout/dot/index.d.ts.map +1 -1
  18. package/dist/layout/sfdp/init.d.ts.map +1 -1
  19. package/dist/model/graph.d.ts +24 -0
  20. package/dist/model/graph.d.ts.map +1 -1
  21. package/dist/parser/builder.d.ts +32 -0
  22. package/dist/parser/builder.d.ts.map +1 -1
  23. package/dist/render/dot/agwrite.d.ts +186 -0
  24. package/dist/render/dot/agwrite.d.ts.map +1 -0
  25. package/dist/render/dot/attrs.d.ts +220 -0
  26. package/dist/render/dot/attrs.d.ts.map +1 -0
  27. package/dist/render/dot/edge-draw.d.ts +60 -0
  28. package/dist/render/dot/edge-draw.d.ts.map +1 -0
  29. package/dist/render/dot/types.d.ts +28 -0
  30. package/dist/render/dot/types.d.ts.map +1 -0
  31. package/dist/render/dot/xdot-ops.d.ts +206 -0
  32. package/dist/render/dot/xdot-ops.d.ts.map +1 -0
  33. package/dist/render/dot.d.ts +58 -277
  34. package/dist/render/dot.d.ts.map +1 -1
  35. package/dist/render/map.d.ts +14 -1
  36. package/dist/render/map.d.ts.map +1 -1
  37. package/dist/render/svg-graph.d.ts.map +1 -1
  38. package/dist/render.js +1942 -1494
  39. package/dist/render.js.map +4 -4
  40. package/package.json +1 -1
  41. package/src/common/arrows.ts +17 -4
  42. package/src/common/edge-label-init.ts +9 -2
  43. package/src/common/htmltable-emit.ts +36 -8
  44. package/src/common/poly-gencode.ts +8 -8
  45. package/src/common/ps-fontalias.ts +80 -49
  46. package/src/common/splines-clip.ts +36 -10
  47. package/src/gvc/device.ts +18 -6
  48. package/src/gvc/job.ts +4 -0
  49. package/src/layout/dot/index.ts +37 -5
  50. package/src/layout/sfdp/init.ts +11 -0
  51. package/src/model/graph.ts +26 -0
  52. package/src/parser/builder.ts +90 -5
  53. package/src/render/dot/agwrite.ts +506 -0
  54. package/src/render/dot/attrs.ts +437 -0
  55. package/src/render/dot/edge-draw.ts +203 -0
  56. package/src/render/dot/types.ts +32 -0
  57. package/src/render/dot/xdot-ops.ts +432 -0
  58. package/src/render/dot.ts +114 -1131
  59. package/src/render/map.ts +18 -1
  60. package/src/render/svg-graph.ts +4 -0
  61. package/src/render/svg-helpers.ts +1 -1
@@ -164,8 +164,51 @@ export class StmtProcessor {
164
164
  */
165
165
  private anonCounter = 0;
166
166
 
167
+ /**
168
+ * Strict-graph edge index: canonical endpoint-pair key → the edge already
169
+ * created for it. `agedge` on a strict graph probes for a pre-existing edge
170
+ * and returns it instead of creating a second one, so `strict digraph
171
+ * { a->b; a->b }` holds ONE edge. A Map (rather than scanning root.edges)
172
+ * keeps that probe O(1); it stays empty for non-strict graphs.
173
+ * @see lib/cgraph/edge.c:agedge (agfindedge_by_key probe, :262-287)
174
+ */
175
+ private readonly strictEdges = new Map<string, Edge>();
176
+
167
177
  constructor(private readonly registry: NodeRegistry) {}
168
178
 
179
+ /**
180
+ * Canonical key for a strict-graph endpoint pair. C probes (t,h) and then,
181
+ * for an UNDIRECTED graph, (h,t) — so an undirected pair matches either way
182
+ * round and is canonicalized here by node id; a directed pair is not.
183
+ * @see lib/cgraph/edge.c:agedge:274-275 (`if (e == NULL && agisundirected(g))`)
184
+ */
185
+ private static strictKey(tail: Node, head: Node, undirected: boolean): string {
186
+ const swap = undirected && head.id < tail.id;
187
+ return swap ? `${head.id}\u0000${tail.id}` : `${tail.id}\u0000${head.id}`;
188
+ }
189
+
190
+ /**
191
+ * Apply the DOT-syntax `:port:compass` endpoints to an edge's attrs.
192
+ *
193
+ * C's precedence is explicit attr > `:port` syntax > edge default (verified
194
+ * against the oracle: with `edge [headport=n]` declared, `a:e -> b:sw` yields
195
+ * headport `sw`, while `c:e -> d:sw [headport=w]` yields `w`). The guard is
196
+ * therefore against THIS statement's own attribute list — not the edge's
197
+ * accumulated map, which also holds inherited defaults and, for a strict
198
+ * duplicate, the earlier statement's values.
199
+ * @see lib/cgraph/grammar.y:396 (mkport)
200
+ */
201
+ private static applySyntaxPorts(
202
+ target: Map<string, string>,
203
+ stmtAttrs: AttrPair[],
204
+ tailPort: string,
205
+ headPort: string,
206
+ ): void {
207
+ const setByStmt = (k: string): boolean => stmtAttrs.some((a) => a.key === k);
208
+ if (tailPort && !setByStmt('tailport')) target.set('tailport', tailPort);
209
+ if (headPort && !setByStmt('headport')) target.set('headport', headPort);
210
+ }
211
+
169
212
  /** Consume one anonymous id, returning cgraph's `2*counter+1`. */
170
213
  private nextAnonId(): number {
171
214
  return this.anonCounter++ * 2 + 1;
@@ -185,13 +228,23 @@ export class StmtProcessor {
185
228
  graph.attrs.set(stmt.key, normaliseAttrValue(stmt.value));
186
229
  // cgraph: assigning a graph attr on any (sub)graph declares it graph-wide
187
230
  // with an empty default → the root sees "" for it. @see agattr declaration.
188
- graph.root.declaredGraphAttrs.add(stmt.key);
231
+ this.declareGraphAttr(graph, stmt.key);
232
+ }
233
+
234
+ /** Record a graph-attribute declaration, noting whether THIS scope was the
235
+ * first to declare the key anywhere — the branch selector in cgraph's
236
+ * setattr. @see lib/cgraph/attr.c:257 · model/graph.ts firstGraphDecl */
237
+ private declareGraphAttr(graph: Graph, key: string): void {
238
+ if (!graph.root.declaredGraphAttrs.has(key)) {
239
+ (graph.firstGraphDecl ??= new Set()).add(key);
240
+ graph.root.declaredGraphAttrs.add(key);
241
+ }
189
242
  }
190
243
 
191
244
  processAttr(stmt: AttrStmt, graph: Graph): void {
192
245
  if (stmt.target === 'graph') {
193
246
  applyAttrs(stmt.attrs, graph.attrs);
194
- for (const a of stmt.attrs) graph.root.declaredGraphAttrs.add(a.key);
247
+ for (const a of stmt.attrs) this.declareGraphAttr(graph, a.key);
195
248
  } else if (stmt.target === 'node') {
196
249
  applyAttrs(stmt.attrs, graph.nodeDefaults);
197
250
  } else {
@@ -261,9 +314,16 @@ export class StmtProcessor {
261
314
  // copy attrs but not the snapshot. Mirrors cgraph's agsubg defval copy; kept
262
315
  // to the keys do_graph_label reads so the blast radius stays in label render.
263
316
  // @see lib/common/input.c:do_graph_label (agget label/font*)
317
+ // The seeded keys are recorded so the -Tdot serializer can tell them from a
318
+ // genuine local declaration: cgraph gives a local declaration its own dict
319
+ // symbol (printed by write_dict) even when the value equals the inherited
320
+ // one, whereas a seeded value must stay invisible. @see src/render/dot.ts
264
321
  for (const key of GRAPH_LABEL_INHERIT_KEYS) {
265
322
  const v = sg.graphDefaultsSnapshot.get(key);
266
- if (v !== undefined && !sg.attrs.has(key)) sg.attrs.set(key, v);
323
+ if (v !== undefined && !sg.attrs.has(key)) {
324
+ sg.attrs.set(key, v);
325
+ (sg.seededAttrs ??= new Set()).add(key);
326
+ }
267
327
  }
268
328
  graph.subgraphs.set(sgName, sg);
269
329
  return sg;
@@ -342,14 +402,39 @@ export class StmtProcessor {
342
402
  // DOT-syntax ports land in tailport/headport attrs; explicit attrs win.
343
403
  const tailPort = tailEnd.port;
344
404
  const headPort = headEnd.port;
405
+ const strict = root.kind === 'strict-directed' || root.kind === 'strict-undirected';
406
+ const undirected = root.kind === 'strict-undirected';
345
407
  for (const tail of tailEnd.nodes) {
346
408
  for (const head of headEnd.nodes) {
409
+ const key = strict ? StmtProcessor.strictKey(tail, head, undirected) : '';
410
+ const existing = strict ? this.strictEdges.get(key) : undefined;
411
+ if (existing !== undefined) {
412
+ // A strict duplicate is NOT a new edge: agedge returns the existing
413
+ // one, so this statement's attributes land on it. It returns before
414
+ // agmapnametoid reserves an id, so the anon counter must NOT advance
415
+ // here — that counter drives sibling subgraphs' `%N` names.
416
+ // @see lib/cgraph/edge.c:agedge:276-277
417
+ applyAttrs(attrs, existing.attrs);
418
+ StmtProcessor.applySyntaxPorts(existing.attrs, attrs, tailPort, headPort);
419
+ // subedge: the pre-existing edge joins any enclosing subgraph that
420
+ // does not already hold it. @see lib/cgraph/edge.c:agedge:283
421
+ for (let g: Graph | null = graph; g !== null && g !== root; g = g.parent) {
422
+ g.nodes.set(tail.name, tail);
423
+ g.nodes.set(head.name, head);
424
+ if (!g.edges.includes(existing)) g.edges.push(existing);
425
+ }
426
+ continue;
427
+ }
347
428
  const edge = new Edge(tail, head, '');
348
429
  this.advanceAnonId(); // keyless edge = anonymous cgraph id (see method)
349
430
  applyAttrs(attrs, edge.attrs);
431
+ // Syntax ports BEFORE the defaults snapshot: C's precedence is
432
+ // explicit attr > `:port` syntax > edge default. Seeding the defaults
433
+ // first made the `!has` guard below true for a declared
434
+ // `edge [headport=…]`, silently dropping every `a:w -> b:e` port.
435
+ StmtProcessor.applySyntaxPorts(edge.attrs, attrs, tailPort, headPort);
350
436
  this.snapshotEdgeDefaults(edge, graph);
351
- if (tailPort && !edge.attrs.has('tailport')) edge.attrs.set('tailport', tailPort);
352
- if (headPort && !edge.attrs.has('headport')) edge.attrs.set('headport', headPort);
437
+ if (strict) this.strictEdges.set(key, edge);
353
438
  root.edges.push(edge);
354
439
  edge.graphSeq = root.edges.length;
355
440
  // cgraph: nodes and edges belong to every enclosing graph.
@@ -0,0 +1,506 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * The agwrite serializer — write.c's graph/subgraph/node/edge emission, shared
5
+ * verbatim by `-Tdot` and `-Txdot`.
6
+ *
7
+ * Split out of dot.ts as an abstract base rather than a collaborator so every
8
+ * method body moves UNCHANGED: the serializer reads only three things from the
9
+ * renderer (`emitDraws`, `clusters`, `drawsOf`), which are declared abstract
10
+ * here and supplied by XdotRenderer. Keeping the bodies byte-identical matters
11
+ * more than the inheritance being fashionable — this is ported C, and a
12
+ * mechanical move is auditable against write.c in a way a rewrite is not.
13
+ *
14
+ * @see lib/cgraph/write.c
15
+ */
16
+
17
+ import type { Graph } from '../../model/graph.js';
18
+ import type { Node } from '../../model/node.js';
19
+ import type { Edge } from '../../model/edge.js';
20
+ import type { TextlabelT, FieldT, ShapeDesc } from '../../common/types.js';
21
+ import { agstrcanon, agstrcanonText } from '../map.js';
22
+ import { isHtmlValue } from '../../common/html-string.js';
23
+ import { XDOT_VERSION, agcanonEscape, gfmt5, lpStr, xdotId } from './xdot-ops.js';
24
+ import {
25
+ COMPUTED_EDGE_ATTRS, COMPUTED_NODE_ATTRS, appendRecordRects, computedPart, dictParts,
26
+ echoAttr, echoGraphAttr, edgeAttrsAttached, edgeConnector, edgePosRaw,
27
+ effectiveEdgeDefaults, effectiveNodeDefaults, graphInputParts, graphLabelAttrs,
28
+ isDirected, nodeDictParts, nodeRecord, objInputParts,
29
+ } from './attrs.js';
30
+ import { nodesInSeq } from '../../layout/dot/decomp.js';
31
+ import type { XdotDraws, SerCtx } from './types.js';
32
+
33
+ /** write.c's serializer half of the dot/xdot renderer. @see lib/cgraph/write.c */
34
+ export abstract class DotWriterBase {
35
+ /** @see dot.ts XdotRenderer.emitDraws */
36
+ protected abstract readonly emitDraws: boolean;
37
+ /** Clusters in render order (GD_clust); filled by the renderer's endCluster. */
38
+ protected clusters: Graph[] = [];
39
+ /** Draw strings for `obj`, or undefined in a format that emits none. */
40
+ protected abstract drawsOf(obj: Node | Edge | Graph): XdotDraws | undefined;
41
+
42
+ /**
43
+ * Emit `key="value"`, escaping the value the way agwrite's agcanonStr does: a
44
+ * `"` becomes `\"` unless it is already part of an escape sequence (see
45
+ * agcanonEscape). A draw string carries label text that may contain a bare `"`
46
+ * (would close the attribute early) or a source `\"`/`\\` (must not be
47
+ * double-escaped). The byte-length prefix stays on the UNescaped text (the
48
+ * parser un-escapes before parseXDot re-reads it), matching native exactly.
49
+ * @see lib/cgraph/write.c:_agstrcanon (135-167)
50
+ */
51
+ private drawAttr(key: string, value: string): string {
52
+ return key + '="' + agcanonEscape(value) + '"';
53
+ }
54
+
55
+ /** `llx,lly,urx,ury` from a graph's layout bb. */
56
+ private bbStr(g: Graph): string {
57
+ const bb = g.info.bb;
58
+ return gfmt5(bb.ll.x) + ',' + gfmt5(bb.ll.y) + ',' +
59
+ gfmt5(bb.ur.x) + ',' + gfmt5(bb.ur.y);
60
+ }
61
+
62
+ /**
63
+ * Serialize the whole laid-out graph to xdot DOT text — a faithful port of
64
+ * cgraph's agwrite (lib/cgraph/write.c). Recurses the subgraph tree
65
+ * (write_subgs/write_body), scoping each node/edge to the subgraph(s) it
66
+ * belongs to via preorder numbers (write_node_test/write_edge_test), and
67
+ * re-emits an object bare (no attrs) on any scope after the first
68
+ * (attrs_written). This reproduces native's per-subgraph edge re-declarations
69
+ * — e.g. an edge in a rank=same subgraph is drawn once, then re-declared bare.
70
+ * @see lib/cgraph/write.c:agwrite
71
+ */
72
+ protected serialize(g: Graph): string {
73
+ const ctx: SerCtx = {
74
+ out: [],
75
+ preorder: new Map<Graph, number>(),
76
+ nodeLW: new Map<Node, number>(),
77
+ edgeLW: new Map<Edge, number>(),
78
+ attrsWritten: new Set<Node | Edge>(),
79
+ level: 0,
80
+ };
81
+ this.subgdfs(g, 1, ctx.preorder);
82
+ this.writeHdr(g, true, ctx);
83
+ this.writeBody(g, ctx);
84
+ this.writeTrl(ctx);
85
+ return ctx.out.join('');
86
+ }
87
+
88
+ /** Preorder-number the subgraph tree. @see write.c:subgdfs */
89
+ private subgdfs(g: Graph, ix: number, preorder: Map<Graph, number>): number {
90
+ let ix0 = ix;
91
+ preorder.set(g, ix0);
92
+ for (const sub of g.subgraphs.values()) ix0 = this.subgdfs(sub, ix0, preorder);
93
+ return ix0 + 1;
94
+ }
95
+
96
+ /** A subgraph is anonymous when its name is empty or a `%N` local name. */
97
+ private isAnonymous(g: Graph): boolean {
98
+ return g.name.length === 0 || g.name.charCodeAt(0) === 0x25 /* % */;
99
+ }
100
+
101
+ /** A graph carries a `bb` attribute when it is the root or has a layout bb
102
+ * (clusters, and any subgraph output.c computed a box for). Native seeds `bb`
103
+ * on every graph via safe_dcl, so the value differs (set vs empty "") between
104
+ * a boxed graph and an unboxed child — the driver that makes an anon subgraph
105
+ * under the root or a cluster "relevant". @see lib/common/output.c:safe_dcl */
106
+ private hasBb(g: Graph): boolean {
107
+ return g === g.root || this.clusters.includes(g);
108
+ }
109
+
110
+ /** Anonymous subgraph with no own node/edge defaults and no graph attrs
111
+ * differing from its parent → inlined into the parent. Native compares every
112
+ * graph attr over the root attr dict; the load-bearing ones are `bb` (set on
113
+ * the parent, empty on the child) and `rank`. @see write.c:irrelevant_subgraph */
114
+ private irrelevantSubgraph(g: Graph): boolean {
115
+ if (!this.isAnonymous(g)) return false;
116
+ if (this.clusters.includes(g)) return false;
117
+ if (g.nodeDefaults.size > 0 || g.edgeDefaults.size > 0) return false;
118
+ if (g.parent) {
119
+ if (this.hasBb(g) !== this.hasBb(g.parent)) return false;
120
+ for (const [k, v] of g.attrs) {
121
+ if (g.parent.attrs.get(k) !== v) return false;
122
+ }
123
+ } else if (g.attrs.size > 0) {
124
+ return false;
125
+ }
126
+ return true;
127
+ }
128
+
129
+ /** Non-draw graph attrs a subgraph emits (rank for rank=same; clusters use
130
+ * clusterAttrs). Only comparator-relevant fields need be exact.
131
+ *
132
+ * `rec_attach_bb` walks the ROOT and then `GD_clust` recursively, so a
133
+ * subgraph the layout did not box is attached NEITHER `bb` NOR the label
134
+ * triple — both keep the INPUT's values, which agwrite echoes. circo and
135
+ * twopi lay out no clusters at all (`GD_clust` empty — mirrored here by
136
+ * `this.clusters`, filled from endCluster), so on a re-fed dot output their
137
+ * `cluster0` comes back carrying the *previous* run's `bb` and `lp`.
138
+ * @see lib/common/output.c:249 rec_attach_bb */
139
+ private subgGraphAttrs(sg: Graph): string[] {
140
+ // `rank` is not special-cased here: it is an ordinary input attribute and
141
+ // graphInputParts echoes it, on clusters as well as plain subgraphs.
142
+ const parts: string[] = [];
143
+ parts.push(...echoGraphAttr(sg, 'bb'));
144
+ parts.push(...graphLabelAttrs(sg));
145
+ parts.push(...graphInputParts(sg, false, this.emitDraws));
146
+ return parts;
147
+ }
148
+
149
+ private indent(ctx: SerCtx): string {
150
+ return '\t'.repeat(ctx.level);
151
+ }
152
+
153
+ /** @see write.c:write_hdr */
154
+ private writeHdr(g: Graph, top: boolean, ctx: SerCtx): void {
155
+ if (top) {
156
+ const strict = g.kind === 'strict-directed' || g.kind === 'strict-undirected' ? 'strict ' : '';
157
+ const kw = isDirected(g) ? 'digraph' : 'graph';
158
+ const nm = g.name.length > 0 && !this.isAnonymous(g) ? xdotId(g.name) + ' ' : '';
159
+ ctx.out.push(strict + kw + ' ' + nm + '{\n');
160
+ ctx.level++;
161
+ } else {
162
+ const nm = this.isAnonymous(g) ? '' : 'subgraph ' + xdotId(g.name) + ' ';
163
+ ctx.out.push(this.indent(ctx) + nm + '{\n');
164
+ ctx.level++;
165
+ }
166
+ this.writeDicts(g, top, ctx);
167
+ }
168
+
169
+ /**
170
+ * Emit this scope's `graph` / `node` / `edge` default statements, in that
171
+ * order. @see lib/cgraph/write.c:307 write_dicts
172
+ */
173
+ private writeDicts(g: Graph, top: boolean, ctx: SerCtx): void {
174
+ const graphParts = top
175
+ ? this.graphAttrs(g)
176
+ : this.clusters.includes(g)
177
+ ? this.clusterAttrs(g)
178
+ : this.subgGraphAttrs(g);
179
+ this.writeDict('graph', graphParts, ctx);
180
+ this.writeDict('node', nodeDictParts(g, top), ctx);
181
+ this.writeDict('edge', dictParts(g.edgeDefaults), ctx);
182
+ }
183
+
184
+ /**
185
+ * One `<name> [...]` default statement. Entries are emitted in **strcmp order
186
+ * of attribute name**: cgraph's attribute dicts are `Dttree`s keyed on
187
+ * `Agsym_t.name` with a NULL comparf (attr.c:34), so `dtfirst`/`dtnext` walk
188
+ * them sorted — the oracle's `graph [bb, rankdir]` and `[height, pos, width]`.
189
+ *
190
+ * Layout mirrors write_dict exactly: a single entry stays on one line, and two
191
+ * or more break after each `,` with the body indented one level deeper and the
192
+ * closing `];` back at the statement's own level.
193
+ *
194
+ * Not ported: the `EMPTY(defval) && !sym->print` skip (write.c:271-280). Every
195
+ * entry here was explicitly declared or computed, i.e. `print` is set, so that
196
+ * branch is unreachable until the eager-propagation artifact lands — an
197
+ * `agapply`-installed empty default is the only `print == false` producer.
198
+ *
199
+ * @see lib/cgraph/write.c:262 write_dict
200
+ */
201
+ private writeDict(name: string, parts: string[], ctx: SerCtx): void {
202
+ if (parts.length === 0) return;
203
+ const sorted = [...parts].sort((a, b) => {
204
+ const ka = a.slice(0, a.indexOf('='));
205
+ const kb = b.slice(0, b.indexOf('='));
206
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
207
+ });
208
+ ctx.out.push(this.indent(ctx) + name + ' [');
209
+ ctx.level++;
210
+ ctx.out.push(sorted.join(',\n' + this.indent(ctx)));
211
+ ctx.level--;
212
+ if (sorted.length > 1) ctx.out.push('\n' + this.indent(ctx));
213
+ ctx.out.push('];\n');
214
+ }
215
+
216
+ /**
217
+ * One object's `[...]` attribute block, or '' when it has no attributes.
218
+ * Entries sort strcmp like every dict (attr.c:34). Layout is
219
+ * write_nondefault_attrs, which differs from write_dict in two ways: the block
220
+ * opens with a literal TAB before `[`, and the closing `]` follows the last
221
+ * value directly — there is no newline+indent before it.
222
+ * @see lib/cgraph/write.c:471 write_nondefault_attrs
223
+ */
224
+ private objAttrBlock(parts: string[], ctx: SerCtx): string {
225
+ if (parts.length === 0) return '';
226
+ const sorted = [...parts].sort((a, b) => {
227
+ const ka = a.slice(0, a.indexOf('='));
228
+ const kb = b.slice(0, b.indexOf('='));
229
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
230
+ });
231
+ ctx.level++;
232
+ const body = sorted.join(',\n' + this.indent(ctx));
233
+ ctx.level--;
234
+ return '\t[' + body + ']';
235
+ }
236
+
237
+ /** @see write.c:write_trl */
238
+ private writeTrl(ctx: SerCtx): void {
239
+ ctx.level--;
240
+ ctx.out.push(this.indent(ctx) + '}\n');
241
+ }
242
+
243
+ /** @see write.c:write_subgs */
244
+ private writeSubgs(g: Graph, ctx: SerCtx): void {
245
+ for (const sub of g.subgraphs.values()) {
246
+ if (this.irrelevantSubgraph(sub)) {
247
+ this.writeSubgs(sub, ctx);
248
+ } else {
249
+ this.writeHdr(sub, false, ctx);
250
+ this.writeBody(sub, ctx);
251
+ this.writeTrl(ctx);
252
+ }
253
+ }
254
+ }
255
+
256
+ /** @see write.c:write_body — subgraphs, then this scope's nodes and edges. */
257
+ private writeBody(g: Graph, ctx: SerCtx): void {
258
+ this.writeSubgs(g, ctx);
259
+ // agfstnode/agnxtnode walk a subgraph's node set in AGSEQ (root creation)
260
+ // order, NOT the order nodes were added to this subgraph, so a node first
261
+ // referenced by an earlier edge is emitted before one declared above it in
262
+ // the subgraph's own text. @see lib/cgraph/node.c:43 · decomp.ts nodesInSeq
263
+ for (const n of nodesInSeq(g)) {
264
+ if (this.writeNodeTest(g, n, ctx)) this.writeNode(g, n, ctx);
265
+ let prev: Node = n;
266
+ for (const e of n.outEdges(g)) {
267
+ if (prev !== e.head && this.writeNodeTest(g, e.head, ctx)) {
268
+ this.writeNode(g, e.head, ctx);
269
+ prev = e.head;
270
+ }
271
+ if (this.writeEdgeTest(g, e, ctx)) this.writeEdge(g, e, ctx);
272
+ }
273
+ }
274
+ }
275
+
276
+ /** @see write.c:write_node_test — every xdot node carries pos/size, so it is
277
+ * never "default"; write it in the first scope that has not yet emitted it.
278
+ * Cluster nodes (fdp compound proxies, ND_clustnode) are never declared —
279
+ * C's write_plain/writenodeandport suppress them. @see lib/common/output.c:146 */
280
+ private writeNodeTest(g: Graph, n: Node, ctx: SerCtx): boolean {
281
+ if (n.info.clustnode) return false;
282
+ return (ctx.nodeLW.get(n) ?? 0) < ctx.preorder.get(g)!;
283
+ }
284
+
285
+ /** Emitted node name: a cluster node's synthetic `__i:<cluster>` id is written
286
+ * as the cluster name it stands for. @see lib/common/output.c:114 */
287
+ private emitNodeName(n: Node): string {
288
+ if (n.info.clustnode) {
289
+ const i = n.name.indexOf(':');
290
+ if (i >= 0) return n.name.slice(i + 1);
291
+ }
292
+ return n.name;
293
+ }
294
+
295
+ /** @see write.c:write_edge_test */
296
+ private writeEdgeTest(g: Graph, e: Edge, ctx: SerCtx): boolean {
297
+ return (ctx.edgeLW.get(e) ?? 0) < ctx.preorder.get(g)!;
298
+ }
299
+
300
+ /** @see write.c:write_node */
301
+ private writeNode(g: Graph, n: Node, ctx: SerCtx): void {
302
+ let s = this.indent(ctx) + xdotId(this.emitNodeName(n));
303
+ if (!ctx.attrsWritten.has(n)) {
304
+ s += this.objAttrBlock(this.nodeAttrs(n, g), ctx);
305
+ ctx.attrsWritten.add(n);
306
+ }
307
+ ctx.out.push(s + ';\n');
308
+ ctx.nodeLW.set(n, ctx.preorder.get(g)!);
309
+ }
310
+
311
+ /** @see write.c:write_edge — attrs only on first emission, bare thereafter. */
312
+ /**
313
+ * The `:port` / `:port:compass` suffix C writes after an endpoint name.
314
+ *
315
+ * These are NOT ordinary attributes on the way out: write_nondefault_attrs
316
+ * skips the tailport/headport symbols (write.c:487-492) precisely because
317
+ * this function re-emits them as endpoint syntax, so an edge parsed from
318
+ * `A:f0:ne -> B` round-trips through the port syntax rather than through a
319
+ * `tailport="f0:ne"` attribute.
320
+ *
321
+ * C reads the value with agxget, which falls back to the edge dict default,
322
+ * and gates on the SYMBOL existing at all — both are subsumed here by the
323
+ * attr-then-default lookup coming back undefined. An html-like value is
324
+ * canonicalized whole; a plain one splits on its FIRST `:` (strchr) so the
325
+ * port and the compass point are canonicalized separately.
326
+ * @see lib/cgraph/write.c:565 write_port
327
+ */
328
+ private portSuffix(e: Edge, g: Graph, key: 'tailport' | 'headport'): string {
329
+ const val = e.attrs.get(key) ?? effectiveEdgeDefaults(g).get(key);
330
+ if (val === undefined || val.length === 0) return '';
331
+ if (isHtmlValue(val)) return ':' + agstrcanon(val);
332
+ const i = val.indexOf(':');
333
+ if (i < 0) return ':' + agstrcanonText(val);
334
+ return ':' + agstrcanonText(val.slice(0, i)) + ':' + agstrcanonText(val.slice(i + 1));
335
+ }
336
+
337
+ private writeEdge(g: Graph, e: Edge, ctx: SerCtx): void {
338
+ const conn = edgeConnector(isDirected(g));
339
+ let s = this.indent(ctx) + xdotId(this.emitNodeName(e.tail)) +
340
+ this.portSuffix(e, g, 'tailport') + ' ' + conn + ' ' +
341
+ xdotId(this.emitNodeName(e.head)) + this.portSuffix(e, g, 'headport');
342
+ if (!ctx.attrsWritten.has(e)) {
343
+ s += this.objAttrBlock(this.edgeAttrStr(e, g), ctx);
344
+ ctx.attrsWritten.add(e);
345
+ }
346
+ ctx.out.push(s + ';\n');
347
+ ctx.edgeLW.set(e, ctx.preorder.get(g)!);
348
+ }
349
+
350
+ /** Root-graph attribute block: `_draw_`, `_ldraw_`, `bb`, `xdotversion`. */
351
+ private graphAttrs(g: Graph): string[] {
352
+ const d = this.drawsOf(g);
353
+ const parts: string[] = [];
354
+ if (d?.draw) parts.push(this.drawAttr('_draw_', d.draw));
355
+ if (d?.ldraw) parts.push(this.drawAttr('_ldraw_', d.ldraw));
356
+ parts.push('bb="' + this.bbStr(g) + '"');
357
+ parts.push(...graphLabelAttrs(g));
358
+ // xdot_begin_graph agsets xdotversion; FORMAT_DOT never does.
359
+ // @see plugin/core/gvrender_core_dot.c:341 xdot_begin_graph
360
+ // Canonicalized like any other dict value: `1.7` needs no quotes, and
361
+ // native emits it bare. @see lib/cgraph/write.c:write_canonstr
362
+ if (this.emitDraws) parts.push('xdotversion=' + agstrcanon(XDOT_VERSION));
363
+ parts.push(...graphInputParts(g, true, this.emitDraws));
364
+ return parts;
365
+ }
366
+
367
+ /** Cluster attribute block. C's xdot_end_cluster ALWAYS agsets `_draw_` when
368
+ * the graph has clusters (even empty, e.g. peripheries=0), so emit it
369
+ * unconditionally; `_ldraw_` only when the cluster has a label.
370
+ * @see plugin/core/gvrender_core_dot.c:284 xdot_end_cluster */
371
+ private clusterAttrs(sg: Graph): string[] {
372
+ const d = this.drawsOf(sg);
373
+ const parts: string[] = this.emitDraws ? [this.drawAttr('_draw_', d?.draw ?? '')] : [];
374
+ if (d?.ldraw) parts.push(this.drawAttr('_ldraw_', d.ldraw));
375
+ if (sg.info.bb) parts.push('bb="' + this.bbStr(sg) + '"');
376
+ // rec_attach_bb recurses into GD_clust, so a labelled cluster carries the
377
+ // same lp/lwidth/lheight triple as the root. @see lib/common/output.c:249
378
+ parts.push(...graphLabelAttrs(sg));
379
+ parts.push(...graphInputParts(sg, false, this.emitDraws));
380
+ return parts;
381
+ }
382
+
383
+ /** Node attribute block: pos/width/height plus `_draw_`/`_ldraw_`. `scope` is
384
+ * the subgraph the node is being WRITTEN in, whose node dict every value is
385
+ * compared against. @see lib/cgraph/write.c:537-545 write_node */
386
+ private nodeAttrs(n: Node, scope: Graph): string[] {
387
+ const info = n.info;
388
+ // attach_attrs derives the emitted size from ND_lw+ND_rw / ND_ht, NOT
389
+ // ND_width/ND_height (output.c:307-308). They coincide except where an
390
+ // engine leaves them divergent — patchwork's finishNode lets poly_init
391
+ // clobber ND_width/height while the tile survives in lw/rw/ht.
392
+ const defs = effectiveNodeDefaults(scope);
393
+ const posRaw = gfmt5(info.coord.x) + ',' + gfmt5(info.coord.y);
394
+ const widthRaw = gfmt5((info.lw + info.rw) / 72);
395
+ const heightRaw = gfmt5(info.ht / 72);
396
+ const parts: string[] = [
397
+ ...computedPart('pos', posRaw, defs),
398
+ ...computedPart('width', widthRaw, defs),
399
+ ...computedPart('height', heightRaw, defs),
400
+ ];
401
+ parts.push(...this.nodeXlpPart(n, defs));
402
+ parts.push(...this.nodeRectsPart(n, defs));
403
+ const d = this.drawsOf(n);
404
+ if (d?.draw) parts.push(this.drawAttr('_draw_', d.draw));
405
+ if (d?.ldraw) parts.push(this.drawAttr('_ldraw_', d.ldraw));
406
+ parts.push(...objInputParts(nodeRecord(n), defs, COMPUTED_NODE_ATTRS));
407
+ return parts;
408
+ }
409
+
410
+ /** The `[...]` attribute body for an edge (draw ops + spline `pos`), or ''. */
411
+ private edgeAttrStr(e: Edge, g: Graph): string[] {
412
+ const defs = effectiveEdgeDefaults(g);
413
+ const parts: string[] = this.edgeDrawParts(e);
414
+ const posRaw = edgePosRaw(e);
415
+ if (posRaw !== null) {
416
+ parts.push(...computedPart('pos', posRaw, defs));
417
+ } else {
418
+ // C's attach_attrs only agsets `pos` when the edge HAS a spline
419
+ // (output.c:348); an engine that never routes (patchwork) leaves the
420
+ // INPUT's own pos attribute intact and write.c emits it verbatim.
421
+ parts.push(...echoAttr(e.attrs, 'pos'));
422
+ }
423
+ // The label-position attributes live inside the SAME loop that writes `pos`
424
+ // (output.c:377-396), so they are attached only for a routed, non-IGNORED
425
+ // edge. Note the asymmetry C encodes: `lp`/`head_lp`/`tail_lp` are emitted
426
+ // whenever the label EXISTS, but `xlp` additionally requires `->set` — an
427
+ // unplaced xlabel is omitted. Order mirrors C: lp, xlp, head_lp, tail_lp.
428
+ // Each gate is independent, and each FAILED gate leaves the input's own
429
+ // value in the slot for write.c to echo: an unrouted edge (patchwork routes
430
+ // none) returns the `lp` a previous dot run wrote.
431
+ parts.push(...this.edgeLabelPosParts(e, defs));
432
+ parts.push(...objInputParts(e.attrs, defs, COMPUTED_EDGE_ATTRS));
433
+ return parts;
434
+ }
435
+
436
+ /** `xlp` — only when the node HAS an xlabel AND the xlabel placer actually set
437
+ * its position (`ND_xlabel(n)->set`). An xlabel that was never placed is
438
+ * omitted entirely, so an input `xlp` survives and is echoed.
439
+ * @see lib/common/output.c:309-313 */
440
+ private nodeXlpPart(n: Node, defs: Map<string, string>): string[] {
441
+ const xlabel = n.info.xlabel as TextlabelT | undefined;
442
+ if (!xlabel || !xlabel.set) return echoAttr(n.attrs, 'xlp');
443
+ const raw = lpStr(xlabel.pos);
444
+ return computedPart('xlp', raw, defs);
445
+ }
446
+
447
+ /** `rects` — record field boxes. C gates on the SHAPE NAME being exactly
448
+ * "record" (`strcmp(ND_shape(n)->name, "record") == 0`), so `Mrecord` and
449
+ * HTML-table nodes get NO rects; confirmed against the native oracle. When
450
+ * the shape is not a record the input's `rects` is never overwritten and is
451
+ * echoed — patchwork forces every node to `box`, so a re-fed dot output
452
+ * returns its old record rects untouched. @see lib/common/output.c:314-317 */
453
+ private nodeRectsPart(n: Node, defs: Map<string, string>): string[] {
454
+ const shape = n.info.shape as ShapeDesc | undefined;
455
+ if (shape?.name !== 'record') return echoAttr(n.attrs, 'rects');
456
+ const rects: string[] = [];
457
+ appendRecordRects(n, n.info.shape_info as FieldT, rects);
458
+ const raw = rects.join(' ');
459
+ return computedPart('rects', raw, defs);
460
+ }
461
+
462
+ /** The six `_draw_`-family attributes of an edge, in C's order. */
463
+ private edgeDrawParts(e: Edge): string[] {
464
+ const d = this.drawsOf(e);
465
+ const parts: string[] = [];
466
+ if (d?.draw) parts.push(this.drawAttr('_draw_', d.draw));
467
+ if (d?.ldraw) parts.push(this.drawAttr('_ldraw_', d.ldraw));
468
+ if (d?.hdraw) parts.push(this.drawAttr('_hdraw_', d.hdraw));
469
+ if (d?.tdraw) parts.push(this.drawAttr('_tdraw_', d.tdraw));
470
+ if (d?.hldraw) parts.push(this.drawAttr('_hldraw_', d.hldraw));
471
+ if (d?.tldraw) parts.push(this.drawAttr('_tldraw_', d.tldraw));
472
+ return parts;
473
+ }
474
+
475
+ /**
476
+ * `lp`/`xlp`/`head_lp`/`tail_lp` for one edge, in C's order.
477
+ *
478
+ * Note the asymmetry C encodes: `lp`/`head_lp`/`tail_lp` are emitted whenever
479
+ * the label EXISTS, but `xlp` additionally requires `->set` — an unplaced
480
+ * xlabel is omitted. Each gate is independent, and each FAILED gate leaves the
481
+ * input's own value in the slot for write.c to echo: an unrouted edge
482
+ * (patchwork routes none) returns the `lp` a previous dot run wrote.
483
+ * @see lib/common/output.c:377-396
484
+ */
485
+ private edgeLabelPosParts(e: Edge, defs: Map<string, string>): string[] {
486
+ const attached = edgeAttrsAttached(e);
487
+ const info = e.info;
488
+ const parts: string[] = [];
489
+ const lpPart = (key: string, label: TextlabelT): string[] => {
490
+ const raw = lpStr(label.pos);
491
+ return computedPart(key, raw, defs);
492
+ };
493
+ if (attached && info.label) parts.push(...lpPart('lp', info.label));
494
+ else parts.push(...echoAttr(e.attrs, 'lp'));
495
+ if (attached && info.xlabel && info.xlabel.set) {
496
+ parts.push(...lpPart('xlp', info.xlabel));
497
+ } else parts.push(...echoAttr(e.attrs, 'xlp'));
498
+ if (attached && info.head_label) {
499
+ parts.push(...lpPart('head_lp', info.head_label));
500
+ } else parts.push(...echoAttr(e.attrs, 'head_lp'));
501
+ if (attached && info.tail_label) {
502
+ parts.push(...lpPart('tail_lp', info.tail_label));
503
+ } else parts.push(...echoAttr(e.attrs, 'tail_lp'));
504
+ return parts;
505
+ }
506
+ }