@knowvah/dot-engine 1.1.0 → 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 +48 -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 +3 -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,432 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * xdot output primitives — number, string, colour and geometry formatting for
5
+ * the xdot draw-string language, plus the shared `%.5g`/`%.2f` helpers.
6
+ *
7
+ * @see plugin/core/gvrender_core_dot.c
8
+ */
9
+
10
+ import type { Point } from '../../model/geom.js';
11
+ import type { GVColor } from '../../common/color.js';
12
+ import { colorxlate } from '../../common/color.js';
13
+ import { getGradientPoints } from '../svg-gradient.js';
14
+ import { EmitState, toFixed2HalfEven } from '../../gvc/job.js';
15
+ import { printfSig, printfFixed } from '../../util/printf-round.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Constants
19
+ // ---------------------------------------------------------------------------
20
+
21
+ export const XDOT_VERSION = '1.7';
22
+
23
+ /** Max magnitude cap — matches maxnegnum in C source. */
24
+ export const MAX_NEGNUM = 999999999999999.99;
25
+
26
+ /** Near-zero suppression threshold (same as gvprintdouble). */
27
+ export const NEAR_ZERO = 0.005;
28
+
29
+ /** Indices 8/9 alias 1; 10/11 alias 5. NUM_XBUFS covers 0–7. */
30
+ export const NUM_XBUFS = 8;
31
+
32
+ /**
33
+ * Style tokens that never become an xdot `S` op: `filled`/`bold`/`setlinewidth`
34
+ * are filtered by xdot_style itself; the polygon/fill styles are consumed by the
35
+ * shape/fill code before gvrender_set_style, so they never reach the render
36
+ * style. Only line styles (solid/dashed/dotted + unknown) emit an `S` op.
37
+ * @see plugin/core/gvrender_core_dot.c:184 xdot_style · lib/common/shapes.c
38
+ */
39
+ export const NON_LINE_STYLES: ReadonlySet<string> = new Set([
40
+ 'filled', 'bold', 'rounded', 'diagonals', 'striped', 'wedged',
41
+ 'invis', 'invisible', 'radial',
42
+ ]);
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // printNum — @see lib/gvc/gvdevice.c:gvprintnum
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Strip trailing zeros (and the decimal point) from a toFixed(3) string,
50
+ * then collapse a leading "0." or "-0." prefix.
51
+ */
52
+ export function trimAndStrip(s: string): string {
53
+ const dot = s.indexOf('.');
54
+ if (dot < 0) return s;
55
+ let end = s.length;
56
+ while (end > dot + 1 && s[end - 1] === '0') end--;
57
+ if (s[end - 1] === '.') end--;
58
+ const t = s.slice(0, end);
59
+ if (t.startsWith('0.')) return t.slice(1);
60
+ if (t.startsWith('-0.')) return '-' + t.slice(2);
61
+ return t;
62
+ }
63
+
64
+ /**
65
+ * Convert a double to compact string form for DOT/XDOT output.
66
+ *
67
+ * Rules (porting gvprintnum from lib/gvc/gvdevice.c):
68
+ * - |n| > MAX_NEGNUM → clamped to ±MAX_NEGNUM
69
+ * - |n| < NEAR_ZERO → "0" (suppresses -0)
70
+ * - 3 decimal places, trailing zeros and point stripped
71
+ * - Leading "0." collapsed to "." (e.g. 0.5 → ".5")
72
+ *
73
+ * Exported for json.ts (T30) and map.ts (T31).
74
+ *
75
+ * @see lib/gvc/gvdevice.c:gvprintnum
76
+ */
77
+ export function printNum(n: number): string {
78
+ if (Math.abs(n) > MAX_NEGNUM) {
79
+ return n < 0 ? String(-MAX_NEGNUM) : String(MAX_NEGNUM);
80
+ }
81
+ if (Math.abs(n) < NEAR_ZERO) return '0';
82
+ return trimAndStrip(n.toFixed(3));
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // XDot buffer management
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Create a 12-slot xbufs array with the canonical aliasing.
91
+ *
92
+ * Indices 8–9 (NDraw, EDraw) alias index 1 (CDraw).
93
+ * Indices 10–11 (NLabel, ELabel) alias index 5 (CLabel).
94
+ *
95
+ * @see plugin/core/gvrender_core_dot.c:xbufs
96
+ */
97
+ export function makeXbufs(): string[][] {
98
+ const bufs: string[][] = Array.from({ length: NUM_XBUFS + 4 }, () => []);
99
+ bufs[EmitState.NDraw] = bufs[EmitState.CDraw]!;
100
+ bufs[EmitState.EDraw] = bufs[EmitState.CDraw]!;
101
+ bufs[EmitState.NLabel] = bufs[EmitState.CLabel]!;
102
+ bufs[EmitState.ELabel] = bufs[EmitState.CLabel]!;
103
+ return bufs;
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // XDOT op helpers — @see plugin/core/gvrender_core_dot.c
108
+ // ---------------------------------------------------------------------------
109
+
110
+ /**
111
+ * Format one xdot draw-op number: 2 decimals, trailing zeros and point trimmed
112
+ * — mirroring xdot_fmt_num ("%.02f" + agxbuf_trim_zeros). Distinct from
113
+ * `printNum` (used for the DOT `pos`/`bb`/`width`/`height` attributes), which
114
+ * keeps more precision; xdot's DRAW ops are emitted at 2 dp by the C engine.
115
+ * @see plugin/core/gvrender_core_dot.c:126 xdot_fmt_num
116
+ */
117
+ export function xdotNum(v: number): string {
118
+ // Round half-to-even like C's printf %.02f (FE_TONEAREST); JS toFixed rounds
119
+ // half-away-from-zero, which diverges at exact .xx5 ties (2323.125 → native
120
+ // 2323.12, not 2323.13). @see lib/gvc/gvdevice.c gvprintdouble
121
+ let s = toFixed2HalfEven(v);
122
+ if (s.indexOf('.') >= 0) {
123
+ let end = s.length;
124
+ while (end > 0 && s[end - 1] === '0') end--;
125
+ if (s[end - 1] === '.') end--;
126
+ s = s.slice(0, end);
127
+ }
128
+ return s === '-0' ? '0' : s;
129
+ }
130
+
131
+ /**
132
+ * Format a single xdot point "x y ". xdot is y-up: `Y_invert` defaults false, so
133
+ * `yDir(y, yOff)` returns `y` unchanged for xdot (only `-Ty` plain/dot invert).
134
+ * The layout coordinate passes through with NO inversion — unlike the SVG path.
135
+ * @see lib/common/output.c:36 yDir · plugin/core/gvrender_core_dot.c:132 xdot_point
136
+ */
137
+ export function xdotPoint(p: Point): string {
138
+ return xdotNum(p.x) + ' ' + xdotNum(p.y) + ' ';
139
+ }
140
+
141
+ /** Format N points preceded by opcode and count: "<c> <n> x0 y0 x1 y1 …". */
142
+ export function xdotPoints(c: string, pts: Point[]): string {
143
+ let s = c + ' ' + String(pts.length) + ' ';
144
+ for (const p of pts) s += xdotPoint(p);
145
+ return s;
146
+ }
147
+
148
+ /**
149
+ * UTF-8 byte length of a string — the value C's `xdot_str` writes as the length
150
+ * prefix (`strlen(s)` over the UTF-8 bytes), NOT the JS UTF-16 code-unit count.
151
+ * A label like `ÿ` (U+00FF) is 2 UTF-8 bytes, so its `T`/`F` op prefix is 2.
152
+ * @see plugin/core/gvrender_core_dot.c:83 xdot_str_xbuf (`%zu`, strlen)
153
+ */
154
+ export function utf8Len(s: string): number {
155
+ let n = 0;
156
+ for (let i = 0; i < s.length; i++) {
157
+ const c = s.charCodeAt(i);
158
+ if (c < 0x80) n += 1;
159
+ else if (c < 0x800) n += 2;
160
+ else if (c >= 0xd800 && c <= 0xdbff) { n += 4; i++; } // surrogate pair → 4 bytes
161
+ else n += 3;
162
+ }
163
+ return n;
164
+ }
165
+
166
+ /** Clamp a normalized [0,1] float channel to a 0-255 byte (round-to-nearest). */
167
+ export function chanByte(v: number): number {
168
+ return Math.round(Math.max(0, Math.min(1, v)) * 255);
169
+ }
170
+
171
+ /**
172
+ * Resolve a GVColor to RGBA bytes — the value the C xdot callbacks read from
173
+ * `job->obj->pencolor.u.rgba` (already resolved by gvrender_set_pencolor). A
174
+ * plain `rgba` passes through; named/hex/HSV specs run through colorxlate; a
175
+ * `none` color is fully transparent black (callers gate on PEN_NONE).
176
+ */
177
+ export function gvColorRgba(c: GVColor): [number, number, number, number] {
178
+ if (c.type === 'rgba') return [chanByte(c.r), chanByte(c.g), chanByte(c.b), chanByte(c.a)];
179
+ // A `none`/transparent paint emits graphviz's "transparent" bytes (ff ff fe 00),
180
+ // matching native's `#fffffe00` — not fully-zero black. @see colxlate.c transparent
181
+ if (c.type === 'none') return [0xff, 0xff, 0xfe, 0x00];
182
+ const out: GVColor = { type: 'rgba', r: 0, g: 0, b: 0, a: 0 };
183
+ colorxlate(c.type === 'string' ? c.s : '', out, 'rgba');
184
+ return out.type === 'rgba'
185
+ ? [chanByte(out.r), chanByte(out.g), chanByte(out.b), chanByte(out.a)]
186
+ : [0, 0, 0, 255];
187
+ }
188
+
189
+ /**
190
+ * Bare xdot color body (no `c `/`C ` prefix): the CONSTANT length prefix
191
+ * (7 for `#rrggbb`, 9 for `#rrggbbaa`) plus `-#hex`, the alpha byte present only
192
+ * when not fully opaque. Used both by the color ops and by gradient color stops.
193
+ * @see plugin/core/gvrender_core_dot.c:99 xdot_str_color_xbuf
194
+ */
195
+ export function xdotColorBody(rgba: [number, number, number, number]): string {
196
+ const hx = (n: number): string => n.toString(16).padStart(2, '0');
197
+ const body = '#' + hx(rgba[0]) + hx(rgba[1]) + hx(rgba[2]);
198
+ return rgba[3] === 0xff ? '7 -' + body : '9 -' + body + hx(rgba[3]);
199
+ }
200
+
201
+ /**
202
+ * Format an xdot color op ("c "/"C ") from RGBA bytes.
203
+ * @see plugin/core/gvrender_core_dot.c:99 xdot_str_color_xbuf
204
+ */
205
+ export function xdotColorOp(prefix: 'c ' | 'C ', rgba: [number, number, number, number]): string {
206
+ return prefix + xdotColorBody(rgba) + ' ';
207
+ }
208
+
209
+ /**
210
+ * Build the linear-gradient `C len -[x0 y0 x1 y1 2 <stops>]` fill op. Endpoints
211
+ * come from getGradientPoints (the same geometry the SVG gradient uses); stops
212
+ * are (frac,fill)/(frac,stop) when frac>0, else (0,fill)/(1,stop). Shared by
213
+ * node/cluster gradient fills and the graph-background gradient.
214
+ * @see plugin/core/gvrender_core_dot.c:544-598 xdot_gradient_fillcolor
215
+ */
216
+ export function linearGradientOp(
217
+ pts: Point[],
218
+ fillColor: GVColor,
219
+ stopColor: GVColor,
220
+ frac: number,
221
+ angleDeg: number,
222
+ ): string {
223
+ // isRHS=true: native y-up coords, matching C's get_gradient_points(A,G,n,angle,2)
224
+ // for the xdot device path (the SVG path uses isRHS=false + a container flip).
225
+ const { g0, g1 } = getGradientPoints(pts, (angleDeg * Math.PI) / 180, false, true);
226
+ const inner =
227
+ '[' + xdotNum(g0.x) + ' ' + xdotNum(g0.y) + ' ' + xdotNum(g1.x) + ' ' + xdotNum(g1.y) +
228
+ ' 2 ' + gradientStops(fillColor, stopColor, frac) + ']';
229
+ return 'C ' + String(utf8Len(inner)) + ' -' + inner + ' ';
230
+ }
231
+
232
+ /** The `<frac> <colorbody>` stop pairs for a gradient (frac>0 vs the 0/1 form). */
233
+ export function gradientStops(fillColor: GVColor, stopColor: GVColor, frac: number): string {
234
+ const fill = gvColorRgba(fillColor);
235
+ const stop = gvColorRgba(stopColor);
236
+ const stops: Array<[number, [number, number, number, number]]> =
237
+ frac > 0 ? [[frac, fill], [frac, stop]] : [[0, fill], [1, stop]];
238
+ return stops.map(([f, c]) => trimFixed3(f) + ' ' + xdotColorBody(c)).join(' ');
239
+ }
240
+
241
+ /**
242
+ * Build the radial-gradient `C len -(c1x c1y r1 c2x c2y r2 2 <stops>)` fill op.
243
+ * Reuses getGradientPoints (radial) for the center/radii, un-negating its SVG
244
+ * y. r1 = outerR/4, r2 = outerR; c2 is the center, c1 the center offset by r1
245
+ * along the gradient angle (== center when angle 0).
246
+ * @see plugin/core/gvrender_core_dot.c:562-585 xdot_gradient_fillcolor (radial)
247
+ */
248
+ export function radialGradientOp(
249
+ pts: Point[],
250
+ fillColor: GVColor,
251
+ stopColor: GVColor,
252
+ frac: number,
253
+ angleDeg: number,
254
+ ): string {
255
+ const rad = (angleDeg * Math.PI) / 180;
256
+ // isRHS=true: native y-up coords, matching C's get_gradient_points(A,G,n,0,3).
257
+ const gp = getGradientPoints(pts, rad, true, true);
258
+ const cx = gp.g0.x;
259
+ const cy = gp.g0.y;
260
+ const r1 = gp.g1.x;
261
+ const r2 = gp.g1.y;
262
+ const c1x = angleDeg === 0 ? cx : cx + r1 * Math.cos(rad);
263
+ const c1y = angleDeg === 0 ? cy : cy + r1 * Math.sin(rad);
264
+ const inner =
265
+ '(' + xdotNum(c1x) + ' ' + xdotNum(c1y) + ' ' + xdotNum(r1) + ' ' +
266
+ xdotNum(cx) + ' ' + xdotNum(cy) + ' ' + xdotNum(r2) + ' 2 ' +
267
+ gradientStops(fillColor, stopColor, frac) + ')';
268
+ return 'C ' + String(utf8Len(inner)) + ' -' + inner + ' ';
269
+ }
270
+
271
+ /** Pen ("c ") color op from a resolved GVColor. */
272
+ export function xdotPenColor(c: GVColor): string {
273
+ return xdotColorOp('c ', gvColorRgba(c));
274
+ }
275
+
276
+ /** Fill ("C ") color op from a resolved GVColor. */
277
+ export function xdotFillColor(c: GVColor): string {
278
+ return xdotColorOp('C ', gvColorRgba(c));
279
+ }
280
+
281
+ /**
282
+ * Build the xdot "F size len -name " font op. Mirrors xdot_textspan's `F` +
283
+ * `xdot_str(job, "", font->name)` — the length prefix is the byte length of the
284
+ * face name. @see plugin/core/gvrender_core_dot.c:498 xdot_textspan
285
+ */
286
+ export function xdotFont(size: number, name: string): string {
287
+ return 'F ' + xdotNum(size > 0 ? size : 0) + ' ' + String(utf8Len(name)) + ' -' + name + ' ';
288
+ }
289
+
290
+ /**
291
+ * Build an xdot length-prefixed string op ("S "/"" prefix): "<pfx><len> -<s> ".
292
+ * @see plugin/core/gvrender_core_dot.c:83 xdot_str_xbuf
293
+ */
294
+ export function xdotStrOp(prefix: string, s: string): string {
295
+ return prefix + String(utf8Len(s)) + ' -' + s + ' ';
296
+ }
297
+
298
+ /**
299
+ * Quote a DOT identifier unless it is a bare id or numeral, mirroring agwrite's
300
+ * agcanonStr so the serialized graph reparses (the comparator reparses both
301
+ * sides). Only `"` is escaped (→ `\"`); a `\` is left as-is — it is already the
302
+ * start of a stored escape like `\n`/`\l` that agcanonStr keeps verbatim, so
303
+ * doubling it (`\\n`) would change the name (`a\n(b\n"c")` must stay `a\n…`, not
304
+ * `a\\n…`). Over-quoting a value native leaves bare is harmless: both parse to
305
+ * the same name. @see lib/cgraph/write.c:_agstrcanon (escapes '"', keeps '\')
306
+ *
307
+ * ONE DELIBERATE DIVERGENCE from _agstrcanon: an ODD trailing backslash run is
308
+ * padded to even. C copies it verbatim and appends the closing quote, so a name
309
+ * ending in a single `\` serializes as `"a\"` — the backslash escapes the quote
310
+ * and the output does not reparse. The DOT lexer cannot produce such a name
311
+ * (source `"a\"` never terminates), so this is unreachable from any file and no
312
+ * corpus id changes; it is reachable only through the programmatic API, where a
313
+ * caller supplies the string directly. Padding costs a name that round-trips to
314
+ * `a\\` instead of `a\`, which beats emitting a document that cannot be parsed.
315
+ * @see CodeQL "Incomplete string escaping or encoding" — the alert's own remedy
316
+ * (escape every `\`) is wrong here and breaks `\n`/`\l` parity.
317
+ */
318
+ export function xdotId(s: string): string {
319
+ if (/^[A-Za-z_][A-Za-z_0-9]*$/.test(s)) return s;
320
+ if (/^-?(\.[0-9]+|[0-9]+(\.[0-9]*)?)$/.test(s)) return s;
321
+ const body = s.replace(/"/g, '\\"');
322
+ const trailingBackslashes = /\\*$/.exec(body)![0].length;
323
+ return '"' + body + (trailingBackslashes % 2 === 1 ? '\\' : '') + '"';
324
+ }
325
+
326
+ /**
327
+ * Format a number as C's `%.5g` — 5 significant figures, trailing zeros and
328
+ * point trimmed, switching to `e±NN` (min 2 exponent digits) when the exponent
329
+ * is < -4 or ≥ 5. Native writes the DOT `pos`/`bb`/`width`/`height` attributes
330
+ * with `%.5g` (output.c:71/294/302), so large coordinates round to 5 sig figs
331
+ * (`2219962` → `2.2201e+06`); `printNum`'s fixed 3 dp keeps too much precision.
332
+ *
333
+ * Uses {@link printfSig} rather than `Number.prototype.toPrecision(5)` so
334
+ * exact-halfway values (e.g. 1399.25) round half-to-even like C's snprintf,
335
+ * not half-away-from-zero like `toPrecision`. @see docs proven case
336
+ * graphs-b786 (circo): pos coordinate 1399.25 → native "1399.2", `toPrecision`
337
+ * gave "1399.3".
338
+ * @see lib/common/output.c:71 (agxbprint "%.5g")
339
+ */
340
+ export function gfmt5(v: number): string {
341
+ if (!Number.isFinite(v)) return String(v);
342
+ if (v === 0) return '0';
343
+ let s = printfSig(v, 5);
344
+ const e = s.indexOf('e');
345
+ if (e >= 0) {
346
+ let mant = s.slice(0, e);
347
+ if (mant.indexOf('.') >= 0) mant = mant.replace(/0+$/, '').replace(/\.$/, '');
348
+ const ei = parseInt(s.slice(e + 1), 10);
349
+ return mant + 'e' + (ei < 0 ? '-' : '+') + String(Math.abs(ei)).padStart(2, '0');
350
+ }
351
+ if (s.indexOf('.') >= 0) s = s.replace(/0+$/, '').replace(/\.$/, '');
352
+ return s;
353
+ }
354
+
355
+ /**
356
+ * Format a number as C's `%.2f` — fixed 2 decimals, half-to-even on exact ties.
357
+ * `attach_attrs` writes the graph-label size attributes `lwidth`/`lheight` in
358
+ * INCHES with `%.2f`, unlike every other computed attribute (which uses `%.5g`).
359
+ * @see lib/common/output.c:244-247 (agxbprint "%.2f", PS2INCH)
360
+ */
361
+ export function gfmt2(v: number): string {
362
+ return printfFixed(v, 2);
363
+ }
364
+
365
+ /**
366
+ * Format a label position as the `x,y` pair every computed `*_lp` attribute
367
+ * uses: both coordinates at `%.5g`. C passes y through `yDir()`, which is the
368
+ * identity unless `Y_invert` — and `Y_invert` is only set by `-Ty` (plain/dot),
369
+ * never for xdot — so no inversion is applied here, exactly as the existing
370
+ * `pos`/`bb` emission does.
371
+ * @see lib/common/output.c:35 yDir · lib/common/output.c:241 (agxbprint "%.5g,%.5g")
372
+ */
373
+ export function lpStr(p: Point): string {
374
+ return gfmt5(p.x) + ',' + gfmt5(p.y);
375
+ }
376
+
377
+ /**
378
+ * Escape backslashes in a LABEL draw string — C's put_escaping_backslashes,
379
+ * applied to the `_ldraw_`/`_hldraw_`/`_tldraw_` buffers (not the shape draws)
380
+ * before agset. A literal `\` in a label's text (e.g. `WXYZ\nabc`) becomes `\\`
381
+ * so it survives the DOT string round-trip; the T-op byte-length prefix stays on
382
+ * the UNescaped text. @see plugin/core/gvrender_core_dot.c:218 put_escaping_backslashes
383
+ */
384
+ export function escBackslash(s: string): string {
385
+ return s.replace(/\\/g, '\\\\');
386
+ }
387
+
388
+ /** True if s[i..] starts an escape sequence agcanonStr keeps verbatim: a `\`
389
+ * followed by one of E G H L N T l n r \ ". @see lib/cgraph/write.c:is_escape */
390
+ export function isEscapeSeq(s: string, i: number): boolean {
391
+ if (s[i] !== '\\') return false;
392
+ const n = s[i + 1];
393
+ return n === 'E' || n === 'G' || n === 'H' || n === 'L' || n === 'N' || n === 'T'
394
+ || n === 'l' || n === 'n' || n === 'r' || n === '\\' || n === '"';
395
+ }
396
+
397
+ /**
398
+ * Escape a value for a DOT attribute exactly as agwrite's agcanonStr does: a `"`
399
+ * becomes `\"` ONLY when it is not already part of an escape sequence, so an
400
+ * existing `\"` or `\\` in the value is passed through verbatim rather than
401
+ * double-escaped. For an already-backslash-doubled value (node/edge/graph labels,
402
+ * post put_escaping_backslashes) this is identical to a naive `"`→`\"` — every
403
+ * `"` follows a doubled `\\`, so none are ever part_of_escape. For raw cluster
404
+ * labels (agset, no put_escaping) it preserves the source `\"`/`\\` unchanged.
405
+ * @see lib/cgraph/write.c:_agstrcanon (135-167)
406
+ */
407
+ export function agcanonEscape(s: string): string {
408
+ let out = '';
409
+ let partOfEscape = false;
410
+ for (let i = 0; i < s.length; i++) {
411
+ const c = s[i]!;
412
+ if (c === '"' && !partOfEscape) {
413
+ out += '\\';
414
+ } else if (!partOfEscape && isEscapeSeq(s, i)) {
415
+ partOfEscape = true;
416
+ } else {
417
+ partOfEscape = false;
418
+ }
419
+ out += c;
420
+ }
421
+ return out;
422
+ }
423
+
424
+ /** Trim a "%.3f" fixed string like C's agxbuf_trim_zeros (trailing 0s + dot). */
425
+ export function trimFixed3(v: number): string {
426
+ let s = v.toFixed(3);
427
+ if (s.indexOf('.') < 0) return s;
428
+ let end = s.length;
429
+ while (end > 0 && s[end - 1] === '0') end--;
430
+ if (s[end - 1] === '.') end--;
431
+ return s.slice(0, end);
432
+ }