@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
package/src/render/dot.ts CHANGED
@@ -17,9 +17,7 @@ import type { Point } from '../model/geom.js';
17
17
  import type { TextSpan } from '../common/emit-types.js';
18
18
  import type { ArrowDrawOp } from '../common/arrows-types.js';
19
19
  import type { GVColor } from '../common/color.js';
20
- import { colorxlate } from '../common/color.js';
21
20
  import { resolveRenderColor } from './color-resolve.js';
22
- import { getGradientPoints } from './svg-gradient.js';
23
21
  import { edgeIsTapered } from './svg-tapered-edge.js';
24
22
  import { taper, taperfun } from '../common/taper.js';
25
23
  import { orthoRoundedRadius } from './svg-helpers.js';
@@ -29,651 +27,32 @@ import { parseGraphPad } from '../gvc/viewport.js';
29
27
  import { parseSegs } from '../common/multicolor.js';
30
28
  import { splitSplineByColor } from './svg-edge-split.js';
31
29
  import { buildOffsetLists, advanceTmpList } from '../common/edge-offset.js';
32
- import { IGNORED } from '../layout/dot/rank.js';
33
30
  import { edgeHasDrawableContent } from './svg.js';
34
31
  import type { Bezier } from '../model/geom.js';
35
32
  import type { RendererPlugin } from '../gvc/context.js';
33
+ import { EdgeDrawBase } from './dot/edge-draw.js';
34
+ export * from './dot/types.js';
35
+ export type { XdotDraws, SerCtx } from './dot/types.js';
36
+ import type { XdotDraws } from './dot/types.js';
36
37
  import { PenType, FillType } from '../gvc/context.js';
37
- import type { RenderJob, ObjState } from '../gvc/job.js';
38
- import { EmitState, toFixed2HalfEven } from '../gvc/job.js';
38
+ import type { RenderJob } from '../gvc/job.js';
39
+ import { EmitState } from '../gvc/job.js';
39
40
  import { renderEdgeLabels } from '../gvc/edge-labels.js';
40
- import { printfSig, printfFixed } from '../util/printf-round.js';
41
- import { POINTS_PER_INCH } from '../model/geom.js';
42
- import type { TextlabelT, FieldT, ShapeDesc } from '../common/types.js';
41
+ export * from './dot/xdot-ops.js';
42
+ export * from './dot/attrs.js';
43
+ import {
44
+ NON_LINE_STYLES, XDOT_VERSION, agcanonEscape, escBackslash, gfmt5, linearGradientOp,
45
+ lpStr, makeXbufs, radialGradientOp, trimFixed3, utf8Len, xdotFillColor, xdotFont,
46
+ xdotId, xdotNum, xdotPenColor, xdotPoint, xdotPoints, xdotStrOp,
47
+ } from './dot/xdot-ops.js';
48
+ import { isDirected } from './dot/attrs.js';
43
49
 
44
- // ---------------------------------------------------------------------------
45
- // Constants
46
- // ---------------------------------------------------------------------------
47
-
48
- const XDOT_VERSION = '1.7';
49
-
50
- /** Max magnitude cap — matches maxnegnum in C source. */
51
- const MAX_NEGNUM = 999999999999999.99;
52
-
53
- /** Near-zero suppression threshold (same as gvprintdouble). */
54
- const NEAR_ZERO = 0.005;
55
-
56
- /** Indices 8/9 alias 1; 10/11 alias 5. NUM_XBUFS covers 0–7. */
57
- const NUM_XBUFS = 8;
58
-
59
- /**
60
- * Style tokens that never become an xdot `S` op: `filled`/`bold`/`setlinewidth`
61
- * are filtered by xdot_style itself; the polygon/fill styles are consumed by the
62
- * shape/fill code before gvrender_set_style, so they never reach the render
63
- * style. Only line styles (solid/dashed/dotted + unknown) emit an `S` op.
64
- * @see plugin/core/gvrender_core_dot.c:184 xdot_style · lib/common/shapes.c
65
- */
66
- const NON_LINE_STYLES: ReadonlySet<string> = new Set([
67
- 'filled', 'bold', 'rounded', 'diagonals', 'striped', 'wedged',
68
- 'invis', 'invisible', 'radial',
69
- ]);
70
-
71
- // ---------------------------------------------------------------------------
72
- // printNum — @see lib/gvc/gvdevice.c:gvprintnum
73
- // ---------------------------------------------------------------------------
74
-
75
- /**
76
- * Strip trailing zeros (and the decimal point) from a toFixed(3) string,
77
- * then collapse a leading "0." or "-0." prefix.
78
- */
79
- function trimAndStrip(s: string): string {
80
- const dot = s.indexOf('.');
81
- if (dot < 0) return s;
82
- let end = s.length;
83
- while (end > dot + 1 && s[end - 1] === '0') end--;
84
- if (s[end - 1] === '.') end--;
85
- const t = s.slice(0, end);
86
- if (t.startsWith('0.')) return t.slice(1);
87
- if (t.startsWith('-0.')) return '-' + t.slice(2);
88
- return t;
89
- }
90
-
91
- /**
92
- * Convert a double to compact string form for DOT/XDOT output.
93
- *
94
- * Rules (porting gvprintnum from lib/gvc/gvdevice.c):
95
- * - |n| > MAX_NEGNUM → clamped to ±MAX_NEGNUM
96
- * - |n| < NEAR_ZERO → "0" (suppresses -0)
97
- * - 3 decimal places, trailing zeros and point stripped
98
- * - Leading "0." collapsed to "." (e.g. 0.5 → ".5")
99
- *
100
- * Exported for json.ts (T30) and map.ts (T31).
101
- *
102
- * @see lib/gvc/gvdevice.c:gvprintnum
103
- */
104
- export function printNum(n: number): string {
105
- if (Math.abs(n) > MAX_NEGNUM) {
106
- return n < 0 ? String(-MAX_NEGNUM) : String(MAX_NEGNUM);
107
- }
108
- if (Math.abs(n) < NEAR_ZERO) return '0';
109
- return trimAndStrip(n.toFixed(3));
110
- }
111
-
112
- // ---------------------------------------------------------------------------
113
- // XDot buffer management
114
- // ---------------------------------------------------------------------------
115
-
116
- /**
117
- * Create a 12-slot xbufs array with the canonical aliasing.
118
- *
119
- * Indices 8–9 (NDraw, EDraw) alias index 1 (CDraw).
120
- * Indices 10–11 (NLabel, ELabel) alias index 5 (CLabel).
121
- *
122
- * @see plugin/core/gvrender_core_dot.c:xbufs
123
- */
124
- export function makeXbufs(): string[][] {
125
- const bufs: string[][] = Array.from({ length: NUM_XBUFS + 4 }, () => []);
126
- bufs[EmitState.NDraw] = bufs[EmitState.CDraw]!;
127
- bufs[EmitState.EDraw] = bufs[EmitState.CDraw]!;
128
- bufs[EmitState.NLabel] = bufs[EmitState.CLabel]!;
129
- bufs[EmitState.ELabel] = bufs[EmitState.CLabel]!;
130
- return bufs;
131
- }
132
-
133
- // ---------------------------------------------------------------------------
134
- // XDOT op helpers — @see plugin/core/gvrender_core_dot.c
135
- // ---------------------------------------------------------------------------
136
-
137
- /**
138
- * Format one xdot draw-op number: 2 decimals, trailing zeros and point trimmed
139
- * — mirroring xdot_fmt_num ("%.02f" + agxbuf_trim_zeros). Distinct from
140
- * `printNum` (used for the DOT `pos`/`bb`/`width`/`height` attributes), which
141
- * keeps more precision; xdot's DRAW ops are emitted at 2 dp by the C engine.
142
- * @see plugin/core/gvrender_core_dot.c:126 xdot_fmt_num
143
- */
144
- export function xdotNum(v: number): string {
145
- // Round half-to-even like C's printf %.02f (FE_TONEAREST); JS toFixed rounds
146
- // half-away-from-zero, which diverges at exact .xx5 ties (2323.125 → native
147
- // 2323.12, not 2323.13). @see lib/gvc/gvdevice.c gvprintdouble
148
- let s = toFixed2HalfEven(v);
149
- if (s.indexOf('.') >= 0) {
150
- let end = s.length;
151
- while (end > 0 && s[end - 1] === '0') end--;
152
- if (s[end - 1] === '.') end--;
153
- s = s.slice(0, end);
154
- }
155
- return s === '-0' ? '0' : s;
156
- }
157
-
158
- /**
159
- * Format a single xdot point "x y ". xdot is y-up: `Y_invert` defaults false, so
160
- * `yDir(y, yOff)` returns `y` unchanged for xdot (only `-Ty` plain/dot invert).
161
- * The layout coordinate passes through with NO inversion — unlike the SVG path.
162
- * @see lib/common/output.c:36 yDir · plugin/core/gvrender_core_dot.c:132 xdot_point
163
- */
164
- export function xdotPoint(p: Point): string {
165
- return xdotNum(p.x) + ' ' + xdotNum(p.y) + ' ';
166
- }
167
-
168
- /** Format N points preceded by opcode and count: "<c> <n> x0 y0 x1 y1 …". */
169
- export function xdotPoints(c: string, pts: Point[]): string {
170
- let s = c + ' ' + String(pts.length) + ' ';
171
- for (const p of pts) s += xdotPoint(p);
172
- return s;
173
- }
174
-
175
- /**
176
- * UTF-8 byte length of a string — the value C's `xdot_str` writes as the length
177
- * prefix (`strlen(s)` over the UTF-8 bytes), NOT the JS UTF-16 code-unit count.
178
- * A label like `ÿ` (U+00FF) is 2 UTF-8 bytes, so its `T`/`F` op prefix is 2.
179
- * @see plugin/core/gvrender_core_dot.c:83 xdot_str_xbuf (`%zu`, strlen)
180
- */
181
- export function utf8Len(s: string): number {
182
- let n = 0;
183
- for (let i = 0; i < s.length; i++) {
184
- const c = s.charCodeAt(i);
185
- if (c < 0x80) n += 1;
186
- else if (c < 0x800) n += 2;
187
- else if (c >= 0xd800 && c <= 0xdbff) { n += 4; i++; } // surrogate pair → 4 bytes
188
- else n += 3;
189
- }
190
- return n;
191
- }
192
-
193
- /** Clamp a normalized [0,1] float channel to a 0-255 byte (round-to-nearest). */
194
- function chanByte(v: number): number {
195
- return Math.round(Math.max(0, Math.min(1, v)) * 255);
196
- }
197
-
198
- /**
199
- * Resolve a GVColor to RGBA bytes — the value the C xdot callbacks read from
200
- * `job->obj->pencolor.u.rgba` (already resolved by gvrender_set_pencolor). A
201
- * plain `rgba` passes through; named/hex/HSV specs run through colorxlate; a
202
- * `none` color is fully transparent black (callers gate on PEN_NONE).
203
- */
204
- export function gvColorRgba(c: GVColor): [number, number, number, number] {
205
- if (c.type === 'rgba') return [chanByte(c.r), chanByte(c.g), chanByte(c.b), chanByte(c.a)];
206
- // A `none`/transparent paint emits graphviz's "transparent" bytes (ff ff fe 00),
207
- // matching native's `#fffffe00` — not fully-zero black. @see colxlate.c transparent
208
- if (c.type === 'none') return [0xff, 0xff, 0xfe, 0x00];
209
- const out: GVColor = { type: 'rgba', r: 0, g: 0, b: 0, a: 0 };
210
- colorxlate(c.type === 'string' ? c.s : '', out, 'rgba');
211
- return out.type === 'rgba'
212
- ? [chanByte(out.r), chanByte(out.g), chanByte(out.b), chanByte(out.a)]
213
- : [0, 0, 0, 255];
214
- }
215
-
216
- /**
217
- * Bare xdot color body (no `c `/`C ` prefix): the CONSTANT length prefix
218
- * (7 for `#rrggbb`, 9 for `#rrggbbaa`) plus `-#hex`, the alpha byte present only
219
- * when not fully opaque. Used both by the color ops and by gradient color stops.
220
- * @see plugin/core/gvrender_core_dot.c:99 xdot_str_color_xbuf
221
- */
222
- export function xdotColorBody(rgba: [number, number, number, number]): string {
223
- const hx = (n: number): string => n.toString(16).padStart(2, '0');
224
- const body = '#' + hx(rgba[0]) + hx(rgba[1]) + hx(rgba[2]);
225
- return rgba[3] === 0xff ? '7 -' + body : '9 -' + body + hx(rgba[3]);
226
- }
227
-
228
- /**
229
- * Format an xdot color op ("c "/"C ") from RGBA bytes.
230
- * @see plugin/core/gvrender_core_dot.c:99 xdot_str_color_xbuf
231
- */
232
- export function xdotColorOp(prefix: 'c ' | 'C ', rgba: [number, number, number, number]): string {
233
- return prefix + xdotColorBody(rgba) + ' ';
234
- }
235
-
236
- /**
237
- * Build the linear-gradient `C len -[x0 y0 x1 y1 2 <stops>]` fill op. Endpoints
238
- * come from getGradientPoints (the same geometry the SVG gradient uses); stops
239
- * are (frac,fill)/(frac,stop) when frac>0, else (0,fill)/(1,stop). Shared by
240
- * node/cluster gradient fills and the graph-background gradient.
241
- * @see plugin/core/gvrender_core_dot.c:544-598 xdot_gradient_fillcolor
242
- */
243
- export function linearGradientOp(
244
- pts: Point[],
245
- fillColor: GVColor,
246
- stopColor: GVColor,
247
- frac: number,
248
- angleDeg: number,
249
- ): string {
250
- // isRHS=true: native y-up coords, matching C's get_gradient_points(A,G,n,angle,2)
251
- // for the xdot device path (the SVG path uses isRHS=false + a container flip).
252
- const { g0, g1 } = getGradientPoints(pts, (angleDeg * Math.PI) / 180, false, true);
253
- const inner =
254
- '[' + xdotNum(g0.x) + ' ' + xdotNum(g0.y) + ' ' + xdotNum(g1.x) + ' ' + xdotNum(g1.y) +
255
- ' 2 ' + gradientStops(fillColor, stopColor, frac) + ']';
256
- return 'C ' + String(utf8Len(inner)) + ' -' + inner + ' ';
257
- }
258
-
259
- /** The `<frac> <colorbody>` stop pairs for a gradient (frac>0 vs the 0/1 form). */
260
- function gradientStops(fillColor: GVColor, stopColor: GVColor, frac: number): string {
261
- const fill = gvColorRgba(fillColor);
262
- const stop = gvColorRgba(stopColor);
263
- const stops: Array<[number, [number, number, number, number]]> =
264
- frac > 0 ? [[frac, fill], [frac, stop]] : [[0, fill], [1, stop]];
265
- return stops.map(([f, c]) => trimFixed3(f) + ' ' + xdotColorBody(c)).join(' ');
266
- }
267
-
268
- /**
269
- * Build the radial-gradient `C len -(c1x c1y r1 c2x c2y r2 2 <stops>)` fill op.
270
- * Reuses getGradientPoints (radial) for the center/radii, un-negating its SVG
271
- * y. r1 = outerR/4, r2 = outerR; c2 is the center, c1 the center offset by r1
272
- * along the gradient angle (== center when angle 0).
273
- * @see plugin/core/gvrender_core_dot.c:562-585 xdot_gradient_fillcolor (radial)
274
- */
275
- export function radialGradientOp(
276
- pts: Point[],
277
- fillColor: GVColor,
278
- stopColor: GVColor,
279
- frac: number,
280
- angleDeg: number,
281
- ): string {
282
- const rad = (angleDeg * Math.PI) / 180;
283
- // isRHS=true: native y-up coords, matching C's get_gradient_points(A,G,n,0,3).
284
- const gp = getGradientPoints(pts, rad, true, true);
285
- const cx = gp.g0.x;
286
- const cy = gp.g0.y;
287
- const r1 = gp.g1.x;
288
- const r2 = gp.g1.y;
289
- const c1x = angleDeg === 0 ? cx : cx + r1 * Math.cos(rad);
290
- const c1y = angleDeg === 0 ? cy : cy + r1 * Math.sin(rad);
291
- const inner =
292
- '(' + xdotNum(c1x) + ' ' + xdotNum(c1y) + ' ' + xdotNum(r1) + ' ' +
293
- xdotNum(cx) + ' ' + xdotNum(cy) + ' ' + xdotNum(r2) + ' 2 ' +
294
- gradientStops(fillColor, stopColor, frac) + ')';
295
- return 'C ' + String(utf8Len(inner)) + ' -' + inner + ' ';
296
- }
297
-
298
- /** Pen ("c ") color op from a resolved GVColor. */
299
- export function xdotPenColor(c: GVColor): string {
300
- return xdotColorOp('c ', gvColorRgba(c));
301
- }
302
-
303
- /** Fill ("C ") color op from a resolved GVColor. */
304
- export function xdotFillColor(c: GVColor): string {
305
- return xdotColorOp('C ', gvColorRgba(c));
306
- }
307
-
308
- /**
309
- * Build the xdot "F size len -name " font op. Mirrors xdot_textspan's `F` +
310
- * `xdot_str(job, "", font->name)` — the length prefix is the byte length of the
311
- * face name. @see plugin/core/gvrender_core_dot.c:498 xdot_textspan
312
- */
313
- export function xdotFont(size: number, name: string): string {
314
- return 'F ' + xdotNum(size > 0 ? size : 0) + ' ' + String(utf8Len(name)) + ' -' + name + ' ';
315
- }
316
-
317
- /**
318
- * Build an xdot length-prefixed string op ("S "/"" prefix): "<pfx><len> -<s> ".
319
- * @see plugin/core/gvrender_core_dot.c:83 xdot_str_xbuf
320
- */
321
- function xdotStrOp(prefix: string, s: string): string {
322
- return prefix + String(utf8Len(s)) + ' -' + s + ' ';
323
- }
324
-
325
- /**
326
- * Quote a DOT identifier unless it is a bare id or numeral, mirroring agwrite's
327
- * agcanonStr so the serialized graph reparses (the comparator reparses both
328
- * sides). Only `"` is escaped (→ `\"`); a `\` is left as-is — it is already the
329
- * start of a stored escape like `\n`/`\l` that agcanonStr keeps verbatim, so
330
- * doubling it (`\\n`) would change the name (`a\n(b\n"c")` must stay `a\n…`, not
331
- * `a\\n…`). Over-quoting a value native leaves bare is harmless: both parse to
332
- * the same name. @see lib/cgraph/write.c:_agstrcanon (escapes '"', keeps '\')
333
- */
334
- export function xdotId(s: string): string {
335
- if (/^[A-Za-z_][A-Za-z_0-9]*$/.test(s)) return s;
336
- if (/^-?(\.[0-9]+|[0-9]+(\.[0-9]*)?)$/.test(s)) return s;
337
- return '"' + s.replace(/"/g, '\\"') + '"';
338
- }
339
-
340
- /**
341
- * Format a number as C's `%.5g` — 5 significant figures, trailing zeros and
342
- * point trimmed, switching to `e±NN` (min 2 exponent digits) when the exponent
343
- * is < -4 or ≥ 5. Native writes the DOT `pos`/`bb`/`width`/`height` attributes
344
- * with `%.5g` (output.c:71/294/302), so large coordinates round to 5 sig figs
345
- * (`2219962` → `2.2201e+06`); `printNum`'s fixed 3 dp keeps too much precision.
346
- *
347
- * Uses {@link printfSig} rather than `Number.prototype.toPrecision(5)` so
348
- * exact-halfway values (e.g. 1399.25) round half-to-even like C's snprintf,
349
- * not half-away-from-zero like `toPrecision`. @see docs proven case
350
- * graphs-b786 (circo): pos coordinate 1399.25 → native "1399.2", `toPrecision`
351
- * gave "1399.3".
352
- * @see lib/common/output.c:71 (agxbprint "%.5g")
353
- */
354
- export function gfmt5(v: number): string {
355
- if (!Number.isFinite(v)) return String(v);
356
- if (v === 0) return '0';
357
- let s = printfSig(v, 5);
358
- const e = s.indexOf('e');
359
- if (e >= 0) {
360
- let mant = s.slice(0, e);
361
- if (mant.indexOf('.') >= 0) mant = mant.replace(/0+$/, '').replace(/\.$/, '');
362
- const ei = parseInt(s.slice(e + 1), 10);
363
- return mant + 'e' + (ei < 0 ? '-' : '+') + String(Math.abs(ei)).padStart(2, '0');
364
- }
365
- if (s.indexOf('.') >= 0) s = s.replace(/0+$/, '').replace(/\.$/, '');
366
- return s;
367
- }
368
-
369
- /**
370
- * Format a number as C's `%.2f` — fixed 2 decimals, half-to-even on exact ties.
371
- * `attach_attrs` writes the graph-label size attributes `lwidth`/`lheight` in
372
- * INCHES with `%.2f`, unlike every other computed attribute (which uses `%.5g`).
373
- * @see lib/common/output.c:244-247 (agxbprint "%.2f", PS2INCH)
374
- */
375
- function gfmt2(v: number): string {
376
- return printfFixed(v, 2);
377
- }
378
-
379
- /**
380
- * Format a label position as the `x,y` pair every computed `*_lp` attribute
381
- * uses: both coordinates at `%.5g`. C passes y through `yDir()`, which is the
382
- * identity unless `Y_invert` — and `Y_invert` is only set by `-Ty` (plain/dot),
383
- * never for xdot — so no inversion is applied here, exactly as the existing
384
- * `pos`/`bb` emission does.
385
- * @see lib/common/output.c:35 yDir · lib/common/output.c:241 (agxbprint "%.5g,%.5g")
386
- */
387
- function lpStr(p: Point): string {
388
- return gfmt5(p.x) + ',' + gfmt5(p.y);
389
- }
390
-
391
- /**
392
- * C's `attach_attrs` edge loop skips IGNORED edges and edges with no spline
393
- * (`ED_spl(e) == NULL`) via `continue`, so such an edge is attached NEITHER
394
- * `pos` NOR any of `lp`/`xlp`/`head_lp`/`tail_lp` — even when it carries a
395
- * label. All five attributes therefore share this one gate.
396
- * @see lib/common/output.c:349-353
397
- */
398
- function edgeAttrsAttached(e: Edge): boolean {
399
- return e.info.edge_type !== IGNORED && e.info.spl != null;
400
- }
401
-
402
- /**
403
- * Append the leaf-field rectangles of a record node, mirroring the recursion in
404
- * `set_record_rects`: a field with sub-fields contributes nothing itself, while
405
- * a LEAF field (`n_flds == 0`) contributes its box translated by the node centre
406
- * as `llx,lly,urx,ury` at `%.5g`. C emits each field followed by a space and
407
- * then pops the trailing one — joining with a single space is equivalent.
408
- * @see lib/common/output.c:215 set_record_rects
409
- */
410
- function appendRecordRects(n: Node, f: FieldT, out: string[]): void {
411
- const c = n.info.coord;
412
- if (f.n_flds === 0) {
413
- out.push(
414
- gfmt5(f.b.ll.x + c.x) + ',' + gfmt5(f.b.ll.y + c.y) + ',' +
415
- gfmt5(f.b.ur.x + c.x) + ',' + gfmt5(f.b.ur.y + c.y),
416
- );
417
- }
418
- for (let i = 0; i < f.n_flds; i++) appendRecordRects(n, f.fld![i], out);
419
- }
420
-
421
- /**
422
- * Echo an attribute that `attach_attrs_and_arrows` did NOT overwrite.
423
- *
424
- * Every computed attribute is `agset` behind a gate (`rects` only for a
425
- * `record` shape; an edge's `lp` only for a routed edge that HAS a label;
426
- * a graph's `lp` only when `GD_label(g)` exists...). When the gate FAILS, C
427
- * simply does not write — so the slot keeps whatever the INPUT file parsed
428
- * into it, and `agwrite` (which serializes the whole attribute table, not
429
- * just the fields layout computed) prints that stale value verbatim. This is
430
- * highly visible under patchwork, which forces every shape to `box` and routes
431
- * no edges: on a re-fed dot output (most of the corpus) native echoes back the
432
- * `rects` / `lp` a *previous* dot run wrote, in that run's coordinate space.
433
- *
434
- * cgraph interns attribute strings, so a value equal to the declared (empty)
435
- * default is the SAME pointer as the default and write.c's
436
- * `data->str[sym->id] != sym->defval` test drops it — hence an empty value is
437
- * never printed.
438
- * @see lib/cgraph/write.c:427 write_nondefault_attrs · lib/common/output.c:270
439
- */
440
- function echoAttr(attrs: Map<string, string>, key: string): string[] {
441
- const v = attrs.get(key);
442
- if (v === undefined || v.length === 0) return [];
443
- return [key + '="' + agcanonEscape(v) + '"'];
444
- }
445
-
446
- /** `echoAttr`'s result as a space-prefixed suffix for the string-built node block. */
447
- function pfx(parts: string[]): string {
448
- return parts.length === 0 ? '' : ' ' + parts.join(' ');
449
- }
450
-
451
- /**
452
- * The `lp`/`lwidth`/`lheight` triple for a graph or cluster, in C's order.
453
- * `rec_attach_bb` attaches them to the root graph AND recursively to every
454
- * cluster, but ONLY when the graph carries a label with non-empty text
455
- * (`GD_label(g) && GD_label(g)->text[0]`) — an absent or empty-text label emits
456
- * none of the three. `lp` is the label centre in points (`%.5g`); `lwidth` and
457
- * `lheight` are the label's `dimen` converted to inches and written `%.2f`.
458
- * When the gate fails, the input's own values survive and are echoed: patchwork
459
- * and osage never build a cluster label object, so a re-fed dot file's cluster
460
- * `lp` comes straight back out.
461
- * @see lib/common/output.c:239-248 rec_attach_bb
462
- */
463
- function graphLabelAttrs(g: Graph): string[] {
464
- const label = g.info.label as TextlabelT | undefined;
465
- if (!label || label.text.length === 0) {
466
- return [
467
- ...echoAttr(g.attrs, 'lp'),
468
- ...echoAttr(g.attrs, 'lwidth'),
469
- ...echoAttr(g.attrs, 'lheight'),
470
- ];
471
- }
472
- return [
473
- 'lp="' + lpStr(label.pos) + '"',
474
- 'lwidth=' + gfmt2(label.dimen.x / POINTS_PER_INCH),
475
- 'lheight=' + gfmt2(label.dimen.y / POINTS_PER_INCH),
476
- ];
477
- }
478
-
479
- // ---------------------------------------------------------------------------
480
- // DOT attribute helpers
481
- // ---------------------------------------------------------------------------
482
-
483
- /** Format node attributes for DOT output. */
484
- export function formatNodeAttrs(n: Node): string {
485
- const info = n.info;
486
- const x = printNum(info.coord.x);
487
- const y = printNum(info.coord.y);
488
- const w = printNum(info.width);
489
- const h = printNum(info.height);
490
- const parts: string[] = [`pos="${x},${y}"`, `width=${w}`, `height=${h}`];
491
- n.attrs.forEach((v, k) => {
492
- if (k !== 'pos' && k !== 'width' && k !== 'height') {
493
- parts.push(`${k}="${v}"`);
494
- }
495
- });
496
- return parts.join(' ');
497
- }
498
-
499
- /**
500
- * Format edge spline points for the DOT `pos` attribute. Per bezier: the start
501
- * endpoint `s,sp` when `sflag` set, then the end endpoint `e,ep` when `eflag`
502
- * set, then `bez.size` control points — all at `%.5g`, exactly as native's
503
- * spline serialization. @see lib/common/output.c:357-372
504
- */
505
- export function formatEdgePos(e: Edge): string {
506
- const spl = e.info.spl;
507
- if (!spl || spl.list.length === 0) return '';
508
- // Native's pos loop skips IGNORED edges (output.c:350) — concentrate merges
509
- // an edge into its opposite and marks the absorbed one IGNORED; it is still
510
- // drawn (has _draw_) but carries no `pos`. @see lib/common/output.c:349-353
511
- if (e.info.edge_type === IGNORED) return '';
512
- const parts: string[] = [];
513
- for (const bez of spl.list) {
514
- if (bez.sflag) parts.push('s,' + gfmt5(bez.sp.x) + ',' + gfmt5(bez.sp.y));
515
- if (bez.eflag) parts.push('e,' + gfmt5(bez.ep.x) + ',' + gfmt5(bez.ep.y));
516
- const pts = bez.list.slice(0, bez.size);
517
- for (const p of pts) parts.push(gfmt5(p.x) + ',' + gfmt5(p.y));
518
- }
519
- return 'pos="' + parts.join(' ') + '"';
520
- }
521
-
522
- /** Return the edge connector token. */
523
- export function edgeConnector(directed: boolean): string {
524
- return directed ? '->' : '--';
525
- }
526
-
527
- /** Return true if the graph is directed or strict-directed. */
528
- export function isDirected(g: Graph): boolean {
529
- return g.kind === 'directed' || g.kind === 'strict-directed';
530
- }
531
-
532
- // ---------------------------------------------------------------------------
533
- // Shared graph header helper
534
- // ---------------------------------------------------------------------------
535
-
536
- /** Emit the opening digraph/graph line and bb attribute. */
537
- export function dotBeginGraphHeader(g: Graph, job: RenderJob): void {
538
- const kw = isDirected(g) ? 'digraph' : 'graph';
539
- job.write(kw + ' ' + g.name + ' {\n');
540
- const bb = g.info.bb;
541
- const bbStr = printNum(bb.ll.x) + ',' + printNum(bb.ll.y) + ','
542
- + printNum(bb.ur.x) + ',' + printNum(bb.ur.y);
543
- job.write('\tgraph [bb="' + bbStr + '"];\n');
544
- }
545
-
546
- // ---------------------------------------------------------------------------
547
- // DotRenderer
548
- // ---------------------------------------------------------------------------
549
-
550
- /**
551
- * DOT format renderer — writes graphviz DOT language with layout attributes.
552
- *
553
- * @see plugin/core/gvrender_core_dot.c:dot_engine
554
- */
555
- export class DotRenderer implements RendererPlugin {
556
- readonly type = 'dot';
557
- /** @see plugin/core/gvplugin_core.c registration table */
558
- readonly quality = 0;
559
-
560
- beginGraph(g: Graph, job: RenderJob): void {
561
- dotBeginGraphHeader(g, job);
562
- }
563
-
564
- endGraph(_g: Graph, job: RenderJob): void {
565
- job.write('}\n');
566
- }
567
-
568
- beginNode(_n: Node, _job: RenderJob): void { /* no-op */ }
569
-
570
- endNode(n: Node, job: RenderJob): void {
571
- const attrs = formatNodeAttrs(n);
572
- job.write('\t' + n.name + ' [' + attrs + '];\n');
573
- }
574
-
575
- beginEdge(_e: Edge, _job: RenderJob): void { /* no-op */ }
576
-
577
- endEdge(e: Edge, job: RenderJob): void {
578
- const directed = isDirected(e.tail.root);
579
- const conn = edgeConnector(directed);
580
- const posAttr = formatEdgePos(e);
581
- const attrList = posAttr ? ' [' + posAttr + ']' : '';
582
- job.write('\t' + e.tail.name + ' ' + conn + ' ' + e.head.name + attrList + ';\n');
583
- }
584
-
585
- textspan(_pos: Point, _span: TextSpan, _job: RenderJob): void { /* no-op */ }
586
- ellipse(_c: Point, _rx: number, _ry: number, _f: boolean, _j: RenderJob): void { /* no-op */ }
587
- polygon(_pts: Point[], _filled: boolean, _job: RenderJob): void { /* no-op */ }
588
- bezier(_pts: Point[], _filled: boolean, _job: RenderJob): void { /* no-op */ }
589
- polyline(_pts: Point[], _job: RenderJob): void { /* no-op */ }
590
- }
591
50
 
592
51
  // ---------------------------------------------------------------------------
593
52
  // XdotRenderer
594
53
  // ---------------------------------------------------------------------------
595
54
 
596
- /** Accumulated xdot draw strings for one model object (agset side-table). */
597
- export interface XdotDraws {
598
- draw?: string;
599
- ldraw?: string;
600
- hdraw?: string;
601
- tdraw?: string;
602
- hldraw?: string;
603
- tldraw?: string;
604
- }
605
-
606
- /** Mutable state threaded through the recursive agwrite serializer. */
607
- interface SerCtx {
608
- out: string[];
609
- /** subgraph → preorder number (write.c:subgdfs). */
610
- preorder: Map<Graph, number>;
611
- /** node → preorder of the subgraph it was last written in (node_last_written). */
612
- nodeLW: Map<Node, number>;
613
- /** edge → preorder of the subgraph it was last written in (edge_last_written). */
614
- edgeLW: Map<Edge, number>;
615
- /** objects whose attributes have already been emitted (AGATTRWF/attrs_written). */
616
- attrsWritten: Set<Node | Edge>;
617
- /** current indentation depth. */
618
- level: number;
619
- }
620
-
621
- /**
622
- * Escape backslashes in a LABEL draw string — C's put_escaping_backslashes,
623
- * applied to the `_ldraw_`/`_hldraw_`/`_tldraw_` buffers (not the shape draws)
624
- * before agset. A literal `\` in a label's text (e.g. `WXYZ\nabc`) becomes `\\`
625
- * so it survives the DOT string round-trip; the T-op byte-length prefix stays on
626
- * the UNescaped text. @see plugin/core/gvrender_core_dot.c:218 put_escaping_backslashes
627
- */
628
- function escBackslash(s: string): string {
629
- return s.replace(/\\/g, '\\\\');
630
- }
631
55
 
632
- /** True if s[i..] starts an escape sequence agcanonStr keeps verbatim: a `\`
633
- * followed by one of E G H L N T l n r \ ". @see lib/cgraph/write.c:is_escape */
634
- function isEscapeSeq(s: string, i: number): boolean {
635
- if (s[i] !== '\\') return false;
636
- const n = s[i + 1];
637
- return n === 'E' || n === 'G' || n === 'H' || n === 'L' || n === 'N' || n === 'T'
638
- || n === 'l' || n === 'n' || n === 'r' || n === '\\' || n === '"';
639
- }
640
-
641
- /**
642
- * Escape a value for a DOT attribute exactly as agwrite's agcanonStr does: a `"`
643
- * becomes `\"` ONLY when it is not already part of an escape sequence, so an
644
- * existing `\"` or `\\` in the value is passed through verbatim rather than
645
- * double-escaped. For an already-backslash-doubled value (node/edge/graph labels,
646
- * post put_escaping_backslashes) this is identical to a naive `"`→`\"` — every
647
- * `"` follows a doubled `\\`, so none are ever part_of_escape. For raw cluster
648
- * labels (agset, no put_escaping) it preserves the source `\"`/`\\` unchanged.
649
- * @see lib/cgraph/write.c:_agstrcanon (135-167)
650
- */
651
- function agcanonEscape(s: string): string {
652
- let out = '';
653
- let partOfEscape = false;
654
- for (let i = 0; i < s.length; i++) {
655
- const c = s[i]!;
656
- if (c === '"' && !partOfEscape) {
657
- out += '\\';
658
- } else if (!partOfEscape && isEscapeSeq(s, i)) {
659
- partOfEscape = true;
660
- } else {
661
- partOfEscape = false;
662
- }
663
- out += c;
664
- }
665
- return out;
666
- }
667
-
668
- /** Trim a "%.3f" fixed string like C's agxbuf_trim_zeros (trailing 0s + dot). */
669
- function trimFixed3(v: number): string {
670
- let s = v.toFixed(3);
671
- if (s.indexOf('.') < 0) return s;
672
- let end = s.length;
673
- while (end > 0 && s[end - 1] === '0') end--;
674
- if (s[end - 1] === '.') end--;
675
- return s.slice(0, end);
676
- }
677
56
 
678
57
  /**
679
58
  * XDOT format renderer — mirrors plugin/core/gvrender_core_dot.c's xdot engine.
@@ -690,29 +69,39 @@ function trimFixed3(v: number): string {
690
69
  *
691
70
  * @see plugin/core/gvrender_core_dot.c xdot_begin_graph / xdot_end_graph
692
71
  */
693
- export class XdotRenderer implements RendererPlugin {
694
- readonly type = 'xdot';
72
+ export class XdotRenderer extends EdgeDrawBase implements RendererPlugin {
73
+ readonly type: string = 'xdot';
695
74
  /** @see plugin/core/gvplugin_core.c registration table */
696
75
  readonly quality = 0;
697
76
 
77
+ /**
78
+ * Whether this format attaches the xdot draw attributes before agwrite.
79
+ * Mirrors C's `job->render.id` switch: FORMAT_DOT runs `attach_attrs(g)` then
80
+ * `agwrite`, while FORMAT_XDOT runs `attach_attrs_and_arrows` +
81
+ * `xdot_begin_graph`/`xdot_end_graph` and *then* the same `agwrite`. The
82
+ * serializer below is that shared `agwrite`; this flag is the only difference.
83
+ * @see plugin/core/gvrender_core_dot.c:404,418,475,482
84
+ */
85
+ protected override readonly emitDraws: boolean = true;
86
+
87
+ /** Draw strings for `obj`, or undefined in a format that emits none (`-Tdot`).
88
+ * Single gate so the xdot path cannot drift from the dot path. */
89
+ protected drawsOf(obj: Node | Edge | Graph): XdotDraws | undefined {
90
+ return this.emitDraws ? this.draws.get(obj) : undefined;
91
+ }
92
+
698
93
  /** Per-render draw buffers. Indices 8/9 alias 1; 10/11 alias 5. */
699
94
  private bufs: string[][] = makeXbufs();
700
- /** setlinewidth state per emit_state. @see gvrender_core_dot.c penwidth[] */
701
- private penwidth: number[] = new Array(12).fill(1);
702
95
  /** text-flag state per emit_state. @see gvrender_core_dot.c textflags[] */
703
96
  private textflags: number[] = new Array(12).fill(0);
704
97
  /** Accumulated draw strings keyed by model object (C: agset on the object). */
705
98
  private draws = new Map<Node | Edge | Graph, XdotDraws>();
706
- /** Clusters in render order — the graphs `endCluster` was actually called for
707
- * (from `renderClusters`/`GD_clust`), which is NOT always reachable via
708
- * `g.subgraphs`, so it is tracked here rather than re-walked. */
709
- private clusters: Graph[] = [];
710
99
  /** edge_in_box gate for the current edge: emit_edge draws nothing (spline OR
711
100
  * label) for an edge whose content is outside job->clip. Mirrors svg.ts's
712
101
  * edgeGroupOpen. @see lib/common/emit.c:emit_edge (3039) */
713
102
  private edgeDrawable = true;
714
103
 
715
- beginGraph(g: Graph, _job: RenderJob): void {
104
+ beginGraph(_g: Graph, _job: RenderJob): void {
716
105
  this.bufs = makeXbufs();
717
106
  this.penwidth = new Array(12).fill(1);
718
107
  this.textflags = new Array(12).fill(0);
@@ -735,7 +124,7 @@ export class XdotRenderer implements RendererPlugin {
735
124
  // @see gvrender_core_dot.c:427 xdot_end_graph
736
125
  const gd = this.flush(EmitState.GDraw);
737
126
  const gl = this.flush(EmitState.GLabel);
738
- if (gd || gl) {
127
+ if (this.emitDraws && (gd || gl)) {
739
128
  const set = this.drawsFor(g);
740
129
  if (gd) set.draw = gd;
741
130
  if (gl) set.ldraw = escBackslash(gl);
@@ -767,22 +156,23 @@ export class XdotRenderer implements RendererPlugin {
767
156
  ];
768
157
  const buf = this.bufs[EmitState.GDraw]!;
769
158
  buf.push(xdotPenColor(resolveRenderColor('transparent')));
770
- // A two-color bgcolor is a gradient (emit_background → findStopColor); a
771
- // linear one emits the bracketed fill, a radial one is deferred to the base.
772
- const stop = fillSpec.includes(':') ? findStopColor(fillSpec) : null;
773
- if (stop !== null) {
774
- const angle = Number(g.attrs.get('gradientangle') ?? 0) || 0;
775
- const radial = parseStyleFlags(g.attrs.get('style')).radial;
776
- const op = radial ? radialGradientOp : linearGradientOp;
777
- buf.push(
778
- op(corners, resolveRenderColor(stop.fillColor), resolveRenderColor(stop.stopColor), stop.frac, angle),
779
- );
780
- } else {
781
- buf.push(xdotFillColor(resolveRenderColor(fillSpec)));
782
- }
159
+ buf.push(this.backgroundFillOp(g, fillSpec, corners));
783
160
  buf.push(xdotPoints('P', corners));
784
161
  }
785
162
 
163
+ /** The canvas fill op: a two-colour `bgcolor` is a gradient (emit_background →
164
+ * findStopColor), linear or radial per style; anything else is a flat fill.
165
+ * @see lib/common/emit.c:1476 emit_background */
166
+ private backgroundFillOp(g: Graph, fillSpec: string, corners: Point[]): string {
167
+ const stop = fillSpec.includes(':') ? findStopColor(fillSpec) : null;
168
+ if (stop === null) return xdotFillColor(resolveRenderColor(fillSpec));
169
+ const angle = Number(g.attrs.get('gradientangle') ?? 0) || 0;
170
+ const op = parseStyleFlags(g.attrs.get('style')).radial ? radialGradientOp : linearGradientOp;
171
+ return op(
172
+ corners, resolveRenderColor(stop.fillColor), resolveRenderColor(stop.stopColor), stop.frac, angle,
173
+ );
174
+ }
175
+
786
176
  beginNode(_n: Node, _job: RenderJob): void { /* no-op */ }
787
177
 
788
178
  endNode(n: Node, _job: RenderJob): void {
@@ -837,32 +227,12 @@ export class XdotRenderer implements RendererPlugin {
837
227
  } else if (edgeIsTapered(e)) {
838
228
  this.emitTaperedSpline(e, spl.list[0], edraw, job);
839
229
  } else if (numc > 0) {
840
- this.emitParallelSpline(spl.list as (Bezier | undefined)[], colorAttr, numc, edraw, job);
230
+ const c = this.emitParallelSpline(
231
+ spl.list as (Bezier | undefined)[], colorAttr, numc, edraw, job);
232
+ tailArrowColor = c.tailColor;
233
+ headArrowColor = c.headColor;
841
234
  } else {
842
- // splines=ortho + radius/style=rounded → straight segments + corner arcs
843
- // as polylines (L), else the single bezier. @see emit.c:2583 / svg-helpers
844
- const radius = orthoRoundedRadius(e, job);
845
- const obj = job.obj;
846
- const origStyle = obj !== null ? [...obj.rawStyle] : [];
847
- const multi = spl.list.length > 1;
848
- for (const bez of spl.list) {
849
- const pts = bez.list.slice(0, bez.size);
850
- const polys = radius !== null && bez.size >= 4 ? orthoRoundedPolylines(pts, radius) : [];
851
- if (polys.length > 0) {
852
- for (const poly of polys) edraw.push(this.styleOp(job), this.penOp(job), xdotPoints('L', poly));
853
- } else {
854
- edraw.push(this.styleOp(job), this.penOp(job), xdotPoints('B', pts));
855
- }
856
- // arrow_gen (drawn to TDRAW/HDRAW below) resets the job style to
857
- // defaultlinestyle ("solid") as a side effect. For a multi-bezier spline
858
- // the NEXT segment's xdot_style re-emits the current rawstyle every call,
859
- // so a solid edge picks up a bare `S 5 -solid`; C restores the edge's own
860
- // styles afterward only when it has explicit ones. @see emit.c:2668-2677
861
- if (obj !== null && multi && (bez.sflag || bez.eflag)) {
862
- obj.rawStyle = origStyle.length > 0 ? origStyle : ['solid'];
863
- }
864
- }
865
- if (obj !== null) obj.rawStyle = origStyle;
235
+ this.emitPlainSpline(e, spl.list, edraw, job);
866
236
  }
867
237
  // Arrows: y-up ops already computed for the shared render path. C sets the
868
238
  // default line style ("solid") + penwidth before each arrow primitive.
@@ -875,122 +245,12 @@ export class XdotRenderer implements RendererPlugin {
875
245
  * -tapered` (the style), a transparent pen, the edge-color fill, then the
876
246
  * polygon vertices from `taper()` (y-up). @see lib/common/emit.c:2422
877
247
  */
878
- private emitTaperedSpline(e: Edge, bz: Bezier | undefined, edraw: string[], job: RenderJob): void {
879
- if (bz === undefined) return;
880
- const radfunc = taperfun(e.attrs.get('dir'), isDirected(e.tail.root));
881
- const verts = taper(bz, radfunc, job.obj?.penWidth ?? 1);
882
- const edgeColor = job.obj?.penColor ?? { type: 'string', s: 'black' };
883
- edraw.push(this.styleOp(job));
884
- edraw.push(xdotPenColor(resolveRenderColor('transparent')));
885
- edraw.push(xdotFillColor(edgeColor));
886
- edraw.push(xdotPoints('P', verts));
887
- }
888
-
889
- /**
890
- * Emit a split-along-length `;` multicolor edge spline: split each routed
891
- * bezier along its arc length into one sub-curve per color segment, drawn
892
- * under that segment's pen. Reuses the same split geometry
893
- * (splitSplineByColor) as the SVG path. @see lib/common/emit.c:1975 multicolor
894
- */
895
- private emitSplitSpline(
896
- bzList: (Bezier | undefined)[],
897
- colorAttr: string,
898
- edraw: string[],
899
- job: RenderJob,
900
- ): { firstColor: string; endColor: string } {
901
- const segs = parseSegs(colorAttr).segs;
902
- const firstColor = segs[0]?.color ?? 'black';
903
- let endColor = firstColor;
904
- for (const bz of bzList) {
905
- if (bz === undefined || bz.size < 4) continue;
906
- const split = splitSplineByColor(bz.list.slice(0, bz.size), segs);
907
- endColor = split.endColor;
908
- for (const c of split.curves) {
909
- edraw.push(this.styleOp(job), xdotPenColor(resolveRenderColor(c.color)), xdotPoints('B', c.points));
910
- }
911
- }
912
- return { firstColor, endColor };
913
- }
914
-
915
- /**
916
- * Emit the parallel-multicolor edge spline: one offset Bézier per color,
917
- * offset SEP=2.0 perpendicular per pass — reusing the same offset geometry
918
- * (buildOffsetLists/advanceTmpList) as the SVG parallel-edge path.
919
- * @see lib/common/emit.c:2443 (parallel multicolor) / svg-parallel-edge.ts
920
- */
921
- private emitParallelSpline(
922
- bzList: (Bezier | undefined)[],
923
- colorAttr: string,
924
- numc: number,
925
- edraw: string[],
926
- job: RenderJob,
927
- ): void {
928
- const segData = bzList.map((bz) =>
929
- bz !== undefined && bz.size >= 4
930
- ? buildOffsetLists(bz.list, (2 + numc) / 2)
931
- : { offlist: [] as Point[], tmplist: [] as Point[] },
932
- );
933
- const colors = parseSegs(colorAttr).segs.map((s) => s.color ?? 'black');
934
- for (const color of colors) {
935
- const pen = xdotPenColor(resolveRenderColor(color));
936
- for (const sd of segData) {
937
- if (sd.offlist.length === 0) continue;
938
- advanceTmpList(sd.tmplist, sd.offlist);
939
- edraw.push(this.styleOp(job), pen, xdotPoints('B', sd.tmplist));
940
- }
941
- }
942
- }
943
-
944
- /** Emit one arrow's primitive ops into `buf` (pen/fill from the edge color).
945
- * arrow_gen sets the default line style ("solid") and the edge penwidth
946
- * before each primitive, so a non-default penwidth emits `S setlinewidth(N)`
947
- * once (tracked per HDRAW/TDRAW state). @see arrows.c:arrow_gen */
948
- private emitArrows(
949
- buf: string[], ops: ArrowDrawOp[] | undefined, job: RenderJob, state: EmitState,
950
- penOverride?: string,
951
- ): void {
952
- if (ops === undefined) return;
953
- const pen = penOverride !== undefined
954
- ? resolveRenderColor(penOverride)
955
- : job.obj?.penColor ?? { type: 'string', s: 'black' };
956
- const pw = job.obj?.penWidth ?? 1;
957
- for (const op of ops) {
958
- if (Math.abs(pw - this.penwidth[state]!) >= 0.0005) {
959
- this.penwidth[state] = pw;
960
- buf.push(xdotStrOp('S ', 'setlinewidth(' + trimFixed3(pw) + ')'));
961
- }
962
- buf.push(xdotStrOp('S ', 'solid'));
963
- buf.push(xdotPenColor(pen));
964
- switch (op.kind) {
965
- case 'polygon':
966
- if (op.filled) buf.push(xdotFillColor(pen));
967
- buf.push(xdotPoints(op.filled ? 'P' : 'p', op.points));
968
- break;
969
- case 'ellipse':
970
- if (op.filled) buf.push(xdotFillColor(pen));
971
- buf.push(
972
- (op.filled ? 'E ' : 'e ') + xdotPoint(op.center) +
973
- xdotNum(op.rx) + ' ' + xdotNum(op.ry) + ' ',
974
- );
975
- break;
976
- case 'polyline':
977
- buf.push(xdotPoints('L', op.points));
978
- break;
979
- case 'bezier':
980
- buf.push(xdotPoints('B', op.points));
981
- break;
982
- }
983
- }
984
- }
985
248
 
986
249
  endEdge(e: Edge, job: RenderJob): void {
987
250
  // edge_in_box gate (set in beginEdge): a content-less / off-box edge draws
988
251
  // nothing, matching native's emit_edge early return. @see emit.c:emit_edge
989
252
  if (!this.edgeDrawable) {
990
- this.resetState(EmitState.EDraw, EmitState.ELabel);
991
- this.resetState(EmitState.HDraw, EmitState.TDraw);
992
- this.penwidth[EmitState.HLabel] = 1;
993
- this.penwidth[EmitState.TLabel] = 1;
253
+ this.resetEdgeState();
994
254
  return;
995
255
  }
996
256
  // Emit the edge's labels (center/xlabel/head/tail) — the port draws these
@@ -998,27 +258,42 @@ export class XdotRenderer implements RendererPlugin {
998
258
  // itself, mirroring emit_edge's emit_edge_label. gvrenderTextspan routes
999
259
  // each span to the edge's ELABEL buffer → _ldraw_. @see emit.c:3010
1000
260
  renderEdgeLabels(e, this, job);
1001
- const draw = this.flush(EmitState.EDraw);
1002
- const hdraw = this.flush(EmitState.HDraw);
1003
- const tdraw = this.flush(EmitState.TDraw);
1004
- const ldraw = this.flush(EmitState.ELabel);
1005
- const hldraw = this.flush(EmitState.HLabel);
1006
- const tldraw = this.flush(EmitState.TLabel);
1007
- if (draw || hdraw || tdraw || ldraw || hldraw || tldraw) {
1008
- const set = this.drawsFor(e);
1009
- if (draw) set.draw = draw;
1010
- if (hdraw) set.hdraw = hdraw;
1011
- if (tdraw) set.tdraw = tdraw;
1012
- if (ldraw) set.ldraw = escBackslash(ldraw);
1013
- if (hldraw) set.hldraw = escBackslash(hldraw);
1014
- if (tldraw) set.tldraw = escBackslash(tldraw);
261
+ this.attachEdgeDraws(e);
262
+ this.resetEdgeState();
263
+ this.textflags[EmitState.HLabel] = 0;
264
+ this.textflags[EmitState.TLabel] = 0;
265
+ }
266
+
267
+ /** Flush the six edge buffers and attach the non-empty ones. Label buffers get
268
+ * put_escaping_backslashes; shape buffers do not.
269
+ * @see plugin/core/gvrender_core_dot.c:218 */
270
+ private attachEdgeDraws(e: Edge): void {
271
+ // (buffer, target field, is-a-label) in C's flush order. Label buffers get
272
+ // put_escaping_backslashes; shape buffers do not.
273
+ const spec: [EmitState, keyof XdotDraws, boolean][] = [
274
+ [EmitState.EDraw, 'draw', false],
275
+ [EmitState.HDraw, 'hdraw', false],
276
+ [EmitState.TDraw, 'tdraw', false],
277
+ [EmitState.ELabel, 'ldraw', true],
278
+ [EmitState.HLabel, 'hldraw', true],
279
+ [EmitState.TLabel, 'tldraw', true],
280
+ ];
281
+ const flushed = spec.map(([state, field, isLabel]) =>
282
+ [field, this.flush(state), isLabel] as const);
283
+ if (!flushed.some(([, s]) => s)) return;
284
+ const set = this.drawsFor(e);
285
+ for (const [field, s, isLabel] of flushed) {
286
+ if (s) set[field] = isLabel ? escBackslash(s) : s;
1015
287
  }
288
+ }
289
+
290
+ /** Per-edge emit-state reset, run on both the drawable and the early-return
291
+ * path — identical in C, so it is one helper here rather than two copies. */
292
+ private resetEdgeState(): void {
1016
293
  this.resetState(EmitState.EDraw, EmitState.ELabel);
1017
294
  this.resetState(EmitState.HDraw, EmitState.TDraw);
1018
295
  this.penwidth[EmitState.HLabel] = 1;
1019
296
  this.penwidth[EmitState.TLabel] = 1;
1020
- this.textflags[EmitState.HLabel] = 0;
1021
- this.textflags[EmitState.TLabel] = 0;
1022
297
  }
1023
298
 
1024
299
  beginCluster(_sg: Graph, _job: RenderJob): void { /* no-op */ }
@@ -1090,7 +365,7 @@ export class XdotRenderer implements RendererPlugin {
1090
365
  // --- graphics-state ops ------------------------------------------------
1091
366
 
1092
367
  /** Pen color op from the resolved graphics state (default black). */
1093
- private penOp(job: RenderJob): string {
368
+ protected penOp(job: RenderJob): string {
1094
369
  return xdotPenColor(job.obj?.penColor ?? { type: 'string', s: 'black' });
1095
370
  }
1096
371
 
@@ -1114,7 +389,7 @@ export class XdotRenderer implements RendererPlugin {
1114
389
 
1115
390
  /** Style ops (`S`): setlinewidth on a penwidth change, plus dash/dot pen.
1116
391
  * @see gvrender_core_dot.c:161 xdot_style */
1117
- private styleOp(job: RenderJob): string {
392
+ protected styleOp(job: RenderJob): string {
1118
393
  const obj = job.obj;
1119
394
  if (obj === null) return '';
1120
395
  let s = '';
@@ -1178,331 +453,38 @@ export class XdotRenderer implements RendererPlugin {
1178
453
 
1179
454
  // --- serialization (agwrite-at-end) ------------------------------------
1180
455
 
1181
- /**
1182
- * Emit `key="value"`, escaping the value the way agwrite's agcanonStr does: a
1183
- * `"` becomes `\"` unless it is already part of an escape sequence (see
1184
- * agcanonEscape). A draw string carries label text that may contain a bare `"`
1185
- * (would close the attribute early) or a source `\"`/`\\` (must not be
1186
- * double-escaped). The byte-length prefix stays on the UNescaped text (the
1187
- * parser un-escapes before parseXDot re-reads it), matching native exactly.
1188
- * @see lib/cgraph/write.c:_agstrcanon (135-167)
1189
- */
1190
- private drawAttr(key: string, value: string): string {
1191
- return key + '="' + agcanonEscape(value) + '"';
1192
- }
1193
-
1194
- /** `llx,lly,urx,ury` from a graph's layout bb. */
1195
- private bbStr(g: Graph): string {
1196
- const bb = g.info.bb;
1197
- return gfmt5(bb.ll.x) + ',' + gfmt5(bb.ll.y) + ',' +
1198
- gfmt5(bb.ur.x) + ',' + gfmt5(bb.ur.y);
1199
- }
1200
-
1201
- /**
1202
- * Serialize the whole laid-out graph to xdot DOT text — a faithful port of
1203
- * cgraph's agwrite (lib/cgraph/write.c). Recurses the subgraph tree
1204
- * (write_subgs/write_body), scoping each node/edge to the subgraph(s) it
1205
- * belongs to via preorder numbers (write_node_test/write_edge_test), and
1206
- * re-emits an object bare (no attrs) on any scope after the first
1207
- * (attrs_written). This reproduces native's per-subgraph edge re-declarations
1208
- * — e.g. an edge in a rank=same subgraph is drawn once, then re-declared bare.
1209
- * @see lib/cgraph/write.c:agwrite
1210
- */
1211
- private serialize(g: Graph): string {
1212
- const ctx: SerCtx = {
1213
- out: [],
1214
- preorder: new Map<Graph, number>(),
1215
- nodeLW: new Map<Node, number>(),
1216
- edgeLW: new Map<Edge, number>(),
1217
- attrsWritten: new Set<Node | Edge>(),
1218
- level: 0,
1219
- };
1220
- this.subgdfs(g, 1, ctx.preorder);
1221
- this.writeHdr(g, true, ctx);
1222
- this.writeBody(g, ctx);
1223
- this.writeTrl(ctx);
1224
- return ctx.out.join('');
1225
- }
1226
-
1227
- /** Preorder-number the subgraph tree. @see write.c:subgdfs */
1228
- private subgdfs(g: Graph, ix: number, preorder: Map<Graph, number>): number {
1229
- let ix0 = ix;
1230
- preorder.set(g, ix0);
1231
- for (const sub of g.subgraphs.values()) ix0 = this.subgdfs(sub, ix0, preorder);
1232
- return ix0 + 1;
1233
- }
1234
-
1235
- /** A subgraph is anonymous when its name is empty or a `%N` local name. */
1236
- private isAnonymous(g: Graph): boolean {
1237
- return g.name.length === 0 || g.name.charCodeAt(0) === 0x25 /* % */;
1238
- }
1239
-
1240
- /** A graph carries a `bb` attribute when it is the root or has a layout bb
1241
- * (clusters, and any subgraph output.c computed a box for). Native seeds `bb`
1242
- * on every graph via safe_dcl, so the value differs (set vs empty "") between
1243
- * a boxed graph and an unboxed child — the driver that makes an anon subgraph
1244
- * under the root or a cluster "relevant". @see lib/common/output.c:safe_dcl */
1245
- private hasBb(g: Graph): boolean {
1246
- return g === g.root || this.clusters.includes(g);
1247
- }
1248
-
1249
- /** Anonymous subgraph with no own node/edge defaults and no graph attrs
1250
- * differing from its parent → inlined into the parent. Native compares every
1251
- * graph attr over the root attr dict; the load-bearing ones are `bb` (set on
1252
- * the parent, empty on the child) and `rank`. @see write.c:irrelevant_subgraph */
1253
- private irrelevantSubgraph(g: Graph): boolean {
1254
- if (!this.isAnonymous(g)) return false;
1255
- if (this.clusters.includes(g)) return false;
1256
- if (g.nodeDefaults.size > 0 || g.edgeDefaults.size > 0) return false;
1257
- if (g.parent) {
1258
- if (this.hasBb(g) !== this.hasBb(g.parent)) return false;
1259
- for (const [k, v] of g.attrs) {
1260
- if (g.parent.attrs.get(k) !== v) return false;
1261
- }
1262
- } else if (g.attrs.size > 0) {
1263
- return false;
1264
- }
1265
- return true;
1266
- }
1267
-
1268
- /** Non-draw graph attrs a subgraph emits (rank for rank=same; clusters use
1269
- * clusterAttrs). Only comparator-relevant fields need be exact.
1270
- *
1271
- * `rec_attach_bb` walks the ROOT and then `GD_clust` recursively, so a
1272
- * subgraph the layout did not box is attached NEITHER `bb` NOR the label
1273
- * triple — both keep the INPUT's values, which agwrite echoes. circo and
1274
- * twopi lay out no clusters at all (`GD_clust` empty — mirrored here by
1275
- * `this.clusters`, filled from endCluster), so on a re-fed dot output their
1276
- * `cluster0` comes back carrying the *previous* run's `bb` and `lp`.
1277
- * @see lib/common/output.c:249 rec_attach_bb */
1278
- private subgGraphAttrs(sg: Graph): string {
1279
- if (this.clusters.includes(sg)) return this.clusterAttrs(sg);
1280
- const parts: string[] = [];
1281
- const rank = sg.attrs.get('rank');
1282
- if (rank !== undefined) parts.push('rank=' + xdotId(rank));
1283
- parts.push(...echoAttr(sg.attrs, 'bb'));
1284
- parts.push(...graphLabelAttrs(sg));
1285
- return parts.join(' ');
1286
- }
1287
-
1288
- private indent(ctx: SerCtx): string {
1289
- return '\t'.repeat(ctx.level);
1290
- }
1291
-
1292
- /** @see write.c:write_hdr */
1293
- private writeHdr(g: Graph, top: boolean, ctx: SerCtx): void {
1294
- if (top) {
1295
- const strict = g.kind === 'strict-directed' || g.kind === 'strict-undirected' ? 'strict ' : '';
1296
- const kw = isDirected(g) ? 'digraph' : 'graph';
1297
- const nm = g.name.length > 0 && !this.isAnonymous(g) ? xdotId(g.name) + ' ' : '';
1298
- ctx.out.push(strict + kw + ' ' + nm + '{\n');
1299
- ctx.level++;
1300
- ctx.out.push(this.indent(ctx) + 'graph [' + this.graphAttrs(g) + '];\n');
1301
- ctx.out.push(this.indent(ctx) + 'node [label="\\N"];\n');
1302
- } else {
1303
- const nm = this.isAnonymous(g) ? '' : 'subgraph ' + xdotId(g.name) + ' ';
1304
- ctx.out.push(this.indent(ctx) + nm + '{\n');
1305
- ctx.level++;
1306
- const ga = this.subgGraphAttrs(g);
1307
- if (ga) ctx.out.push(this.indent(ctx) + 'graph [' + ga + '];\n');
1308
- }
1309
- }
1310
-
1311
- /** @see write.c:write_trl */
1312
- private writeTrl(ctx: SerCtx): void {
1313
- ctx.level--;
1314
- ctx.out.push(this.indent(ctx) + '}\n');
1315
- }
1316
-
1317
- /** @see write.c:write_subgs */
1318
- private writeSubgs(g: Graph, ctx: SerCtx): void {
1319
- for (const sub of g.subgraphs.values()) {
1320
- if (this.irrelevantSubgraph(sub)) {
1321
- this.writeSubgs(sub, ctx);
1322
- } else {
1323
- this.writeHdr(sub, false, ctx);
1324
- this.writeBody(sub, ctx);
1325
- this.writeTrl(ctx);
1326
- }
1327
- }
1328
- }
1329
-
1330
- /** @see write.c:write_body — subgraphs, then this scope's nodes and edges. */
1331
- private writeBody(g: Graph, ctx: SerCtx): void {
1332
- this.writeSubgs(g, ctx);
1333
- for (const n of g.nodes.values()) {
1334
- if (this.writeNodeTest(g, n, ctx)) this.writeNode(g, n, ctx);
1335
- let prev: Node = n;
1336
- for (const e of n.outEdges(g)) {
1337
- if (prev !== e.head && this.writeNodeTest(g, e.head, ctx)) {
1338
- this.writeNode(g, e.head, ctx);
1339
- prev = e.head;
1340
- }
1341
- if (this.writeEdgeTest(g, e, ctx)) this.writeEdge(g, e, ctx);
1342
- }
1343
- }
1344
- }
1345
-
1346
- /** @see write.c:write_node_test — every xdot node carries pos/size, so it is
1347
- * never "default"; write it in the first scope that has not yet emitted it.
1348
- * Cluster nodes (fdp compound proxies, ND_clustnode) are never declared —
1349
- * C's write_plain/writenodeandport suppress them. @see lib/common/output.c:146 */
1350
- private writeNodeTest(g: Graph, n: Node, ctx: SerCtx): boolean {
1351
- if (n.info.clustnode) return false;
1352
- return (ctx.nodeLW.get(n) ?? 0) < ctx.preorder.get(g)!;
1353
- }
1354
-
1355
- /** Emitted node name: a cluster node's synthetic `__i:<cluster>` id is written
1356
- * as the cluster name it stands for. @see lib/common/output.c:114 */
1357
- private emitNodeName(n: Node): string {
1358
- if (n.info.clustnode) {
1359
- const i = n.name.indexOf(':');
1360
- if (i >= 0) return n.name.slice(i + 1);
1361
- }
1362
- return n.name;
1363
- }
1364
-
1365
- /** @see write.c:write_edge_test */
1366
- private writeEdgeTest(g: Graph, e: Edge, ctx: SerCtx): boolean {
1367
- return (ctx.edgeLW.get(e) ?? 0) < ctx.preorder.get(g)!;
1368
- }
1369
-
1370
- /** @see write.c:write_node */
1371
- private writeNode(g: Graph, n: Node, ctx: SerCtx): void {
1372
- let s = this.indent(ctx) + xdotId(this.emitNodeName(n));
1373
- if (!ctx.attrsWritten.has(n)) {
1374
- s += ' [' + this.nodeAttrs(n) + ']';
1375
- ctx.attrsWritten.add(n);
1376
- }
1377
- ctx.out.push(s + ';\n');
1378
- ctx.nodeLW.set(n, ctx.preorder.get(g)!);
1379
- }
1380
-
1381
- /** @see write.c:write_edge — attrs only on first emission, bare thereafter. */
1382
- private writeEdge(g: Graph, e: Edge, ctx: SerCtx): void {
1383
- const conn = edgeConnector(isDirected(g));
1384
- let s = this.indent(ctx) + xdotId(this.emitNodeName(e.tail)) + ' ' + conn + ' ' +
1385
- xdotId(this.emitNodeName(e.head));
1386
- if (!ctx.attrsWritten.has(e)) {
1387
- const attrs = this.edgeAttrStr(e);
1388
- if (attrs) s += ' [' + attrs + ']';
1389
- ctx.attrsWritten.add(e);
1390
- }
1391
- ctx.out.push(s + ';\n');
1392
- ctx.edgeLW.set(e, ctx.preorder.get(g)!);
1393
- }
1394
-
1395
- /** Root-graph attribute block: `_draw_`, `_ldraw_`, `bb`, `xdotversion`. */
1396
- private graphAttrs(g: Graph): string {
1397
- const d = this.draws.get(g);
1398
- const parts: string[] = [];
1399
- if (d?.draw) parts.push(this.drawAttr('_draw_', d.draw));
1400
- if (d?.ldraw) parts.push(this.drawAttr('_ldraw_', d.ldraw));
1401
- parts.push('bb="' + this.bbStr(g) + '"');
1402
- parts.push(...graphLabelAttrs(g));
1403
- parts.push('xdotversion="' + XDOT_VERSION + '"');
1404
- return parts.join(' ');
1405
- }
1406
-
1407
- /** Cluster attribute block. C's xdot_end_cluster ALWAYS agsets `_draw_` when
1408
- * the graph has clusters (even empty, e.g. peripheries=0), so emit it
1409
- * unconditionally; `_ldraw_` only when the cluster has a label.
1410
- * @see plugin/core/gvrender_core_dot.c:284 xdot_end_cluster */
1411
- private clusterAttrs(sg: Graph): string {
1412
- const d = this.draws.get(sg);
1413
- const parts: string[] = [this.drawAttr('_draw_', d?.draw ?? '')];
1414
- if (d?.ldraw) parts.push(this.drawAttr('_ldraw_', d.ldraw));
1415
- if (sg.info.bb) parts.push('bb="' + this.bbStr(sg) + '"');
1416
- // rec_attach_bb recurses into GD_clust, so a labelled cluster carries the
1417
- // same lp/lwidth/lheight triple as the root. @see lib/common/output.c:249
1418
- parts.push(...graphLabelAttrs(sg));
1419
- return parts.join(' ');
1420
- }
1421
-
1422
- /** Node attribute block: pos/width/height plus `_draw_`/`_ldraw_`. */
1423
- private nodeAttrs(n: Node): string {
1424
- const info = n.info;
1425
- // attach_attrs derives the emitted size from ND_lw+ND_rw / ND_ht, NOT
1426
- // ND_width/ND_height (output.c:307-308). They coincide except where an
1427
- // engine leaves them divergent — patchwork's finishNode lets poly_init
1428
- // clobber ND_width/height while the tile survives in lw/rw/ht.
1429
- let s = 'pos="' + gfmt5(info.coord.x) + ',' + gfmt5(info.coord.y) + '"' +
1430
- ' width=' + gfmt5((info.lw + info.rw) / 72) + ' height=' + gfmt5(info.ht / 72);
1431
- // xlp — only when the node HAS an xlabel AND the xlabel placer actually set
1432
- // its position (`ND_xlabel(n)->set`). An xlabel that was never placed is
1433
- // omitted entirely — so an input `xlp` survives and is echoed.
1434
- // @see lib/common/output.c:309-313
1435
- const xlabel = info.xlabel as TextlabelT | undefined;
1436
- if (xlabel && xlabel.set) s += ' xlp="' + lpStr(xlabel.pos) + '"';
1437
- else s += pfx(echoAttr(n.attrs, 'xlp'));
1438
- // rects — record field boxes. C gates on the SHAPE NAME being exactly
1439
- // "record" (`strcmp(ND_shape(n)->name, "record") == 0`), so `Mrecord` and
1440
- // HTML-table nodes get NO rects; confirmed against the native oracle. When
1441
- // the shape is not a record the input's `rects` is never overwritten and is
1442
- // echoed — patchwork forces every node to `box`, so a re-fed dot output
1443
- // returns its old record rects untouched. @see lib/common/output.c:314-317
1444
- const shape = info.shape as ShapeDesc | undefined;
1445
- if (shape?.name === 'record') {
1446
- const rects: string[] = [];
1447
- appendRecordRects(n, info.shape_info as FieldT, rects);
1448
- s += ' rects="' + rects.join(' ') + '"';
1449
- } else {
1450
- s += pfx(echoAttr(n.attrs, 'rects'));
1451
- }
1452
- const d = this.draws.get(n);
1453
- if (d?.draw) s += ' ' + this.drawAttr('_draw_', d.draw);
1454
- if (d?.ldraw) s += ' ' + this.drawAttr('_ldraw_', d.ldraw);
1455
- return s;
1456
- }
1457
-
1458
- /** The `[...]` attribute body for an edge (draw ops + spline `pos`), or ''. */
1459
- private edgeAttrStr(e: Edge): string {
1460
- const d = this.draws.get(e);
1461
- const parts: string[] = [];
1462
- if (d?.draw) parts.push(this.drawAttr('_draw_', d.draw));
1463
- if (d?.ldraw) parts.push(this.drawAttr('_ldraw_', d.ldraw));
1464
- if (d?.hdraw) parts.push(this.drawAttr('_hdraw_', d.hdraw));
1465
- if (d?.tdraw) parts.push(this.drawAttr('_tdraw_', d.tdraw));
1466
- if (d?.hldraw) parts.push(this.drawAttr('_hldraw_', d.hldraw));
1467
- if (d?.tldraw) parts.push(this.drawAttr('_tldraw_', d.tldraw));
1468
- const pos = formatEdgePos(e);
1469
- if (pos) {
1470
- parts.push(pos);
1471
- } else {
1472
- // C's attach_attrs only agsets `pos` when the edge HAS a spline
1473
- // (output.c:348); an engine that never routes (patchwork) leaves the
1474
- // INPUT's own pos attribute intact and write.c emits it verbatim.
1475
- parts.push(...echoAttr(e.attrs, 'pos'));
1476
- }
1477
- // The label-position attributes live inside the SAME loop that writes `pos`
1478
- // (output.c:377-396), so they are attached only for a routed, non-IGNORED
1479
- // edge. Note the asymmetry C encodes: `lp`/`head_lp`/`tail_lp` are emitted
1480
- // whenever the label EXISTS, but `xlp` additionally requires `->set` — an
1481
- // unplaced xlabel is omitted. Order mirrors C: lp, xlp, head_lp, tail_lp.
1482
- // Each gate is independent, and each FAILED gate leaves the input's own
1483
- // value in the slot for write.c to echo: an unrouted edge (patchwork routes
1484
- // none) returns the `lp` a previous dot run wrote.
1485
- const attached = edgeAttrsAttached(e);
1486
- const info = e.info;
1487
- if (attached && info.label) parts.push('lp="' + lpStr(info.label.pos) + '"');
1488
- else parts.push(...echoAttr(e.attrs, 'lp'));
1489
- if (attached && info.xlabel && info.xlabel.set) {
1490
- parts.push('xlp="' + lpStr(info.xlabel.pos) + '"');
1491
- } else parts.push(...echoAttr(e.attrs, 'xlp'));
1492
- if (attached && info.head_label) {
1493
- parts.push('head_lp="' + lpStr(info.head_label.pos) + '"');
1494
- } else parts.push(...echoAttr(e.attrs, 'head_lp'));
1495
- if (attached && info.tail_label) {
1496
- parts.push('tail_lp="' + lpStr(info.tail_label.pos) + '"');
1497
- } else parts.push(...echoAttr(e.attrs, 'tail_lp'));
1498
- return parts.join(' ');
1499
- }
1500
456
  }
1501
457
 
1502
458
  // ---------------------------------------------------------------------------
1503
459
  // Factory functions
1504
460
  // ---------------------------------------------------------------------------
1505
461
 
462
+ /**
463
+ * DOT format renderer (`-Tdot`) — the same engine as {@link XdotRenderer} with
464
+ * the xdot draw-attribute step switched off, mirroring C's one renderer with a
465
+ * `job->render.id` switch: FORMAT_DOT is `attach_attrs(g)` then `agwrite(g)`,
466
+ * FORMAT_XDOT is `attach_attrs_and_arrows` + the `xdot_*` draw attributes then
467
+ * *the same* `agwrite(g)`. Sharing the serializer is what makes `-Tdot` emit the
468
+ * subgraph tree, per-scope node/edge scoping, and the `node [label="\N"]`
469
+ * default line, none of which a streaming emitter can produce (the whole graph
470
+ * must be walked before the first byte).
471
+ *
472
+ * The drawing callbacks are overridden to no-ops: `-Tdot` renders no draw ops,
473
+ * so accumulating them would be dead work.
474
+ *
475
+ * @see plugin/core/gvrender_core_dot.c:398 dot_begin_graph / :464 dot_end_graph
476
+ */
477
+ export class DotRenderer extends XdotRenderer {
478
+ override readonly type: string = 'dot';
479
+ protected override readonly emitDraws: boolean = false;
480
+
481
+ override textspan(_pos: Point, _span: TextSpan, _job: RenderJob): void { /* no-op */ }
482
+ override ellipse(_c: Point, _rx: number, _ry: number, _f: boolean, _j: RenderJob): void { /* no-op */ }
483
+ override polygon(_pts: Point[], _filled: boolean, _job: RenderJob): void { /* no-op */ }
484
+ override bezier(_pts: Point[], _filled: boolean, _job: RenderJob): void { /* no-op */ }
485
+ override polyline(_pts: Point[], _job: RenderJob): void { /* no-op */ }
486
+ }
487
+
1506
488
  /** @see plugin/core/gvrender_core_dot.c FORMAT_DOT */
1507
489
  export function createDotRenderer(): RendererPlugin {
1508
490
  return new DotRenderer();
@@ -1512,3 +494,4 @@ export function createDotRenderer(): RendererPlugin {
1512
494
  export function createXdotRenderer(): RendererPlugin {
1513
495
  return new XdotRenderer();
1514
496
  }
497
+