@knowvah/dot-engine 1.0.0 → 1.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Faithful TypeScript port of Graphviz (dot, neato, fdp, sfdp, circo, twopi, osage, patchwork) — no C: no native binary, no WASM. Oracle-verified against the C implementation, runs in the browser.",
5
5
  "keywords": [
6
6
  "graphviz",
package/src/api/index.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  // SPDX-License-Identifier: EPL-2.0
2
2
 
3
3
  /**
4
- * `graphviz-ts/api` — the build + inspect + geometry entry point (ADR-2).
4
+ * `@knowvah/dot-engine/api` — the build + inspect + geometry entry point (ADR-2).
5
5
  *
6
6
  * Pure re-export barrel for the api layer: programmatic graph construction
7
7
  * (the builder), the safe edge helper, and the computed-geometry snapshot.
8
8
  *
9
9
  * Typical flow: call {@link createGraph} to build nodes/edges/subgraphs
10
10
  * without hand-writing DOT text, hand the builder's `.graph` (or a
11
- * `parse()` result) to `render()` from `graphviz-ts/render` (or the root
11
+ * `parse()` result) to `render()` from `@knowvah/dot-engine/render` (or the root
12
12
  * package) to lay out and emit an output format, then optionally call
13
13
  * {@link getLayout} here to read back computed node/edge/cluster geometry
14
14
  * from the laid-out graph.
@@ -59,7 +59,7 @@ function adviseHostFaithful(): void {
59
59
  if (process.env?.GV_FONT_QUIET || !process.stderr?.isTTY) return;
60
60
  adviceShown = true;
61
61
  process.stderr.write(
62
- 'graphviz-ts: measuring text with built-in metrics (kerning/shaping not applied). '
62
+ '@knowvah/dot-engine: measuring text with built-in metrics (kerning/shaping not applied). '
63
63
  + 'For host-faithful sizing matching the rendering font, install `canvas` and call '
64
64
  + "setTextMeasurer(new CanvasTextMeasurer(createCanvas(0,0).getContext('2d'))). "
65
65
  + 'Silence with GV_FONT_QUIET=1.\n',
package/src/errors.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: EPL-2.0
2
2
 
3
3
  /**
4
- * Public structured-error contract for graphviz-ts.
4
+ * Public structured-error contract for @knowvah/dot-engine.
5
5
  *
6
6
  * Consumers branch on the stable `code` / `type` fields; the
7
7
  * `code -> friendlyMessage` map is the single seam a future i18n library
package/src/gvc/anchor.ts CHANGED
@@ -392,8 +392,16 @@ export function computeNodeUrlMap(n: Node, obj: ObjState, ctx: MapCtx): void {
392
392
  * @see lib/common/emit.c:emit_page (3623-3642)
393
393
  */
394
394
  export function computeGraphUrlMap(obj: ObjState, ctx: MapCtx): void {
395
- const ll = { x: ctx.bb.ll.x - ctx.pad.x, y: ctx.bb.ll.y - ctx.pad.y };
396
- const ur = { x: ctx.bb.ur.x + ctx.pad.x, y: ctx.bb.ur.y + ctx.pad.y };
395
+ // C uses job->clip — the 0-BASED page window ([-pad, (UR-LL)+pad] in graph
396
+ // coords), not bb±pad. Identical when bb.LL is the origin, but engines that
397
+ // keep a negative bb.LL (fdp graph labels: bb "0,-24.8,...") shift the root
398
+ // rect: native url.gv fdp emits 0,-33,539,374 where bb±pad would give
399
+ // 0,0,539,407. @see lib/common/emit.c:3652 emit_map_rect(job, job->clip)
400
+ const ll = { x: -ctx.pad.x, y: -ctx.pad.y };
401
+ const ur = {
402
+ x: ctx.bb.ur.x - ctx.bb.ll.x + ctx.pad.x,
403
+ y: ctx.bb.ur.y - ctx.bb.ll.y + ctx.pad.y,
404
+ };
397
405
  obj.urlMapShape = MapShape.Rectangle;
398
406
  obj.urlMapPts = [mapTransform(ll, ctx), mapTransform(ur, ctx)];
399
407
  }
package/src/gvc/device.ts CHANGED
@@ -50,7 +50,7 @@ import { emitRoundedBezier } from '../common/poly-shapes.js';
50
50
  import { applyClusterObjState, clusterStyle, clusterPeripheries } from './device-cluster.js';
51
51
  /** Cluster labels go through the single emit_label port. @see labels.c:emit_label */
52
52
  export function renderClusterLabel(sg: Graph, renderer: RendererPlugin, job: RenderJob): void {
53
- renderOneLabel(sg.info.label as TextlabelT | undefined, renderer, job);
53
+ renderOneLabel(sg.info.label as TextlabelT | undefined, renderer, job, false);
54
54
  }
55
55
  import { svgNodeId, svgEdgeId, svgClusterId, svgGraphId } from '../render/svg-id.js';
56
56
 
@@ -302,8 +302,15 @@ export function renderOneLabel(
302
302
  lp: TextlabelT | undefined,
303
303
  renderer: RendererPlugin,
304
304
  job: RenderJob,
305
+ requireSet = true,
305
306
  ): void {
306
- if (!lp?.set) return; // emit_label: lbl == NULL || !lbl->set
307
+ // C gates `->set` at the xlabel/edge-label CALL SITES (emit.c:1829,
308
+ // emit_edge_label:2891), but draws root-graph and cluster labels on
309
+ // existence alone (emit.c:3656, 3920) — an unplaced label (e.g. fdp's
310
+ // non-comparable-clusters abort skips gv_postprocess) still renders at its
311
+ // default pos. Callers mirroring the existence-only sites pass false.
312
+ if (!lp) return;
313
+ if (requireSet && !lp.set) return;
307
314
  // HTML branch: @see lib/common/labels.c:emit_label (226-230)
308
315
  // C routes to emit_html_label(job, lp->u.html, lp) using lp->pos as anchor.
309
316
  if (lp.html) {
@@ -346,7 +353,7 @@ export function renderNodeXLabel(n: Node, renderer: RendererPlugin, job: RenderJ
346
353
  export function renderGraphLabel(g: Graph, renderer: RendererPlugin, job: RenderJob): void {
347
354
  // @see lib/common/emit.c:emit_begin_graph / getObjId (root graph → graph0)
348
355
  setHtmlAnchorObj(svgGraphId(g, job), labelTextOf(g.info.label), job.obj ?? undefined);
349
- renderOneLabel(g.info.label as TextlabelT | undefined, renderer, job);
356
+ renderOneLabel(g.info.label as TextlabelT | undefined, renderer, job, false);
350
357
  }
351
358
 
352
359
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // SPDX-License-Identifier: EPL-2.0
2
2
 
3
3
  /**
4
- * Public entry point for the graphviz-ts library.
4
+ * Public entry point for the @knowvah/dot-engine library.
5
5
  *
6
6
  * Wires together the parser, layout engines, SVG renderer, and GVC
7
7
  * orchestration layer into a single renderSvg function.
@@ -154,8 +154,8 @@ export type { BuiltinEngine, EngineName } from './gvc/context.js';
154
154
  export { render as renderWithContext } from './gvc/device.js';
155
155
 
156
156
  // Discoverable root re-exports of the api + render surfaces (ADR-2): root
157
- // `graphviz-ts` exposes everything from `graphviz-ts/api` and
158
- // `graphviz-ts/render` for one-import discoverability.
157
+ // `@knowvah/dot-engine` exposes everything from `@knowvah/dot-engine/api` and
158
+ // `@knowvah/dot-engine/render` for one-import discoverability.
159
159
  //
160
160
  // Collision resolution: the root `render` is the new public
161
161
  // `render(g, format, opts?)` from `./render`. The low-level
@@ -9,6 +9,7 @@
9
9
  * @see lib/neatogen/neatoprocs.h
10
10
  */
11
11
 
12
+ import type { ShapeDesc } from '../../common/types.js';
12
13
  import type { Graph } from '../../model/graph.js';
13
14
  import type { Node } from '../../model/node.js';
14
15
  import type { Edge } from '../../model/edge.js';
@@ -85,8 +86,18 @@ export function neatoInitNode(n: Node, dim = DFLT_DIM): void {
85
86
  n.info.pos = pos;
86
87
  }
87
88
  if (n.info.UF_size === undefined) n.info.UF_size = 1;
88
- if (!n.info.width) n.info.width = 0.75;
89
- if (!n.info.height) n.info.height = 0.5;
89
+ // C neato_init_node has NO size defaulting (neatoinit.c:60-66) — after
90
+ // common_init_node, ND_width is always written (late_double clamps a 0
91
+ // attr to the 0.75 default; every initfn assigns). The port's NodeInfo
92
+ // zero-initializes width/height (calloc mirror) and two port-only paths
93
+ // leave them at that unset-0 (no-measurer initNodeDefaults; poly-null
94
+ // custom/epsf shapes — C gives customs box geometry, a modeling gap
95
+ // flagged in the journal), so a fallback is still needed. The ONE case
96
+ // where 0 is a legitimate post-init size is shape=plain (poly_init
97
+ // IS_PLAIN, shapes.c:1962) — never clobber it.
98
+ const plainShaped = (n.info.shape as ShapeDesc | undefined)?.name === 'plain';
99
+ if (!n.info.width && !plainShaped) n.info.width = 0.75;
100
+ if (!n.info.height && !plainShaped) n.info.height = 0.5;
90
101
  }
91
102
 
92
103
  // ---------------------------------------------------------------------------
@@ -8,6 +8,7 @@
8
8
  * @see lib/twopigen/twopiinit.c
9
9
  */
10
10
 
11
+ import type { ShapeDesc } from '../../common/types.js';
11
12
  import type { Graph } from '../../model/graph.js';
12
13
  import type { Node } from '../../model/node.js';
13
14
  import type { Edge } from '../../model/edge.js';
@@ -47,8 +48,12 @@ function makeRdata(INF: number): TwopiAlgData {
47
48
  */
48
49
  export function twopiInitNode(n: Node): void {
49
50
  if (!n.info.pos || n.info.pos.length < 2) n.info.pos = [0, 0];
50
- if (!n.info.width) n.info.width = 0.75;
51
- if (!n.info.height) n.info.height = 0.5;
51
+ // shape=plain legitimately sizes 0×0 (poly_init IS_PLAIN) — never clobber
52
+ // it; all other zero widths are the port's unset-0. See neatoInitNode's
53
+ // note for the full rationale. @see lib/neatogen/neatoinit.c:60-66
54
+ const plainShaped = (n.info.shape as ShapeDesc | undefined)?.name === 'plain';
55
+ if (!n.info.width && !plainShaped) n.info.width = 0.75;
56
+ if (!n.info.height && !plainShaped) n.info.height = 0.5;
52
57
  }
53
58
 
54
59
  /**
@@ -1,12 +1,12 @@
1
1
  // SPDX-License-Identifier: EPL-2.0
2
2
 
3
3
  /**
4
- * `graphviz-ts/render` — the output-formats + draw-ops entry point (ADR-2).
4
+ * `@knowvah/dot-engine/render` — the output-formats + draw-ops entry point (ADR-2).
5
5
  *
6
6
  * Pure re-export barrel for the public render surface: the multi-format
7
7
  * `render()` entry and the structured xdot draw-op access.
8
8
  *
9
- * Typical flow: build or `parse()` a graph (see `graphviz-ts/api`), then
9
+ * Typical flow: build or `parse()` a graph (see `@knowvah/dot-engine/api`), then
10
10
  * either call `render(g, format, opts?)` for a serialized output string —
11
11
  * `format` is one of {@link OutputFormat} (`'svg'`, `'dot'`, `'xdot'`,
12
12
  * `'json'`, `'plain'`, ...) — or call {@link getDrawOps} to skip string
package/src/render/map.ts CHANGED
@@ -16,8 +16,11 @@ import type { Edge } from '../model/edge.js';
16
16
  import type { Point, Box } from '../model/geom.js';
17
17
  import { POINTS_PER_INCH } from '../model/geom.js';
18
18
  import type { TextSpan } from '../common/emit-types.js';
19
- import type { TextlabelT } from '../common/types.js';
19
+ import { ShapeKind, type ShapeDesc, type TextlabelT } from '../common/types.js';
20
20
  import { lateDouble } from '../common/nodeinit.js';
21
+ import { nodeAttr } from '../common/poly-init.js';
22
+ import { htmlValueContent, isHtmlValue } from '../common/html-string.js';
23
+ import { nodesInSeq } from '../layout/dot/decomp.js';
21
24
  import { substObjAnchor } from '../common/subst.js';
22
25
  import type { RendererPlugin } from '../gvc/context.js';
23
26
  import type { ObjState, RenderJob } from '../gvc/job.js';
@@ -58,13 +61,74 @@ export interface AnchorCtx {
58
61
  // printG5 — @see lib/common/output.c:printdouble (%.5g)
59
62
  // ---------------------------------------------------------------------------
60
63
 
61
- /** Format with 5 significant figures, trailing zeros stripped. */
64
+ /** Exact decimal digits of a finite double: doubles are binary rationals, so
65
+ * the expansion terminates. Returns significant digits and the base-10
66
+ * exponent of the first digit (value = 0.d₁d₂… × 10^(exp10+1)). */
67
+ function exactDecimalDigits(v: number): { digits: string; exp10: number } {
68
+ const dv = new DataView(new ArrayBuffer(8));
69
+ dv.setFloat64(0, Math.abs(v));
70
+ const bits = dv.getBigUint64(0);
71
+ const expBits = Number((bits >> 52n) & 0x7ffn);
72
+ const mant = bits & 0xfffffffffffffn;
73
+ const m = expBits === 0 ? mant : mant | (1n << 52n);
74
+ const e = (expBits === 0 ? 1 : expBits) - 1075;
75
+ let intDigits: string;
76
+ let fracLen = 0;
77
+ if (e >= 0) {
78
+ intDigits = (m << BigInt(e)).toString();
79
+ } else {
80
+ intDigits = (m * 5n ** BigInt(-e)).toString();
81
+ fracLen = -e;
82
+ }
83
+ if (intDigits.length <= fracLen) {
84
+ intDigits = '0'.repeat(fracLen - intDigits.length + 1) + intDigits;
85
+ }
86
+ const pointPos = intDigits.length - fracLen; // digits before the decimal point
87
+ const firstSig = intDigits.search(/[1-9]/);
88
+ return { digits: intDigits.slice(firstSig), exp10: pointPos - firstSig - 1 };
89
+ }
90
+
91
+ /** Format with 5 significant figures, trailing zeros stripped — C snprintf
92
+ * `%.5g`. JS toPrecision rounds exact decimal ties away from zero, but C
93
+ * (round-to-nearest-even FP mode) rounds them to even, and integer point
94
+ * coordinates land on exact .x5 inch ties (e.g. 78498pt/72 = 1090.25 →
95
+ * "1090.2", not "1090.3"). Round from the exact expansion instead.
96
+ * @see lib/common/output.c:printdouble */
62
97
  export function printG5(v: number): string {
63
- const s = v.toPrecision(5);
64
- if (s.includes('.') && !s.includes('e')) {
65
- return s.replace(/\.?0+$/, '');
98
+ if (v === 0 || !Number.isFinite(v)) return String(v);
99
+ const P = 5;
100
+ const { digits, exp10 } = exactDecimalDigits(v);
101
+ let sig = digits.slice(0, P);
102
+ if (sig.length < P) sig += '0'.repeat(P - sig.length);
103
+ const rest = digits.slice(P);
104
+ const restNonzeroAfterFirst = /[1-9]/.test(rest.slice(1));
105
+ const roundUp = rest.length > 0 && (
106
+ rest[0]! > '5'
107
+ || (rest[0] === '5' && restNonzeroAfterFirst)
108
+ || (rest[0] === '5' && !restNonzeroAfterFirst
109
+ && (sig.charCodeAt(P - 1) - 0x30) % 2 === 1));
110
+ let exp = exp10;
111
+ if (roundUp) {
112
+ const n = String(BigInt(sig) + 1n);
113
+ sig = n.length > P ? (exp++, n.slice(0, P)) : n;
114
+ }
115
+ const sign = v < 0 ? '-' : '';
116
+ // %g style selection: exponential when exp < -4 or exp >= precision.
117
+ if (exp < -4 || exp >= P) {
118
+ const mant = (sig[0]! + '.' + sig.slice(1)).replace(/\.?0+$/, '');
119
+ const as = Math.abs(exp);
120
+ return sign + mant + 'e' + (exp < 0 ? '-' : '+') + (as < 10 ? '0' : '') + String(as);
121
+ }
122
+ let s: string;
123
+ if (exp >= sig.length - 1) {
124
+ s = sig + '0'.repeat(exp - sig.length + 1);
125
+ } else if (exp >= 0) {
126
+ s = sig.slice(0, exp + 1) + '.' + sig.slice(exp + 1);
127
+ } else {
128
+ s = '0.' + '0'.repeat(-exp - 1) + sig;
66
129
  }
67
- return s;
130
+ if (s.includes('.')) s = s.replace(/\.?0+$/, '');
131
+ return sign + s;
68
132
  }
69
133
 
70
134
  /** Convert points → inches (PS2INCH = 1/72) and format as %.5g. */
@@ -72,44 +136,207 @@ export function plainCoord(v: number): string {
72
136
  return printG5(v / 72);
73
137
  }
74
138
 
139
+ // ---------------------------------------------------------------------------
140
+ // agstrcanon — DOT-canonical string form for plain names/labels/ports.
141
+ // @see lib/cgraph/write.c:_agstrcanon / agstrcanon / agcanonhtmlstr
142
+ // ---------------------------------------------------------------------------
143
+
144
+ /** must agree with scan.l @see lib/cgraph/write.c:120 tokenlist */
145
+ const CANON_KEYWORDS = ['node', 'edge', 'strict', 'graph', 'digraph', 'subgraph'];
146
+
147
+ /** Line-break threshold. agwrite may override via `linelength` but always
148
+ * restores this value, so the plain path (direct agstrcanon, no agwrite)
149
+ * observes the default. @see lib/cgraph/write.c:44,676,692 */
150
+ const MAX_OUTPUTLINE = 128;
151
+
152
+ function isDigitByte(b: number): boolean { return b >= 0x30 && b <= 0x39; }
153
+
154
+ function isAlnumByte(b: number): boolean {
155
+ return isDigitByte(b) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a);
156
+ }
157
+
158
+ /** alphanumeric, '.', '-', or non-ascii byte. @see lib/cgraph/write.c:is_id_char */
159
+ function isIdCharByte(b: number): boolean {
160
+ return isAlnumByte(b) || b === 0x2e || b === 0x2d || b >= 0x80;
161
+ }
162
+
163
+ /** Recognized escString escape starting at bytes[i]. @see lib/cgraph/write.c:is_escape */
164
+ function isEscapeAt(bytes: Uint8Array, i: number): boolean {
165
+ if (bytes[i] !== 0x5c) return false;
166
+ const c = bytes[i + 1];
167
+ return c !== undefined && 'EGHLNTlnr\\"'.includes(String.fromCharCode(c));
168
+ }
169
+
170
+ /** The needs-quotes / numeral / escape state scan of `_agstrcanon`, over UTF-8
171
+ * BYTES (C iterates bytes: cnt counts bytes for line breaking, and non-ascii
172
+ * bytes are id chars). Returns the quoted buffer or the untouched input.
173
+ * @see lib/cgraph/write.c:_agstrcanon */
174
+ export function agstrcanonText(arg: string): string {
175
+ if (arg.length === 0) return '""';
176
+ const bytes = new TextEncoder().encode(arg);
177
+ const out: number[] = [0x22];
178
+ let needsQuotes = false;
179
+ let partOfEscape = false;
180
+ let backslashPending = false;
181
+ let cnt = 0;
182
+ let dotcnt = 0;
183
+ let maybeNum = isDigitByte(bytes[0]!) || bytes[0] === 0x2e || bytes[0] === 0x2d;
184
+ for (let i = 0; i < bytes.length; i++) {
185
+ const uc = bytes[i]!;
186
+ if (uc === 0x22 && !partOfEscape) { // '"' not already part of an escape
187
+ out.push(0x5c);
188
+ needsQuotes = true;
189
+ } else if (!partOfEscape && isEscapeAt(bytes, i)) {
190
+ needsQuotes = true;
191
+ partOfEscape = true;
192
+ } else if (maybeNum) {
193
+ if (uc === 0x2d) { // '-' legal only as the first char of a numeral
194
+ if (cnt) { maybeNum = false; needsQuotes = true; }
195
+ } else if (uc === 0x2e) { // one '.' allowed
196
+ if (dotcnt++) { maybeNum = false; needsQuotes = true; }
197
+ } else if (!isDigitByte(uc)) {
198
+ maybeNum = false;
199
+ needsQuotes = true;
200
+ }
201
+ partOfEscape = false;
202
+ } else if (!(isAlnumByte(uc) || uc === 0x5f || uc >= 0x80)) {
203
+ needsQuotes = true;
204
+ partOfEscape = false;
205
+ } else {
206
+ partOfEscape = false;
207
+ }
208
+ out.push(uc);
209
+ cnt++;
210
+ const next = bytes[i + 1];
211
+ // Long-string line breaking: only after a non-id, non-backslash output
212
+ // char where the next input char is an id char. @see write.c:170-190
213
+ if (next !== undefined) {
214
+ const last = out[out.length - 1]!;
215
+ const canBreak = !(isIdCharByte(last) || last === 0x5c) && isIdCharByte(next);
216
+ if (backslashPending && canBreak) {
217
+ out.push(0x5c, 0x0a);
218
+ needsQuotes = true;
219
+ backslashPending = false;
220
+ cnt = 0;
221
+ } else if (cnt >= MAX_OUTPUTLINE) {
222
+ if (canBreak) {
223
+ out.push(0x5c, 0x0a);
224
+ needsQuotes = true;
225
+ cnt = 0;
226
+ } else {
227
+ backslashPending = true;
228
+ }
229
+ }
230
+ }
231
+ }
232
+ out.push(0x22);
233
+ const first = bytes[0]!;
234
+ if (needsQuotes || (cnt === 1 && (first === 0x2e || first === 0x2d))) {
235
+ return new TextDecoder().decode(new Uint8Array(out));
236
+ }
237
+ // Quotes protect DOT keywords (e.g. a node named "node"). @see write.c:199-203
238
+ const lower = arg.toLowerCase();
239
+ for (const tok of CANON_KEYWORDS) {
240
+ if (tok === lower) return new TextDecoder().decode(new Uint8Array(out));
241
+ }
242
+ return arg;
243
+ }
244
+
245
+ /** late_nnstring: default when the attr is missing OR empty.
246
+ * @see lib/common/utils.c:late_nnstring */
247
+ function lateNN(v: string | undefined, def: string): string {
248
+ return v !== undefined && v !== '' ? v : def;
249
+ }
250
+
75
251
  /** Resolve fill color: fillcolor attr, then color attr, then lightgrey. */
76
- export function plainNodeFill(n: Node): string {
77
- const color = n.attrs.get('color') ?? 'black';
78
- return n.attrs.get('fillcolor') || color || 'lightgrey';
252
+ export function plainNodeFill(n: Node, g: Graph): string {
253
+ // @see lib/common/output.c:write_plain (167-169): fillcolor attr if non-empty,
254
+ // else the color attr, else DEFAULT_FILL ("lightgrey"). Note the fallback is
255
+ // DEFAULT_FILL, not DEFAULT_COLOR — an unfilled node's plain fill field is
256
+ // "lightgrey", not "black".
257
+ const fillcolor = lateNN(nodeAttr(n, g, 'fillcolor'), '');
258
+ if (fillcolor !== '') return fillcolor;
259
+ return lateNN(nodeAttr(n, g, 'color'), 'lightgrey');
79
260
  }
80
261
 
81
262
  // ---------------------------------------------------------------------------
82
263
  // Plain format helpers — @see lib/common/output.c:write_plain
83
264
  // ---------------------------------------------------------------------------
84
265
 
266
+ /** The label field of a plain node line: HTML labels re-wrap the ORIGINAL
267
+ * label attr in `<...>` (agstrcanon's aghtmlstr branch on agxget(n, N_label));
268
+ * everything else — including record labels, whose textlabel keeps the raw
269
+ * unsubstituted source — canonicalizes ND_label(n)->text.
270
+ * @see lib/common/output.c:write_plain (152-158) */
271
+ function plainNodeLabel(n: Node, g: Graph): string {
272
+ const lbl = n.info.label as TextlabelT | undefined;
273
+ const attr = nodeAttr(n, g, 'label');
274
+ if (lbl !== undefined && lbl.u.kind === 'html') {
275
+ const a = attr ?? '';
276
+ return '<' + (isHtmlValue(a) ? htmlValueContent(a) : a) + '>';
277
+ }
278
+ // An html-valued attr with a NON-html label object = the HTML parse failed:
279
+ // the port's fallback label is empty (matching C's drawn spans), but C's
280
+ // ND_label->text still holds the raw markup, which plain prints quoted.
281
+ // @see lib/common/labels.c:make_label (html branch gv_strdup before parse)
282
+ if (attr !== undefined && isHtmlValue(attr) && lbl !== undefined && lbl.u.kind === 'txt') {
283
+ return agstrcanonText(htmlValueContent(attr));
284
+ }
285
+ // C record textlabels keep the raw UNSUBSTITUTED label source (make_label's
286
+ // is_record branch gv_strdup's it; substitution happens per-field at record
287
+ // parse). The port's record label resolves the default to the node name, so
288
+ // reconstruct C's text: the label attr, or cgraph's always-present N_label
289
+ // default "\N". @see lib/common/labels.c:make_label ; lib/common/input.c:468
290
+ const shape = n.info.shape as ShapeDesc | undefined;
291
+ if (shape !== undefined && shape.kind === ShapeKind.SH_RECORD) {
292
+ return agstrcanonText(nodeAttr(n, g, 'label') ?? '\\N');
293
+ }
294
+ const text = lbl !== undefined ? lbl.text : (n.attrs.get('label') ?? n.name);
295
+ return agstrcanonText(text);
296
+ }
297
+
85
298
  /** Read the five style attrs needed for a plain node line. */
86
- export function plainNodeAttrs(n: Node): PlainNodeAttrs {
299
+ export function plainNodeAttrs(n: Node, g: Graph): PlainNodeAttrs {
300
+ // style/shape/color resolve through the node-defaults chain (C agxget sees
301
+ // `node [...]` defaults). C prints ND_shape(n)->name, which bind_shape
302
+ // derives purely from attrs: a non-epsf shapefile forces "custom", unknown
303
+ // names keep the user's name (user_shape), default is "ellipse" — so the
304
+ // port's resolved-fallback ShapeDesc name must NOT be used here.
305
+ // @see lib/common/output.c:write_plain (163-166)
306
+ // @see lib/common/shapes.c:bind_shape / user_shape
307
+ const shapeAttr = lateNN(nodeAttr(n, g, 'shape'), 'ellipse');
308
+ const shapefile = nodeAttr(n, g, 'shapefile');
309
+ const shapeName =
310
+ shapefile !== undefined && shapefile !== '' && shapeAttr !== 'epsf' ? 'custom' : shapeAttr;
87
311
  return {
88
- label: n.attrs.get('label') ?? n.name,
89
- style: n.attrs.get('style') ?? 'solid',
90
- shape: n.attrs.get('shape') ?? 'ellipse',
91
- color: n.attrs.get('color') ?? 'black',
92
- fill: plainNodeFill(n),
312
+ label: plainNodeLabel(n, g),
313
+ style: lateNN(nodeAttr(n, g, 'style'), 'solid'),
314
+ shape: shapeName,
315
+ color: lateNN(nodeAttr(n, g, 'color'), 'black'),
316
+ fill: plainNodeFill(n, g),
93
317
  };
94
318
  }
95
319
 
96
320
  /** Write one node line: `node name x y w h label style shape color fill\n` */
97
- export function writePlainNode(n: Node, out: string[]): void {
321
+ export function writePlainNode(n: Node, g: Graph, out: string[]): void {
98
322
  const x = plainCoord(n.info.coord.x);
99
323
  const y = plainCoord(n.info.coord.y);
100
324
  const w = printG5(n.info.width);
101
325
  const h = printG5(n.info.height);
102
- const a = plainNodeAttrs(n);
103
- out.push('node ' + n.name + ' ' + x + ' ' + y + ' ' + w + ' ' + h
326
+ const a = plainNodeAttrs(n, g);
327
+ out.push('node ' + agstrcanonText(n.name) + ' ' + x + ' ' + y + ' ' + w + ' ' + h
104
328
  + ' ' + a.label + ' ' + a.style + ' ' + a.shape + ' ' + a.color + ' ' + a.fill + '\n');
105
329
  }
106
330
 
107
- /** Flatten all Bezier curves in an edge spline into a point array. */
331
+ /** Flatten all Bezier curves in an edge spline into a point array. Reads
332
+ * exactly bz.size points — routing can leave over-allocated scratch entries
333
+ * past size in bz.list (C sums ED_spl sizes, never the allocation).
334
+ * @see lib/common/output.c:write_plain (183-195) */
108
335
  export function collectSplinePts(e: Edge): Point[] {
109
336
  if (!e.info.spl) return [];
110
337
  const pts: Point[] = [];
111
338
  for (const bz of e.info.spl.list) {
112
- for (const pt of bz.list) pts.push(pt);
339
+ for (let i = 0; i < bz.size; i++) pts.push(bz.list[i]!);
113
340
  }
114
341
  return pts;
115
342
  }
@@ -119,39 +346,75 @@ export function portSuffix(name: string | null): string {
119
346
  return name ? ':' + name : '';
120
347
  }
121
348
 
349
+ /** Write ` name[:port]`, both parts DOT-canonicalized. A cluster proxy node's
350
+ * synthetic `__i:<cluster>` id is written as the cluster name it stands for
351
+ * (C: strchr(agnameof(node), ':') + 1).
352
+ * @see lib/common/output.c:writenodeandport */
353
+ function writeNodeAndPort(n: Node, portname: string, out: string[]): void {
354
+ let name = n.name;
355
+ if (n.info.clustnode) {
356
+ const i = name.indexOf(':');
357
+ if (i >= 0) name = name.slice(i + 1);
358
+ }
359
+ out.push(' ' + agstrcanonText(name));
360
+ if (portname !== '') out.push(':' + agstrcanonText(portname));
361
+ }
362
+
122
363
  /** Write the `edge tail head n pt...` prefix when spline data exists. */
123
364
  export function writePlainEdgeHead(
124
365
  e: Edge, tport: string, hport: string, pts: Point[], out: string[],
125
366
  ): void {
126
- out.push('edge ' + e.tail.name + tport + ' ' + e.head.name + hport
127
- + ' ' + String(pts.length));
367
+ out.push('edge');
368
+ writeNodeAndPort(e.tail, tport, out);
369
+ writeNodeAndPort(e.head, hport, out);
370
+ out.push(' ' + String(pts.length));
128
371
  for (const pt of pts) {
129
372
  out.push(' ' + plainCoord(pt.x) + ' ' + plainCoord(pt.y));
130
373
  }
131
374
  }
132
375
 
133
- /** Write one edge — spline prefix if available, always appends `style color\n`. */
376
+ /** Write one edge — spline prefix if available, then the edge label (when
377
+ * present), always appends `style color\n`. plain-ext ports come from the
378
+ * tailport/headport ATTRS (C agget), which keep any `:compass` suffix the
379
+ * resolved port objects have already split off.
380
+ * @see lib/common/output.c:write_plain (200-208) */
134
381
  export function writePlainEdge(e: Edge, extend: boolean, out: string[]): void {
135
- const tport = extend ? portSuffix(e.info.tail_port.name) : '';
136
- const hport = extend ? portSuffix(e.info.head_port.name) : '';
382
+ const tport = extend ? (e.attrs.get('tailport') ?? '') : '';
383
+ const hport = extend ? (e.attrs.get('headport') ?? '') : '';
137
384
  const pts = collectSplinePts(e);
138
385
  if (pts.length > 0) writePlainEdgeHead(e, tport, hport, pts, out);
139
- const style = e.attrs.get('style') ?? 'solid';
140
- const color = e.attrs.get('color') ?? 'black';
386
+ // Edge label: canon(text) then position, mirroring `if (ED_label(e)) {
387
+ // printstring(canon(...)); printpoint(pos) }`.
388
+ const lbl = e.info.label;
389
+ if (lbl !== undefined) {
390
+ out.push(' ' + agstrcanonText(lbl.text)
391
+ + ' ' + plainCoord(lbl.pos.x) + ' ' + plainCoord(lbl.pos.y));
392
+ }
393
+ const style = lateNN(e.attrs.get('style'), 'solid');
394
+ const color = lateNN(e.attrs.get('color'), 'black');
141
395
  out.push(' ' + style + ' ' + color + '\n');
142
396
  }
143
397
 
144
- /** Write the full plain output: graph header, nodes, edges, stop. */
398
+ /** Write the full plain output: graph header, nodes, edges, stop.
399
+ * Node iteration is agfstnode/agnxtnode (AGSEQ) order, not insertion-Map
400
+ * order; the graph line's scale is job->zoom — the size= fit factor, computed
401
+ * with the dot renderer's pad of 0 (render_features_dot).
402
+ * @see lib/common/output.c:write_plain
403
+ * @see plugin/core/gvrender_core_dot.c:render_features_dot */
145
404
  export function writePlain(g: Graph, job: RenderJob, extend: boolean): void {
146
405
  const w = plainCoord(g.info.bb.ur.x);
147
406
  const h = plainCoord(g.info.bb.ur.y);
148
- job.write('graph ' + printG5(job.zoom) + ' ' + w + ' ' + h + '\n');
149
- for (const [, n] of g.nodes) {
407
+ const zoom = initJobViewportZoom(
408
+ job.bb, parseDrawingSize(g.attrs.get('size')), { x: 0, y: 0 });
409
+ job.write('graph ' + printG5(zoom) + ' ' + w + ' ' + h + '\n');
410
+ const nodes = nodesInSeq(g);
411
+ for (const n of nodes) {
412
+ if (n.info.clustnode) continue; // IS_CLUST_NODE — cluster proxies get no node line
150
413
  const buf: string[] = [];
151
- writePlainNode(n, buf);
414
+ writePlainNode(n, g, buf);
152
415
  job.write(buf.join(''));
153
416
  }
154
- for (const [, n] of g.nodes) {
417
+ for (const n of nodes) {
155
418
  for (const e of n.outEdges(g)) {
156
419
  const buf: string[] = [];
157
420
  writePlainEdge(e, extend, buf);
@@ -53,7 +53,7 @@ export interface RenderOptions {
53
53
  * `false` — unset reproduces the pre-AD-1 raw `xlink:href="src"`
54
54
  * passthrough byte-for-byte. When `true`, the SVG emitter consults the
55
55
  * process-global resolver registered via `setImageResolver` (from
56
- * `graphviz-ts`) for each `image=`/HTML `<IMG>` source; a hit is inlined
56
+ * `@knowvah/dot-engine`) for each `image=`/HTML `<IMG>` source; a hit is inlined
57
57
  * as `data:<mime>;base64,<...>`, a miss (or no resolver registered) falls
58
58
  * back to the raw src passthrough. Has no effect on non-SVG formats.
59
59
  */
@@ -33,7 +33,7 @@ const SVG_XML_DECL =
33
33
  const SVG_DOCTYPE =
34
34
  '<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"\n' +
35
35
  ' "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">\n';
36
- const SVG_GENERATOR_COMMENT = '<!-- Generated by graphviz-ts -->\n';
36
+ const SVG_GENERATOR_COMMENT = '<!-- Generated by @knowvah/dot-engine -->\n';
37
37
 
38
38
  /** Sentinel meaning "emit no background polygon" (bgcolor=transparent on SVG). */
39
39
  const BGCOLOR_TRANSPARENT = '\x00transparent';
@@ -138,7 +138,7 @@ function layoutAndRenderXdot(g: Graph, engine: EngineName): string {
138
138
  * @example
139
139
  * ```ts
140
140
  * import { parse } from '@knowvah/dot-engine';
141
- * import { getDrawOps } from 'graphviz-ts/render';
141
+ * import { getDrawOps } from '@knowvah/dot-engine/render';
142
142
  *
143
143
  * const g = parse('digraph { a -> b; }');
144
144
  * for (const op of getDrawOps(g)) {