@knowvah/dot-engine 1.1.1 → 1.2.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.
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
@@ -0,0 +1,437 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * Attribute helpers for the `-Tdot` writer — the `attach_attrs_and_arrows`
5
+ * side of output.c, plus the value-record and dict-default rules
6
+ * write_nondefault_attrs / write_dict compare against.
7
+ *
8
+ * @see lib/common/output.c:254 attach_attrs_and_arrows
9
+ * @see lib/cgraph/write.c:471 write_nondefault_attrs
10
+ */
11
+
12
+ import type { Graph } from '../../model/graph.js';
13
+ import type { Node } from '../../model/node.js';
14
+ import type { Edge } from '../../model/edge.js';
15
+ import { IGNORED } from '../../layout/dot/rank.js';
16
+ import { agstrcanon } from '../map.js';
17
+ import { POINTS_PER_INCH } from '../../model/geom.js';
18
+ import type { TextlabelT, FieldT } from '../../common/types.js';
19
+ import { agcanonEscape, gfmt2, gfmt5, lpStr } from './xdot-ops.js';
20
+
21
+ /**
22
+ * C's `attach_attrs` edge loop skips IGNORED edges and edges with no spline
23
+ * (`ED_spl(e) == NULL`) via `continue`, so such an edge is attached NEITHER
24
+ * `pos` NOR any of `lp`/`xlp`/`head_lp`/`tail_lp` — even when it carries a
25
+ * label. All five attributes therefore share this one gate.
26
+ * @see lib/common/output.c:349-353
27
+ */
28
+ export function edgeAttrsAttached(e: Edge): boolean {
29
+ return e.info.edge_type !== IGNORED && e.info.spl != null;
30
+ }
31
+
32
+ /**
33
+ * Append the leaf-field rectangles of a record node, mirroring the recursion in
34
+ * `set_record_rects`: a field with sub-fields contributes nothing itself, while
35
+ * a LEAF field (`n_flds == 0`) contributes its box translated by the node centre
36
+ * as `llx,lly,urx,ury` at `%.5g`. C emits each field followed by a space and
37
+ * then pops the trailing one — joining with a single space is equivalent.
38
+ * @see lib/common/output.c:215 set_record_rects
39
+ */
40
+ export function appendRecordRects(n: Node, f: FieldT, out: string[]): void {
41
+ const c = n.info.coord;
42
+ if (f.n_flds === 0) {
43
+ out.push(
44
+ gfmt5(f.b.ll.x + c.x) + ',' + gfmt5(f.b.ll.y + c.y) + ',' +
45
+ gfmt5(f.b.ur.x + c.x) + ',' + gfmt5(f.b.ur.y + c.y),
46
+ );
47
+ }
48
+ for (let i = 0; i < f.n_flds; i++) appendRecordRects(n, f.fld![i], out);
49
+ }
50
+
51
+ /**
52
+ * Echo an attribute that `attach_attrs_and_arrows` did NOT overwrite.
53
+ *
54
+ * Every computed attribute is `agset` behind a gate (`rects` only for a
55
+ * `record` shape; an edge's `lp` only for a routed edge that HAS a label;
56
+ * a graph's `lp` only when `GD_label(g)` exists...). When the gate FAILS, C
57
+ * simply does not write — so the slot keeps whatever the INPUT file parsed
58
+ * into it, and `agwrite` (which serializes the whole attribute table, not
59
+ * just the fields layout computed) prints that stale value verbatim. This is
60
+ * highly visible under patchwork, which forces every shape to `box` and routes
61
+ * no edges: on a re-fed dot output (most of the corpus) native echoes back the
62
+ * `rects` / `lp` a *previous* dot run wrote, in that run's coordinate space.
63
+ *
64
+ * cgraph interns attribute strings, so a value equal to the declared (empty)
65
+ * default is the SAME pointer as the default and write.c's
66
+ * `data->str[sym->id] != sym->defval` test drops it — hence an empty value is
67
+ * never printed.
68
+ * @see lib/cgraph/write.c:427 write_nondefault_attrs · lib/common/output.c:270
69
+ */
70
+ /** Names the serializer computes for a node; never echoed from input. */
71
+ export const COMPUTED_NODE_ATTRS = new Set(['pos', 'width', 'height', 'xlp', 'rects', '_draw_', '_ldraw_']);
72
+
73
+ /** Names the serializer computes for an edge. `tailport`/`headport` are excluded
74
+ * too: write_nondefault_attrs skips those symbols because they are written as
75
+ * `:port` syntax on the endpoints instead. @see lib/cgraph/write.c:487-492 */
76
+ export const COMPUTED_EDGE_ATTRS = new Set([
77
+ 'pos', 'lp', 'xlp', 'head_lp', 'tail_lp', 'tailport', 'headport',
78
+ '_draw_', '_ldraw_', '_hdraw_', '_tdraw_', '_hldraw_', '_tldraw_',
79
+ ]);
80
+
81
+ /** dot's synthesized AGNODE `label` default. @see lib/common/const.h NODENAME_ESC */
82
+ export const NODENAME_ESC = '\\N';
83
+
84
+ /** Effective edge defaults at `scope`, inner overriding outer — mirrors the
85
+ * builder's snapshotEdgeDefaults walk (builder.ts:294). Edges have no stored
86
+ * snapshot: the builder copies inherited defaults straight into `edge.attrs`,
87
+ * so the only way to tell a defaulted value from an explicit one is to compare
88
+ * against this walk. That merge also means `edge.attrs` already IS the cgraph
89
+ * value record `objInputParts` expects — the node side has to build one
90
+ * (`nodeRecord`) because node defaults are kept in a separate snapshot. */
91
+ export function effectiveEdgeDefaults(scope: Graph): Map<string, string> {
92
+ const eff = new Map<string, string>();
93
+ for (let g: Graph | null = scope; g !== null; g = g.parent) {
94
+ for (const [k, v] of g.edgeDefaults) if (!eff.has(k)) eff.set(k, v);
95
+ }
96
+ return eff;
97
+ }
98
+
99
+ /**
100
+ * Effective node defaults at `scope`, inner overriding outer — the dict
101
+ * write_node compares each node against (`d`, threaded from write_body,
102
+ * write.c:537-545). This is the scope the node is WRITTEN in, which is not
103
+ * necessarily where it was created.
104
+ *
105
+ * Includes dot's synthesized `label` default: `graph_init` installs
106
+ * NODENAME_ESC on the root when the input declares none (input.c:737-739), and
107
+ * it is part of the dict C compares against. `nodeRecord` seeds the same value
108
+ * under the same condition so the two cancel — omitting it from either side
109
+ * would print `label="\N"` (or `label=""`) on every node in the corpus.
110
+ * @see lib/cgraph/write.c:537-545 write_node · lib/common/input.c:737-739
111
+ */
112
+ export function effectiveNodeDefaults(scope: Graph): Map<string, string> {
113
+ const eff = new Map<string, string>();
114
+ for (let g: Graph | null = scope; g !== null; g = g.parent) {
115
+ for (const [k, v] of g.nodeDefaults) if (!eff.has(k)) eff.set(k, v);
116
+ }
117
+ if (!eff.has('label')) eff.set('label', NODENAME_ESC);
118
+ return eff;
119
+ }
120
+
121
+ /**
122
+ * One node's cgraph value RECORD: the node-dict defaults in effect where the
123
+ * node was CREATED, overridden by its own explicit attributes.
124
+ *
125
+ * `addattr` seeds a node's slot from the default at declaration time and an
126
+ * explicit `n [k=v]` overwrites it; neither is disturbed by a LATER
127
+ * `node [k=…]` in the same scope, which only moves the dict symbol. That gap is
128
+ * the whole mechanism: `{node[shape=house]; A; node[shape=invhouse]; B}` leaves
129
+ * A's record at `house` against a dict default of `invhouse`, so C prints
130
+ * `shape=house` on A and nothing on B.
131
+ * @see lib/cgraph/attr.c:210 addattr · lib/cgraph/write.c:485
132
+ */
133
+ export function nodeRecord(n: Node): Map<string, string> {
134
+ const rec = new Map<string, string>(n.nodeDefaultsSnapshot ?? []);
135
+ if (!rec.has('label')) rec.set('label', NODENAME_ESC);
136
+ for (const [k, v] of n.attrs) rec.set(k, v);
137
+ return rec;
138
+ }
139
+
140
+ /**
141
+ * One object's attribute block: every name whose value RECORD differs from the
142
+ * writing scope's dict default. A name in the dict but not the record compares
143
+ * as empty and prints as `k=""` — `digraph G { a; node[color=red]; b; }` emits
144
+ * `a [color=""]`, which a record-only walk structurally cannot produce.
145
+ * @see lib/cgraph/write.c:471 write_nondefault_attrs
146
+ */
147
+ export function objInputParts(
148
+ record: Map<string, string>,
149
+ defaults: Map<string, string>,
150
+ computed: Set<string>,
151
+ ): string[] {
152
+ const parts: string[] = [];
153
+ for (const k of new Set([...record.keys(), ...defaults.keys()])) {
154
+ if (computed.has(k)) continue;
155
+ const v = record.get(k) ?? '';
156
+ if (v === (defaults.get(k) ?? '')) continue;
157
+ parts.push(k + '=' + agstrcanon(v));
158
+ }
159
+ return parts;
160
+ }
161
+
162
+ /**
163
+ * A COMPUTED node/edge attribute, dropped when its value equals the applicable
164
+ * dict default.
165
+ *
166
+ * `safe_dcl` declares each computed name with an empty default only when the
167
+ * symbol does not already exist — an input file that says `node [width=0.5]`
168
+ * leaves `sym->defval` at `0.5`. cgraph interns strings, so `agxset`ing a
169
+ * computed `0.5` stores the SAME refstr as the default and
170
+ * write_nondefault_attrs' `data->str[sym->id] != sym->defval` test drops it.
171
+ * tests/graphs/arrows.gv is the clean case: every node emits `height=0.5`
172
+ * (no declared default) but none emits `width`.
173
+ *
174
+ * `formatted` is passed in rather than derived so each call site keeps its own
175
+ * quoting; `raw` is the unquoted text that C would have interned.
176
+ * @see lib/common/utils.c:1065 safe_dcl · lib/cgraph/write.c:485
177
+ */
178
+ export function computedPart(
179
+ key: string,
180
+ raw: string,
181
+ defaults: Map<string, string> | undefined,
182
+ ): string[] {
183
+ if (defaults !== undefined && defaults.get(key) === raw) return [];
184
+ // C agsets the raw computed text (output.c) and lets agwrite canonicalize it
185
+ // on the way out, so the quoting DECISION and the 128-byte line breaking both
186
+ // belong here rather than at the call site: `lheight=0.23` prints bare while
187
+ // a long `pos` is split across lines with a trailing backslash.
188
+ // @see lib/cgraph/write.c:481 write_nondefault_attrs -> write_canonstr
189
+ return [key + '=' + agstrcanon(raw)];
190
+ }
191
+
192
+ /** Attribute names the serializer computes itself; input values are echoed by
193
+ * `graphInputParts`, so these must not be echoed twice. */
194
+ export const COMPUTED_GRAPH_ATTRS = new Set([
195
+ '_draw_', '_ldraw_', 'bb', 'lp', 'lwidth', 'lheight',
196
+ ]);
197
+
198
+ /** `COMPUTED_GRAPH_ATTRS` for a renderer that emits draw ops. Only
199
+ * `xdot_begin_graph` agsets `xdotversion` (gvrender_core_dot.c:341); plain
200
+ * `-Tdot` never computes it, so an input `xdotversion=1.7` stays an ordinary
201
+ * attribute there and write.c echoes it like any other. */
202
+ const COMPUTED_GRAPH_ATTRS_XDOT = new Set([...COMPUTED_GRAPH_ATTRS, 'xdotversion']);
203
+
204
+ /** The computed-attr set in force for this renderer. */
205
+ function computedGraphAttrs(emitDraws: boolean): Set<string> {
206
+ return emitDraws ? COMPUTED_GRAPH_ATTRS_XDOT : COMPUTED_GRAPH_ATTRS;
207
+ }
208
+
209
+ /** Walk to the root graph (cgraph `agroot`). */
210
+ export function rootOf(g: Graph): Graph {
211
+ let cur: Graph = g;
212
+ while (cur.parent !== null) cur = cur.parent;
213
+ return cur;
214
+ }
215
+
216
+ /**
217
+ * A scope's own graph-attribute dict entries — the INPUT attributes, echoed by
218
+ * write_dict because in cgraph a graph's attributes ARE its dict defaults.
219
+ *
220
+ * For a non-root scope the test is PROVENANCE, not value. `setattr` gives a
221
+ * subgraph its own dict symbol for every attribute the input declares in that
222
+ * scope, and write_dict prints each local symbol whose value is non-empty —
223
+ * there is no comparison against the inherited value, so re-declaring an
224
+ * attribute to the value it already inherited still prints. Oracle-pinned:
225
+ * `digraph G { foo="x"; { foo="x"; a; b } }` emits `foo=x` on the subgraph.
226
+ *
227
+ * The only entries in `attrs` that are NOT local declarations are the ones the
228
+ * builder seeded from the snapshot so cluster-label inheritance survives the
229
+ * layout's cluster rebuilds; those carry no dict symbol in C and are recorded in
230
+ * `seededAttrs`. (A value-equality test stood in for this before the marker
231
+ * existed, and wrongly swallowed genuine re-declarations.)
232
+ *
233
+ * Also reproduces C's EAGER-propagation artifact. `agattr` creating a NEW global
234
+ * graph attribute runs `agapply(root, addattr, rsym, true)`, installing the
235
+ * symbol — with its pre-declaration (empty) default — on every subgraph that
236
+ * ALREADY EXISTS. So a subgraph opened before the declaration carries a local
237
+ * empty value and agwrite prints e.g. `rankdir=""`, while a sibling opened after
238
+ * it inherits and prints nothing. `graphDefaultsSnapshot` records exactly which
239
+ * defaults were in effect when the scope opened, so "declared at root after this
240
+ * scope opened" is `root.attrs.has(k) && !snapshot.has(k)`.
241
+ * @see lib/cgraph/attr.c:287 (agapply/addattr) · lib/cgraph/write.c:262
242
+ */
243
+ export function graphInputParts(g: Graph, top: boolean, emitDraws: boolean): string[] {
244
+ const computed = computedGraphAttrs(emitDraws);
245
+ const parts: string[] = [];
246
+ const snap = g.graphDefaultsSnapshot;
247
+ const seeded = g.seededAttrs;
248
+ for (const [k, v] of g.attrs) {
249
+ if (computed.has(k)) continue;
250
+ if (!top && seeded !== undefined && seeded.has(k)) continue;
251
+ if (writeDictSkips(v, snap?.get(k))) continue;
252
+ parts.push(k + '=' + agstrcanon(v));
253
+ }
254
+ if (!top && snap !== undefined) parts.push(...eagerEmptyParts(g, snap, computed));
255
+ return parts;
256
+ }
257
+
258
+ /**
259
+ * C's EAGER-propagation artifact. Setting a graph attribute in scope S runs
260
+ * `unviewsubgraphsattr(S, name)`, which walks `agfstsubg(S)` and gives every
261
+ * subgraph with no local definition its own symbol holding the value it had at
262
+ * that moment — empty, when the attribute is only now being declared. agwrite
263
+ * then prints that local `rankdir=""`, while a sibling opened afterwards simply
264
+ * inherits and prints nothing.
265
+ *
266
+ * Two things narrow this. The walk is over DIRECT subgraphs and is NOT
267
+ * recursive, so the trigger is the immediate PARENT declaring the key, not any
268
+ * ancestor: in `A { B { C {…} } graph[label=x] }` only B prints `label=""`,
269
+ * never C. And setattr only reaches `unviewsubgraphsattr` on the branch where
270
+ * the key had no symbol yet, so the parent's declaration must be the FIRST
271
+ * anywhere (`firstGraphDecl`) — once a sibling scope has declared the key, the
272
+ * same statement takes the "new local definition" branch and seeds nothing.
273
+ * @see lib/cgraph/attr.c:232 unviewsubgraphsattr · :257 setattr (branch split)
274
+ */
275
+ function eagerEmptyParts(g: Graph, snap: Map<string, string>, computed: Set<string>): string[] {
276
+ const parts: string[] = [];
277
+ const p = g.parent;
278
+ if (p === null) return parts;
279
+ // The ROOT is special: a global declaration made anywhere inserts its symbol
280
+ // into the root's OWN dict, so by the time the root declares the key setattr
281
+ // always finds a local symbol and takes the unview branch. A non-root scope
282
+ // only holds a local symbol for a key it declared first.
283
+ const keys = p.parent === null ? p.attrs.keys() : (p.firstGraphDecl ?? []);
284
+ for (const k of keys) {
285
+ if (computed.has(k)) continue;
286
+ if (snap.has(k) || g.attrs.has(k)) continue;
287
+ parts.push(k + '=' + agstrcanon(''));
288
+ }
289
+ return parts;
290
+ }
291
+
292
+ /**
293
+ * `name=value` parts for one attribute-default map, values canonicalized by the
294
+ * `_agstrcanon` port (conditional quoting — `rankdir=LR` bare, `bb="0,0,1,1"`
295
+ * quoted), matching write_canonstr. @see lib/cgraph/write.c:write_canonstr
296
+ */
297
+ export function dictParts(defaults: Map<string, string>): string[] {
298
+ const parts: string[] = [];
299
+ for (const [k, v] of defaults) parts.push(k + '=' + agstrcanon(v));
300
+ return parts;
301
+ }
302
+
303
+ /**
304
+ * Node-dict parts for a scope. At the root, dot's `graph_init` installs an
305
+ * AGNODE `label` default of NODENAME_ESC (`\N`) when absent — the port models
306
+ * attributes as per-object Maps and reproduces that default at each use-site
307
+ * (see the N_label note in common/graph-init.ts), and the serializer is such a
308
+ * use-site. An explicit root-level `node [label=...]` wins over the synthesized
309
+ * default. @see lib/common/input.c:737-739 (N_label)
310
+ */
311
+ export function nodeDictParts(g: Graph, top: boolean): string[] {
312
+ const defs = new Map(g.nodeDefaults);
313
+ if (top && !defs.has('label')) defs.set('label', NODENAME_ESC);
314
+ return dictParts(defs);
315
+ }
316
+
317
+ export function echoAttr(attrs: Map<string, string>, key: string): string[] {
318
+ const v = attrs.get(key);
319
+ if (v === undefined || v.length === 0) return [];
320
+ return [key + '="' + agcanonEscape(v) + '"'];
321
+ }
322
+
323
+ /**
324
+ * write_dict's conditional empty-value skip, as OBSERVED against the oracle.
325
+ *
326
+ * A non-empty local value always prints. An empty one is dropped only when the
327
+ * INHERITED value is present and empty too; an inherited value that is absent
328
+ * or non-empty leaves the empty local value printed, and so does the root,
329
+ * which inherits nothing. Pinned with `digraph G { [foo=<i>;] { foo=""; … } }`:
330
+ * inherited absent PRINTS, inherited `"x"` PRINTS, inherited `""` SKIPS, and
331
+ * the root's own `foo=""` PRINTS in every one of those.
332
+ *
333
+ * Neither half of the C's guard can be read literally. `Agsym_t.print`
334
+ * (cgraph.h:644) is assigned nowhere in the tree, so porting
335
+ * `EMPTY(psym->defval) && psym->print` verbatim makes the parent-empty skip
336
+ * unreachable; and the `view == NULL` skip would drop the root's `foo=""`,
337
+ * which native prints. The observed rule is authoritative here.
338
+ * @see lib/cgraph/write.c:271-278 write_dict
339
+ */
340
+ export function writeDictSkips(v: string, inherited: string | undefined): boolean {
341
+ return v.length === 0 && inherited === '';
342
+ }
343
+
344
+ /**
345
+ * `echoAttr` for a GRAPH attribute the layout did not overwrite — same as
346
+ * echoAttr except an EMPTY value can still be emitted, per `writeDictSkips`.
347
+ * So a re-fed dot output whose `{rank=same}` block carries `bb=""`/`lp=""`
348
+ * under a boxed, labelled cluster echoes both back verbatim
349
+ * (tests/share/KW91.gv).
350
+ */
351
+ export function echoGraphAttr(g: Graph, key: string): string[] {
352
+ const v = g.attrs.get(key);
353
+ if (v === undefined) return [];
354
+ if (writeDictSkips(v, g.graphDefaultsSnapshot?.get(key))) return [];
355
+ return [key + '="' + agcanonEscape(v) + '"'];
356
+ }
357
+
358
+ /**
359
+ * The `lp`/`lwidth`/`lheight` triple for a graph or cluster, in C's order.
360
+ * `rec_attach_bb` attaches them to the root graph AND recursively to every
361
+ * cluster, but ONLY when the graph carries a label with non-empty text
362
+ * (`GD_label(g) && GD_label(g)->text[0]`) — an absent or empty-text label emits
363
+ * none of the three. `lp` is the label centre in points (`%.5g`); `lwidth` and
364
+ * `lheight` are the label's `dimen` converted to inches and written `%.2f`.
365
+ * When the gate fails, the input's own values survive and are echoed: patchwork
366
+ * and osage never build a cluster label object, so a re-fed dot file's cluster
367
+ * `lp` comes straight back out.
368
+ * @see lib/common/output.c:239-248 rec_attach_bb
369
+ */
370
+ export function graphLabelAttrs(g: Graph): string[] {
371
+ const label = g.info.label as TextlabelT | undefined;
372
+ if (!label || label.text.length === 0) {
373
+ return [
374
+ ...echoGraphAttr(g, 'lp'),
375
+ ...echoGraphAttr(g, 'lwidth'),
376
+ ...echoGraphAttr(g, 'lheight'),
377
+ ];
378
+ }
379
+ return [
380
+ 'lp="' + lpStr(label.pos) + '"',
381
+ 'lwidth=' + gfmt2(label.dimen.x / POINTS_PER_INCH),
382
+ 'lheight=' + gfmt2(label.dimen.y / POINTS_PER_INCH),
383
+ ];
384
+ }
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // DOT attribute helpers
388
+ // ---------------------------------------------------------------------------
389
+
390
+ /**
391
+ * Format edge spline points for the DOT `pos` attribute. Per bezier: the start
392
+ * endpoint `s,sp` when `sflag` set, then the end endpoint `e,ep` when `eflag`
393
+ * set, then `bez.size` control points — all at `%.5g`, exactly as native's
394
+ * spline serialization. Beziers are separated by `;`, points within one by a
395
+ * space. @see lib/common/output.c:353-372
396
+ */
397
+ export function formatEdgePos(e: Edge): string {
398
+ const raw = edgePosRaw(e);
399
+ return raw === null ? '' : 'pos="' + raw + '"';
400
+ }
401
+
402
+ /** `formatEdgePos`'s value without the `pos="…"` wrapper — the text C interns
403
+ * and compares against the symbol default. `null` when no `pos` is attached. */
404
+ export function edgePosRaw(e: Edge): string | null {
405
+ const spl = e.info.spl;
406
+ if (!spl || spl.list.length === 0) return null;
407
+ // Native's pos loop skips IGNORED edges (output.c:350) — concentrate merges
408
+ // an edge into its opposite and marks the absorbed one IGNORED; it is still
409
+ // drawn (has _draw_) but carries no `pos`. @see lib/common/output.c:349-353
410
+ if (e.info.edge_type === IGNORED) return null;
411
+ // Built imperatively rather than by join() because C's separators are not
412
+ // uniform: beziers are separated by ';' with no space, while the `s,`/`e,`
413
+ // endpoints each carry a trailing space from their own format string.
414
+ let out = '';
415
+ for (let i = 0; i < spl.size; i++) {
416
+ const bez = spl.list[i];
417
+ if (i > 0) out += ';';
418
+ if (bez.sflag) out += 's,' + gfmt5(bez.sp.x) + ',' + gfmt5(bez.sp.y) + ' ';
419
+ if (bez.eflag) out += 'e,' + gfmt5(bez.ep.x) + ',' + gfmt5(bez.ep.y) + ' ';
420
+ for (let j = 0; j < bez.size; j++) {
421
+ if (j > 0) out += ' ';
422
+ const p = bez.list[j];
423
+ out += gfmt5(p.x) + ',' + gfmt5(p.y);
424
+ }
425
+ }
426
+ return out;
427
+ }
428
+
429
+ /** Return the edge connector token. */
430
+ export function edgeConnector(directed: boolean): string {
431
+ return directed ? '->' : '--';
432
+ }
433
+
434
+ /** Return true if the graph is directed or strict-directed. */
435
+ export function isDirected(g: Graph): boolean {
436
+ return g.kind === 'directed' || g.kind === 'strict-directed';
437
+ }
@@ -0,0 +1,203 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * Edge spline + arrow draw-op emission — emit.c's multicolor / tapered / ortho
5
+ * branches, split out of dot.ts for file size. Same abstract-base approach as
6
+ * agwrite.ts: every body moves UNCHANGED and reaches the renderer's emit state
7
+ * (`penwidth`, `styleOp`, `penOp`) through inheritance rather than injection,
8
+ * so each branch stays diffable against the C.
9
+ *
10
+ * @see lib/common/emit.c:2389 multicolor · :2422 tapered · :2583 ortho
11
+ */
12
+
13
+ import type { Edge } from '../../model/edge.js';
14
+ import type { Point } from '../../model/geom.js';
15
+ import { isDirected } from './attrs.js';
16
+ import type { Bezier } from '../../model/geom.js';
17
+ import type { ArrowDrawOp } from '../../common/arrows-types.js';
18
+ import type { GVColor } from '../../common/color.js';
19
+ import type { RenderJob } from '../../gvc/job.js';
20
+ import { EmitState } from '../../gvc/job.js';
21
+ import { resolveRenderColor } from '../color-resolve.js';
22
+ import { taper, taperfun } from '../../common/taper.js';
23
+ import { orthoRoundedRadius } from '../svg-helpers.js';
24
+ import { orthoRoundedPolylines } from '../svg-edge-ortho-radius.js';
25
+ import { parseSegs } from '../../common/multicolor.js';
26
+ import { splitSplineByColor } from '../svg-edge-split.js';
27
+ import { buildOffsetLists, advanceTmpList } from '../../common/edge-offset.js';
28
+ import { xdotFillColor, xdotNum, xdotPenColor, xdotPoint, xdotPoints, xdotStrOp, trimFixed3 } from './xdot-ops.js';
29
+ import { DotWriterBase } from './agwrite.js';
30
+
31
+ /** Pen colour when a colour-list entry is empty. @see lib/common/emit.c:2485 */
32
+ const DEFAULT_COLOR = 'black';
33
+
34
+ /**
35
+ * One arrow primitive as an xdot op. Lifted out of emitArrows' loop unchanged —
36
+ * the four cases and their `filled` variants are exactly arrow_gen's.
37
+ * @see lib/common/arrows.c arrow_gen
38
+ */
39
+ function emitArrowOp(buf: string[], op: ArrowDrawOp, pen: GVColor): void {
40
+ switch (op.kind) {
41
+ case 'polygon':
42
+ if (op.filled) buf.push(xdotFillColor(pen));
43
+ buf.push(xdotPoints(op.filled ? 'P' : 'p', op.points));
44
+ break;
45
+ case 'ellipse':
46
+ if (op.filled) buf.push(xdotFillColor(pen));
47
+ buf.push(
48
+ (op.filled ? 'E ' : 'e ') + xdotPoint(op.center) +
49
+ xdotNum(op.rx) + ' ' + xdotNum(op.ry) + ' ',
50
+ );
51
+ break;
52
+ case 'polyline':
53
+ buf.push(xdotPoints('L', op.points));
54
+ break;
55
+ case 'bezier':
56
+ buf.push(xdotPoints('B', op.points));
57
+ break;
58
+ }
59
+ }
60
+
61
+ /** emit.c's edge-drawing half, between the serializer base and the renderer. */
62
+ export abstract class EdgeDrawBase extends DotWriterBase {
63
+ /** setlinewidth state per emit_state. @see gvrender_core_dot.c penwidth[] */
64
+ protected penwidth: number[] = new Array(12).fill(1);
65
+ protected abstract styleOp(job: RenderJob): string;
66
+ protected abstract penOp(job: RenderJob): string;
67
+
68
+ /**
69
+ * The plain single-colour spline branch of beginEdge — lifted verbatim so the
70
+ * four-way spline-kind chain above stays readable; no branch was reordered or
71
+ * merged. splines=ortho + radius/style=rounded emits straight segments plus
72
+ * corner arcs as polylines (L), else the bezier itself.
73
+ * @see lib/common/emit.c:2583
74
+ */
75
+ protected emitPlainSpline(e: Edge, list: Bezier[], edraw: string[], job: RenderJob): void {
76
+ const radius = orthoRoundedRadius(e, job);
77
+ const obj = job.obj;
78
+ const origStyle = obj !== null ? [...obj.rawStyle] : [];
79
+ const multi = list.length > 1;
80
+ for (const bez of list) {
81
+ this.emitSplineBezier(bez, radius, edraw, job);
82
+ // arrow_gen (drawn to TDRAW/HDRAW below) resets the job style to
83
+ // defaultlinestyle ("solid") as a side effect. For a multi-bezier spline
84
+ // the NEXT segment's xdot_style re-emits the current rawstyle every call,
85
+ // so a solid edge picks up a bare `S 5 -solid`; C restores the edge's own
86
+ // styles afterward only when it has explicit ones. @see emit.c:2668-2677
87
+ if (obj !== null && multi && (bez.sflag || bez.eflag)) {
88
+ obj.rawStyle = origStyle.length > 0 ? origStyle : ['solid'];
89
+ }
90
+ }
91
+ if (obj !== null) obj.rawStyle = origStyle;
92
+ }
93
+
94
+ /** One bezier of a plain spline: rounded-ortho corner polylines when a radius
95
+ * applies and the segment has enough control points, else the bezier. */
96
+ protected emitSplineBezier(bez: Bezier, radius: number | null, edraw: string[], job: RenderJob): void {
97
+ const pts = bez.list.slice(0, bez.size);
98
+ const polys = radius !== null && bez.size >= 4 ? orthoRoundedPolylines(pts, radius) : [];
99
+ if (polys.length === 0) {
100
+ edraw.push(this.styleOp(job), this.penOp(job), xdotPoints('B', pts));
101
+ return;
102
+ }
103
+ for (const poly of polys) edraw.push(this.styleOp(job), this.penOp(job), xdotPoints('L', poly));
104
+ }
105
+
106
+ protected emitTaperedSpline(e: Edge, bz: Bezier | undefined, edraw: string[], job: RenderJob): void {
107
+ if (bz === undefined) return;
108
+ const radfunc = taperfun(e.attrs.get('dir'), isDirected(e.tail.root));
109
+ const verts = taper(bz, radfunc, job.obj?.penWidth ?? 1);
110
+ const edgeColor = job.obj?.penColor ?? { type: 'string', s: 'black' };
111
+ edraw.push(this.styleOp(job));
112
+ edraw.push(xdotPenColor(resolveRenderColor('transparent')));
113
+ edraw.push(xdotFillColor(edgeColor));
114
+ edraw.push(xdotPoints('P', verts));
115
+ }
116
+
117
+ /**
118
+ * Emit a split-along-length `;` multicolor edge spline: split each routed
119
+ * bezier along its arc length into one sub-curve per color segment, drawn
120
+ * under that segment's pen. Reuses the same split geometry
121
+ * (splitSplineByColor) as the SVG path. @see lib/common/emit.c:1975 multicolor
122
+ */
123
+ protected emitSplitSpline(
124
+ bzList: (Bezier | undefined)[],
125
+ colorAttr: string,
126
+ edraw: string[],
127
+ job: RenderJob,
128
+ ): { firstColor: string; endColor: string } {
129
+ const segs = parseSegs(colorAttr).segs;
130
+ const firstColor = segs[0]?.color ?? DEFAULT_COLOR;
131
+ let endColor = firstColor;
132
+ for (const bz of bzList) {
133
+ if (bz === undefined || bz.size < 4) continue;
134
+ const split = splitSplineByColor(bz.list.slice(0, bz.size), segs);
135
+ endColor = split.endColor;
136
+ for (const c of split.curves) {
137
+ edraw.push(this.styleOp(job), xdotPenColor(resolveRenderColor(c.color)), xdotPoints('B', c.points));
138
+ }
139
+ }
140
+ return { firstColor, endColor };
141
+ }
142
+
143
+ /**
144
+ * Emit the parallel-multicolor edge spline: one offset Bézier per color,
145
+ * offset SEP=2.0 perpendicular per pass — reusing the same offset geometry
146
+ * (buildOffsetLists/advanceTmpList) as the SVG parallel-edge path.
147
+ * @see lib/common/emit.c:2443 (parallel multicolor) / svg-parallel-edge.ts
148
+ */
149
+ protected emitParallelSpline(
150
+ bzList: (Bezier | undefined)[],
151
+ colorAttr: string,
152
+ numc: number,
153
+ edraw: string[],
154
+ job: RenderJob,
155
+ ): { headColor: string; tailColor: string } {
156
+ const segData = bzList.map((bz) =>
157
+ bz !== undefined && bz.size >= 4
158
+ ? buildOffsetLists(bz.list, (2 + numc) / 2)
159
+ : { offlist: [] as Point[], tmplist: [] as Point[] },
160
+ );
161
+ const colors = parseSegs(colorAttr).segs.map((s) => s.color ?? DEFAULT_COLOR);
162
+ for (const color of colors) {
163
+ const pen = xdotPenColor(resolveRenderColor(color));
164
+ for (const sd of segData) {
165
+ if (sd.offlist.length === 0) continue;
166
+ advanceTmpList(sd.tmplist, sd.offlist);
167
+ edraw.push(this.styleOp(job), pen, xdotPoints('B', sd.tmplist));
168
+ }
169
+ }
170
+ // C sets headcolor=tailcolor at cnum==0 and tailcolor again at cnum==1, so
171
+ // the HEAD arrow takes the first colour and the TAIL arrow the second (or
172
+ // the first when only one was given). Note this is the INVERSE of the `;`
173
+ // split branch above (tail=first, head=end). @see lib/common/emit.c:2493-2497
174
+ return {
175
+ headColor: colors[0] ?? DEFAULT_COLOR,
176
+ tailColor: colors[1] ?? colors[0] ?? DEFAULT_COLOR,
177
+ };
178
+ }
179
+
180
+ /** Emit one arrow's primitive ops into `buf` (pen/fill from the edge color).
181
+ * arrow_gen sets the default line style ("solid") and the edge penwidth
182
+ * before each primitive, so a non-default penwidth emits `S setlinewidth(N)`
183
+ * once (tracked per HDRAW/TDRAW state). @see arrows.c:arrow_gen */
184
+ protected emitArrows(
185
+ buf: string[], ops: ArrowDrawOp[] | undefined, job: RenderJob, state: EmitState,
186
+ penOverride?: string,
187
+ ): void {
188
+ if (ops === undefined) return;
189
+ const pen = penOverride !== undefined
190
+ ? resolveRenderColor(penOverride)
191
+ : job.obj?.penColor ?? { type: 'string', s: 'black' };
192
+ const pw = job.obj?.penWidth ?? 1;
193
+ for (const op of ops) {
194
+ if (Math.abs(pw - this.penwidth[state]!) >= 0.0005) {
195
+ this.penwidth[state] = pw;
196
+ buf.push(xdotStrOp('S ', 'setlinewidth(' + trimFixed3(pw) + ')'));
197
+ }
198
+ buf.push(xdotStrOp('S ', 'solid'));
199
+ buf.push(xdotPenColor(pen));
200
+ emitArrowOp(buf, op, pen);
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,32 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /** Shared shapes for the dot/xdot renderer and its agwrite serializer. */
4
+
5
+ import type { Graph } from '../../model/graph.js';
6
+ import type { Node } from '../../model/node.js';
7
+ import type { Edge } from '../../model/edge.js';
8
+
9
+ /** Accumulated xdot draw strings for one model object (agset side-table). */
10
+ export interface XdotDraws {
11
+ draw?: string;
12
+ ldraw?: string;
13
+ hdraw?: string;
14
+ tdraw?: string;
15
+ hldraw?: string;
16
+ tldraw?: string;
17
+ }
18
+
19
+ /** Mutable state threaded through the recursive agwrite serializer. */
20
+ export interface SerCtx {
21
+ out: string[];
22
+ /** subgraph → preorder number (write.c:subgdfs). */
23
+ preorder: Map<Graph, number>;
24
+ /** node → preorder of the subgraph it was last written in (node_last_written). */
25
+ nodeLW: Map<Node, number>;
26
+ /** edge → preorder of the subgraph it was last written in (edge_last_written). */
27
+ edgeLW: Map<Edge, number>;
28
+ /** objects whose attributes have already been emitted (AGATTRWF/attrs_written). */
29
+ attrsWritten: Set<Node | Edge>;
30
+ /** current indentation depth. */
31
+ level: number;
32
+ }