@knowvah/dot-engine 2.3.1 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/dist/common/arith.d.ts +12 -0
  2. package/dist/common/arith.d.ts.map +1 -1
  3. package/dist/common/edge-in-box.d.ts +31 -0
  4. package/dist/common/edge-in-box.d.ts.map +1 -0
  5. package/dist/common/htmltable-emit.d.ts +1 -1
  6. package/dist/common/htmltable-emit.d.ts.map +1 -1
  7. package/dist/common/make-label.d.ts +3 -1
  8. package/dist/common/make-label.d.ts.map +1 -1
  9. package/dist/common/poly-gencode.d.ts +12 -1
  10. package/dist/common/poly-gencode.d.ts.map +1 -1
  11. package/dist/gvc/device.d.ts.map +1 -1
  12. package/dist/gvc/emit-gates.d.ts +35 -0
  13. package/dist/gvc/emit-gates.d.ts.map +1 -0
  14. package/dist/gvc/job.d.ts +4 -0
  15. package/dist/gvc/job.d.ts.map +1 -1
  16. package/dist/gvc/viewport.d.ts +13 -0
  17. package/dist/gvc/viewport.d.ts.map +1 -1
  18. package/dist/index.js +821 -548
  19. package/dist/index.js.map +4 -4
  20. package/dist/layout/circo/blockpath.d.ts +1 -38
  21. package/dist/layout/circo/blockpath.d.ts.map +1 -1
  22. package/dist/layout/circo/crossings.d.ts +59 -0
  23. package/dist/layout/circo/crossings.d.ts.map +1 -0
  24. package/dist/layout/dot/edge-route-chain.d.ts +5 -3
  25. package/dist/layout/dot/edge-route-chain.d.ts.map +1 -1
  26. package/dist/layout/dot/edge-route.d.ts.map +1 -1
  27. package/dist/layout/dot/index.d.ts +10 -2
  28. package/dist/layout/dot/index.d.ts.map +1 -1
  29. package/dist/layout/dot/ortho-adapter.d.ts +6 -2
  30. package/dist/layout/dot/ortho-adapter.d.ts.map +1 -1
  31. package/dist/layout/dot/pack-components.d.ts +4 -1
  32. package/dist/layout/dot/pack-components.d.ts.map +1 -1
  33. package/dist/layout/dot/splines-flat-multi.d.ts +4 -4
  34. package/dist/layout/dot/splines-flat.d.ts +1 -11
  35. package/dist/layout/dot/splines-flat.d.ts.map +1 -1
  36. package/dist/layout/dot/splines-route.d.ts.map +1 -1
  37. package/dist/layout/neato/constraint-adjust.d.ts +32 -0
  38. package/dist/layout/neato/constraint-adjust.d.ts.map +1 -1
  39. package/dist/layout/neato/multispline.d.ts.map +1 -1
  40. package/dist/layout/neato/splines.d.ts.map +1 -1
  41. package/dist/layout/pack/poly-pack.d.ts +3 -0
  42. package/dist/layout/pack/poly-pack.d.ts.map +1 -1
  43. package/dist/layout/pack/poly-place.d.ts.map +1 -1
  44. package/dist/layout/sfdp/__fixtures__/pcp-rotate-native.d.ts +23 -0
  45. package/dist/layout/sfdp/__fixtures__/pcp-rotate-native.d.ts.map +1 -0
  46. package/dist/layout/sfdp/spring-driver.d.ts +6 -0
  47. package/dist/layout/sfdp/spring-driver.d.ts.map +1 -1
  48. package/dist/ortho/index.d.ts +4 -1
  49. package/dist/ortho/index.d.ts.map +1 -1
  50. package/dist/ortho/maze.d.ts +5 -2
  51. package/dist/ortho/maze.d.ts.map +1 -1
  52. package/dist/ortho/partition.d.ts +8 -1
  53. package/dist/ortho/partition.d.ts.map +1 -1
  54. package/dist/ortho/trap-segment.d.ts +2 -1
  55. package/dist/ortho/trap-segment.d.ts.map +1 -1
  56. package/dist/ortho/trapezoid.d.ts.map +1 -1
  57. package/dist/parser/index.d.ts +0 -7
  58. package/dist/parser/index.d.ts.map +1 -1
  59. package/dist/parser/number-scan.d.ts +31 -0
  60. package/dist/parser/number-scan.d.ts.map +1 -0
  61. package/dist/render/dot/edge-draw.d.ts +12 -0
  62. package/dist/render/dot/edge-draw.d.ts.map +1 -1
  63. package/dist/render/svg-graph.d.ts +6 -5
  64. package/dist/render/svg-graph.d.ts.map +1 -1
  65. package/dist/render/svg.d.ts +2 -21
  66. package/dist/render/svg.d.ts.map +1 -1
  67. package/dist/render.js +821 -548
  68. package/dist/render.js.map +4 -4
  69. package/dist/xdot/parse.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/src/common/arith.ts +25 -0
  72. package/src/common/edge-in-box.ts +54 -0
  73. package/src/common/graph-init.ts +3 -3
  74. package/src/common/htmltable-emit.ts +4 -2
  75. package/src/common/make-label.ts +3 -1
  76. package/src/common/poly-gencode.ts +18 -4
  77. package/src/gvc/device.ts +8 -41
  78. package/src/gvc/edge-labels.ts +30 -13
  79. package/src/gvc/emit-gates.ts +89 -0
  80. package/src/gvc/job.ts +4 -0
  81. package/src/gvc/viewport.ts +29 -0
  82. package/src/layout/circo/blockpath.ts +6 -125
  83. package/src/layout/circo/crossings.ts +171 -0
  84. package/src/layout/dot/__fixtures__/2784.dot +1 -0
  85. package/src/layout/dot/__fixtures__/2784.native-16.1.svg +48 -0
  86. package/src/layout/dot/edge-route-chain.ts +11 -5
  87. package/src/layout/dot/edge-route.ts +2 -1
  88. package/src/layout/dot/index.ts +14 -4
  89. package/src/layout/dot/ortho-adapter.ts +7 -3
  90. package/src/layout/dot/pack-components.ts +11 -4
  91. package/src/layout/dot/splines-flat-multi.ts +4 -4
  92. package/src/layout/dot/splines-flat.ts +5 -32
  93. package/src/layout/dot/splines-route.ts +10 -2
  94. package/src/layout/dot/splines.ts +6 -6
  95. package/src/layout/neato/constraint-adjust.ts +10 -4
  96. package/src/layout/neato/multispline.ts +26 -9
  97. package/src/layout/neato/splines.ts +7 -7
  98. package/src/layout/pack/poly-pack.ts +19 -6
  99. package/src/layout/pack/poly-place.ts +9 -2
  100. package/src/layout/sfdp/__fixtures__/pcp-rotate-native.ts +94 -0
  101. package/src/layout/sfdp/spring-driver.ts +45 -13
  102. package/src/ortho/index.ts +22 -13
  103. package/src/ortho/maze.ts +8 -2
  104. package/src/ortho/partition.ts +17 -2
  105. package/src/ortho/trap-segment.ts +47 -24
  106. package/src/ortho/trapezoid.ts +24 -15
  107. package/src/parser/dot.js +334 -343
  108. package/src/parser/dot.pegjs +19 -5
  109. package/src/parser/index.ts +15 -0
  110. package/src/parser/number-scan.ts +118 -0
  111. package/src/render/dot/edge-draw.ts +25 -0
  112. package/src/render/svg-graph.ts +77 -47
  113. package/src/render/svg-helpers.ts +2 -2
  114. package/src/render/svg.ts +4 -41
  115. package/src/xdot/parse.ts +19 -5
@@ -57,11 +57,22 @@ Stmt
57
57
  // grammar.y: attrtype : T_graph | T_node | T_edge
58
58
  // grammar.y: graphattrdefs : attrassignment (bare key=value at graph level)
59
59
  AttrStmt
60
- = t:AttrType _ attrs:AttrListReq
61
- { return { type: "attr", target: t, attrs: attrs }; }
60
+ = t:AttrType _ m:OptMacroName attrs:AttrListReq
61
+ {
62
+ // grammar.y:attrstmt calls nomacros() when a macro name is present;
63
+ // the attributes are still applied unchanged.
64
+ if (m) console.error("Warning: attribute macros not implemented");
65
+ return { type: "attr", target: t, attrs: attrs };
66
+ }
62
67
  / a:AttrAssignment
63
68
  { return { type: "assign", key: a.key, value: a.value }; }
64
69
 
70
+ // grammar.y: optmacroname : atom '=' | /* empty */
71
+ // @see grammar.y:nomacros -- agwarningf("attribute macros not implemented")
72
+ OptMacroName
73
+ = Atom _ "=" _ { return true; }
74
+ / "" { return false; }
75
+
65
76
  // grammar.y: attrtype : T_graph | T_node | T_edge
66
77
  AttrType
67
78
  = GraphKw { return "graph"; }
@@ -216,10 +227,13 @@ Name
216
227
  = first:[A-Za-z_\x80-￿] rest:[A-Za-z_0-9\x80-￿]*
217
228
  { return first + rest.join(""); }
218
229
 
219
- // scan.l: NUMBER = [-]?(({DIGIT}+(\.{DIGIT}*)?)|(\.{DIGIT}+))
220
- // Returned as string per spec requirement.
230
+ // scan.l: NUMBER = [-]?(({DIGIT}+(\.{DIGIT}*)?)|(\.{DIGIT}+))(\.|{LETTER})?
231
+ // There is no exponent: `1e5` lexes as NUMBER `1e`, which chkNum splits into
232
+ // `1` and the NAME `e5`. The optional trailing '.'/LETTER is always split off
233
+ // by chkNum (yyless), so it is never part of the atom; its warning is emitted
234
+ // by number-scan.ts. Returned as string per spec requirement.
221
235
  NumericLiteral
222
- = s:( "-"? ( [0-9]+ ( "." [0-9]* )? / "." [0-9]+ ) ( [eE] [+-]? [0-9]+ )? )
236
+ = s:( "-"? ( [0-9]+ ( "." [0-9]* )? / "." [0-9]+ ) )
223
237
  { return Array.isArray(s) ? s.flat(Infinity).filter(x => x !== null && x !== undefined).join("") : String(s); }
224
238
 
225
239
  // ── Case-insensitive keywords ─────────────────────────────────────────────────
@@ -13,6 +13,7 @@ import type { GraphKind } from '../model/graph.js';
13
13
  import type { Edge } from '../model/edge.js';
14
14
  import { isHtmlValue, htmlValueContent } from '../common/html-string.js';
15
15
  import { buildFromAst } from './builder.js';
16
+ import { scanNumberAmbiguities, numberAmbiguityMessage } from './number-scan.js';
16
17
  import type { ParsedGraph } from './ast.js';
17
18
  import type { GvErrorCode, GvExpectation } from '../errors.js';
18
19
  import { DotEngineError, friendlyMessageFor, invalidArgType } from '../errors.js';
@@ -223,6 +224,18 @@ export function isPeggyError(err: unknown): err is {
223
224
  * is too deep
224
225
  * @throws TypeError `ERR_INVALID_ARG_TYPE` if `src` is not a string
225
226
  */
227
+ /**
228
+ * Emit scan.l's chkNum warnings for every badly delimited number the lexer
229
+ * reaches: all of them on success, those at or before a syntax error otherwise
230
+ * (C's lexer stops there). @see lib/cgraph/scan.l:chkNum
231
+ */
232
+ function warnNumberAmbiguities(src: string, upTo: number): void {
233
+ for (const a of scanNumberAmbiguities(src, Stripper.strip(src))) {
234
+ if (a.offset > upTo) break;
235
+ console.error(numberAmbiguityMessage(a));
236
+ }
237
+ }
238
+
226
239
  export function parse(src: string): Graph {
227
240
  if (typeof src !== 'string') {
228
241
  // Runtime guard for JS callers (the TS signature already forbids this):
@@ -235,6 +248,7 @@ export function parse(src: string): Graph {
235
248
  } catch (err: unknown) {
236
249
  if (isPeggyError(err)) {
237
250
  const start = err.location.start;
251
+ warnNumberAmbiguities(src, start.offset);
238
252
  const code: GvErrorCode =
239
253
  err.found === null ? 'SYNTAX_UNEXPECTED_EOF' : 'SYNTAX_ERROR';
240
254
  throw new ParseError(
@@ -256,6 +270,7 @@ export function parse(src: string): Graph {
256
270
  }
257
271
  throw err;
258
272
  }
273
+ warnNumberAmbiguities(src, src.length);
259
274
  validateEdgeOperators(src, ast.directed);
260
275
  return buildFromAst(ast);
261
276
  }
@@ -0,0 +1,118 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * scan.l's chkNum diagnostics. The NUMBER regexp allows one trailing '.' or
5
+ * LETTER so the lexer can catch a number run into a name (`00splines`) or a
6
+ * second dot (`1.2.3`); chkNum then warns and yyless() hands the last
7
+ * character back, splitting the text into two tokens. The PEG grammar already
8
+ * yields the split tokens; this pass reproduces the warnings, which a
9
+ * backtracking PEG action would emit unreliably (an alternative that fails
10
+ * after matching a number would re-run it).
11
+ *
12
+ * The walk runs over Stripper output (comments and string interiors blanked,
13
+ * same length, newlines kept), lexing the INITIAL-state tokens that can
14
+ * contain or abut a number: NAME, NUMBER, and the `--` / `->` edge operators.
15
+ *
16
+ * @see lib/cgraph/scan.l:106-110 (LETTER, DIGIT, NAME, NUMBER), :135, chkNum
17
+ */
18
+
19
+ /** One chkNum warning: the matched NUMBER text and its line. */
20
+ export interface NumberAmbiguity {
21
+ text: string;
22
+ line: number;
23
+ offset: number;
24
+ }
25
+
26
+ const ASCII_DEL = 0x7f;
27
+
28
+ /** scan.l LETTER `[A-Za-z_\200-\377]`: any non-ASCII character counts. */
29
+ function isLetter(c: string | undefined): boolean {
30
+ if (c === undefined) return false;
31
+ return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c === '_'
32
+ || c.charCodeAt(0) > ASCII_DEL;
33
+ }
34
+
35
+ function isDigit(c: string | undefined): boolean {
36
+ return c !== undefined && c >= '0' && c <= '9';
37
+ }
38
+
39
+ /** True when a NUMBER starts at i (sign, digits, or a dot before a digit). */
40
+ function startsNumber(s: string, i: number): boolean {
41
+ const c = s[i];
42
+ if (isDigit(c)) return true;
43
+ if (c === '.') return isDigit(s[i + 1]);
44
+ if (c !== '-') return false;
45
+ return isDigit(s[i + 1]) || (s[i + 1] === '.' && isDigit(s[i + 2]));
46
+ }
47
+
48
+ /** End index of the longest NUMBER match starting at i (scan.l:109). */
49
+ function matchNumber(s: string, i: number): number {
50
+ let j = s[i] === '-' ? i + 1 : i;
51
+ if (s[j] === '.') {
52
+ j++;
53
+ while (isDigit(s[j])) j++;
54
+ } else {
55
+ while (isDigit(s[j])) j++;
56
+ if (s[j] === '.') {
57
+ j++;
58
+ while (isDigit(s[j])) j++;
59
+ }
60
+ }
61
+ if (s[j] === '.' || isLetter(s[j])) j++;
62
+ return j;
63
+ }
64
+
65
+ /** scan.l twoDots: the first '.' is not the last character. */
66
+ function twoDots(text: string): boolean {
67
+ const dot = text.indexOf('.');
68
+ return dot !== -1 && dot !== text.length - 1;
69
+ }
70
+
71
+ /** scan.l chkNum: the last character is a letter, or a second '.'. */
72
+ function chkNum(text: string): boolean {
73
+ const c = text[text.length - 1]!;
74
+ return (!isDigit(c) && c !== '.') || (c === '.' && twoDots(text));
75
+ }
76
+
77
+ /**
78
+ * Lex one INITIAL token at i; returns the next index and, for a badly
79
+ * delimited NUMBER, its text (the next index is then yyless'd back by one).
80
+ */
81
+ function lexToken(s: string, i: number): { next: number; bad: string | null } {
82
+ if (isLetter(s[i])) {
83
+ let j = i + 1;
84
+ while (isLetter(s[j]) || isDigit(s[j])) j++;
85
+ return { next: j, bad: null };
86
+ }
87
+ if (s[i] === '-' && (s[i + 1] === '-' || s[i + 1] === '>')) return { next: i + 2, bad: null };
88
+ if (!startsNumber(s, i)) return { next: i + 1, bad: null };
89
+ const j = matchNumber(s, i);
90
+ const text = s.slice(i, j);
91
+ return chkNum(text) ? { next: j - 1, bad: text } : { next: j, bad: null };
92
+ }
93
+
94
+ /**
95
+ * Every badly delimited NUMBER in `src`, in lexing order. `stripped` is
96
+ * Stripper.strip(src) (same length); tokens are lexed from it, lines are
97
+ * counted in `src` (the stripper blanks newlines inside comments and strings,
98
+ * which scan.l still counts). @see lib/cgraph/scan.l:chkNum
99
+ */
100
+ export function scanNumberAmbiguities(src: string, stripped: string): NumberAmbiguity[] {
101
+ const out: NumberAmbiguity[] = [];
102
+ let line = 1;
103
+ let lineAt = 0;
104
+ for (let i = 0; i < stripped.length;) {
105
+ const { next, bad } = lexToken(stripped, i);
106
+ if (bad !== null) {
107
+ for (; lineAt < i; lineAt++) if (src[lineAt] === '\n') line++;
108
+ out.push({ text: bad, line, offset: i });
109
+ }
110
+ i = next;
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /** agwarningf text for one chkNum ambiguity; a parsed string is "input". */
116
+ export function numberAmbiguityMessage(a: NumberAmbiguity): string {
117
+ return `Warning: syntax ambiguity - badly delimited number '${a.text}' in line ${a.line} of input splits into two tokens`;
118
+ }
@@ -18,6 +18,7 @@ import type { ArrowDrawOp } from '../../common/arrows-types.js';
18
18
  import type { GVColor } from '../../common/color.js';
19
19
  import type { RenderJob } from '../../gvc/job.js';
20
20
  import { EmitState } from '../../gvc/job.js';
21
+ import { PenType } from '../../gvc/context.js';
21
22
  import { resolveRenderColor } from '../color-resolve.js';
22
23
  import { taper, taperfun } from '../../common/taper.js';
23
24
  import { orthoRoundedRadius } from '../svg-helpers.js';
@@ -31,6 +32,9 @@ import { DotWriterBase } from './agwrite.js';
31
32
  /** Pen colour when a colour-list entry is empty. @see lib/common/emit.c:2485 */
32
33
  const DEFAULT_COLOR = 'black';
33
34
 
35
+ /** gvc->defaultlinestyle as style tokens. @see lib/common/emit.c:105 */
36
+ const DEFAULT_LINE_STYLE: readonly string[] = ['solid', 'setlinewidth(1)'];
37
+
34
38
  /**
35
39
  * One arrow primitive as an xdot op. Lifted out of emitArrows' loop unchanged —
36
40
  * the four cases and their `filled` variants are exactly arrow_gen's.
@@ -64,6 +68,27 @@ export abstract class EdgeDrawBase extends DotWriterBase {
64
68
  protected penwidth: number[] = new Array(12).fill(1);
65
69
  protected abstract styleOp(job: RenderJob): string;
66
70
  protected abstract penOp(job: RenderJob): string;
71
+ abstract polyline(pts: Point[], job: RenderJob): void;
72
+
73
+ /**
74
+ * emit_attachment's draw calls (decorate=true): gvrender_set_style with the
75
+ * default line style sets the obj's rawstyle/pen/penwidth (setlinewidth 1),
76
+ * gvrender_set_pencolor sets the pen to the label fontcolor (first entry of
77
+ * a color list), then gvrender_polyline → xdot_polyline emits the style ops
78
+ * (`S 5 -solid`; setlinewidth is filtered), the pen color and `L 3`.
79
+ * @see lib/common/emit.c:emit_attachment (1886-1893)
80
+ * @see lib/gvc/gvrender.c:gvrender_set_style / gvrender_set_pencolor
81
+ */
82
+ attachmentPolyline(pts: Point[], pencolor: string, job: RenderJob): void {
83
+ const obj = job.obj;
84
+ if (obj !== null) {
85
+ obj.rawStyle = [...DEFAULT_LINE_STYLE];
86
+ obj.pen = PenType.Solid;
87
+ obj.penWidth = 1;
88
+ obj.penColor = resolveRenderColor(pencolor.split(':')[0]!);
89
+ }
90
+ this.polyline(pts, job);
91
+ }
67
92
 
68
93
  /**
69
94
  * The plain single-colour spline branch of beginEdge — lifted verbatim so the
@@ -16,12 +16,12 @@ import type { Box, Point } from '../model/geom.js';
16
16
  import type { RenderJob } from '../gvc/job.js';
17
17
  import { createObjState, ObjType } from '../gvc/job.js';
18
18
  import { FillType } from '../gvc/context.js';
19
- import { escapeXml, escapeXmlTitle } from './svg-helpers.js';
19
+ import { escapeXml, escapeXmlTitle, svgComment } from './svg-helpers.js';
20
20
  import { resolveRenderColor, colorPaint } from './color-resolve.js';
21
21
  import { svgGraphId, svgGraphClass } from './svg-id.js';
22
22
  import { parseGradientSpec } from '../common/htmltable-emit-fill.js';
23
23
  import { findStopColor, parseStyleFlags } from '../common/style-resolve.js';
24
- import { cround } from '../common/arith.js';
24
+ import { croundNarrow32 } from '../common/arith.js';
25
25
  import { FontnameKind } from '../model/layoutParams.js';
26
26
  import {
27
27
  emitLinearGradient,
@@ -66,7 +66,7 @@ function formatG(n: number): string {
66
66
  * (svgBeginPage). ViewBox starts at (round(margin.x), round(margin.y)) --
67
67
  * (0,0) only in the common no-margin case.
68
68
  */
69
- export function emitSvgTag(job: RenderJob): void {
69
+ export function emitSvgTag(job: RenderJob, svgid: string | undefined = ''): void {
70
70
  const bb = job.bb;
71
71
  // Size-fitted, UNROUNDED page-oriented view extent (init_job_pagination's
72
72
  // local `imageSize`/`pageSize`, emit.c:1198-1246): sz = bb extent plus pad,
@@ -87,8 +87,11 @@ export function emitSvgTag(job: RenderJob): void {
87
87
  const pageX = rotated ? viewY : viewX;
88
88
  const pageY = rotated ? viewX : viewY;
89
89
  // job->width/job->height (emit.c:1249-1250, dpi=72 so *dpi/72 is a no-op).
90
- const w = cround(pageX + 2 * job.margin.x);
91
- const h = cround(pageY + 2 * job.margin.y);
90
+ // The native build vectorises these ROUNDs (fcvtzs.2d + xtn: low 32 bits of
91
+ // an int64), so an out-of-range page size wraps exactly as native prints it
92
+ // (2784: 2.16e18 -> 1255669760). @see croundNarrow32
93
+ const w = croundNarrow32(pageX + 2 * job.margin.x);
94
+ const h = croundNarrow32(pageY + 2 * job.margin.y);
92
95
  job.write('<svg width="' + String(w) + 'pt" height="' + String(h) + 'pt"\n');
93
96
  // job->pageBoundingBox (canvasBox.LL = margin+centering, canvasBox.UR = LL +
94
97
  // imageSize, then ROUND -- emit.c:1282-1293). C's rotation-branch double
@@ -96,14 +99,20 @@ export function emitSvgTag(job: RenderJob): void {
96
99
  // swap-back, emit.c:1275-1298) cancels for LL (always plain margin) but NOT
97
100
  // for UR, which ends up paired with the page-oriented (rotation-swapped)
98
101
  // view extent -- i.e. the same `pageX`/`pageY` used for width/height above.
99
- const vbLLx = cround(job.margin.x);
100
- const vbLLy = cround(job.margin.y);
101
- const vbURx = cround(job.margin.x + pageX);
102
- const vbURy = cround(job.margin.y + pageY);
102
+ const vbLLx = croundNarrow32(job.margin.x);
103
+ const vbLLy = croundNarrow32(job.margin.y);
104
+ const vbURx = croundNarrow32(job.margin.x + pageX);
105
+ const vbURy = croundNarrow32(job.margin.y + pageY);
103
106
  job.write(' viewBox="' + String(vbLLx) + '.00 ' + String(vbLLy) + '.00 ' +
104
107
  String(vbURx) + '.00 ' + String(vbURy) + '.00"\n');
105
108
  job.write(' xmlns="http://www.w3.org/2000/svg"');
106
- job.write(' xmlns:xlink="http://www.w3.org/1999/xlink">\n');
109
+ job.write(' xmlns:xlink="http://www.w3.org/1999/xlink"');
110
+ // graphviz 16.1: `svgid` graph attribute -> id on the root <svg> (gvputs_xml).
111
+ // @see plugin/core/gvrender_core_svg.c:svg_begin_graph
112
+ if (svgid !== undefined && svgid.length > 0) {
113
+ job.write(' id="' + escapeXmlTitle(svgid) + '"');
114
+ }
115
+ job.write('>\n');
107
116
  }
108
117
 
109
118
  /**
@@ -175,22 +184,23 @@ export function resolveGraphBgcolor(bgcolorAttr: string | undefined): string {
175
184
  }
176
185
 
177
186
  /**
178
- * Emit background polygon covering the full padded content area.
187
+ * Emit background polygon covering the page clip box (C gvrender_box over
188
+ * job->clip — bb ± pad as setup_page evaluates it, see setupPageClip).
179
189
  *
180
190
  * Omits the polygon entirely when resolvedColor is the transparent sentinel.
181
191
  * Coordinates are in the group's local frame (y-flipped via devscale.y=-1):
182
- * x: [bb.ll.x - PAD, bb.ur.x + PAD]
183
- * y (flipped): [-(bb.ur.y + PAD), -(bb.ll.y - PAD)]
192
+ * x: [clip.ll.x, clip.ur.x]
193
+ * y (flipped): [-clip.ur.y, -clip.ll.y]
184
194
  *
185
195
  * @see plugin/core/gvrender_core_svg.c:svg_begin_page
186
196
  * @see lib/common/emit.c:emit_background:1476
187
197
  */
188
- export function emitGraphBackground(bb: Box, resolvedColor: string, job: RenderJob): void {
198
+ export function emitGraphBackground(clip: Box, resolvedColor: string, job: RenderJob): void {
189
199
  if (resolvedColor === BGCOLOR_TRANSPARENT) return;
190
- const left = bb.ll.x - job.pad.x;
191
- const right = bb.ur.x + job.pad.x;
192
- const top = -(bb.ur.y + job.pad.y);
193
- const bottom = -(bb.ll.y - job.pad.y);
200
+ const left = clip.ll.x;
201
+ const right = clip.ur.x;
202
+ const top = -clip.ur.y;
203
+ const bottom = -clip.ll.y;
194
204
  const corners: [number, number][] = [
195
205
  [left, bottom], [left, top], [right, top], [right, bottom], [left, bottom],
196
206
  ];
@@ -209,11 +219,11 @@ export function emitGraphBackground(bb: Box, resolvedColor: string, job: RenderJ
209
219
  * Passed to getGradientPoints inside emitLinearGradient.
210
220
  * @see lib/common/emit.c:emit_background:1517 (gvrender_box with clip)
211
221
  */
212
- function bgGradientPts(bb: Box, pad: Point): Point[] {
213
- const left = bb.ll.x - pad.x;
214
- const right = bb.ur.x + pad.x;
215
- const bottom = bb.ll.y - pad.y;
216
- const top = bb.ur.y + pad.y;
222
+ function bgGradientPts(clip: Box): Point[] {
223
+ const left = clip.ll.x;
224
+ const right = clip.ur.x;
225
+ const bottom = clip.ll.y;
226
+ const top = clip.ur.y;
217
227
  return [
218
228
  { x: left, y: bottom },
219
229
  { x: left, y: top },
@@ -227,11 +237,11 @@ function bgGradientPts(bb: Box, pad: Point): Point[] {
227
237
  * Coords identical to emitGraphBackground; only fill changes.
228
238
  * @see plugin/core/gvrender_core_svg.c:svg_polygon:686
229
239
  */
230
- function writeGradientPolygon(bb: Box, id: string, job: RenderJob): void {
231
- const left = bb.ll.x - job.pad.x;
232
- const right = bb.ur.x + job.pad.x;
233
- const top = -(bb.ur.y + job.pad.y);
234
- const bottom = -(bb.ll.y - job.pad.y);
240
+ function writeGradientPolygon(clip: Box, id: string, job: RenderJob): void {
241
+ const left = clip.ll.x;
242
+ const right = clip.ur.x;
243
+ const top = -clip.ur.y;
244
+ const bottom = -clip.ll.y;
235
245
  const corners: [number, number][] = [
236
246
  [left, bottom], [left, top], [right, top], [right, bottom], [left, bottom],
237
247
  ];
@@ -256,7 +266,7 @@ function writeGradientPolygon(bb: Box, id: string, job: RenderJob): void {
256
266
  * @see plugin/core/gvrender_core_svg.c:svg_gradstyle:572
257
267
  * @see plugin/core/gvrender_core_svg.c:svg_rgradstyle:608
258
268
  */
259
- function emitGradientBackground(bb: Box, spec: BgGradientSpec, job: RenderJob): void {
269
+ function emitGradientBackground(clip: Box, spec: BgGradientSpec, job: RenderJob): void {
260
270
  const obj = createObjState(ObjType.RootGraph);
261
271
  obj.id = 'graph0';
262
272
  obj.fillColor = resolveRenderColor(spec.fillColor);
@@ -271,10 +281,10 @@ function emitGradientBackground(bb: Box, spec: BgGradientSpec, job: RenderJob):
271
281
  emitRadialGradient(job, gid);
272
282
  } else {
273
283
  gid = gradientId('graph0', 'l', job.linearGradId++);
274
- emitLinearGradient(job, bgGradientPts(bb, job.pad), gid);
284
+ emitLinearGradient(job, bgGradientPts(clip), gid);
275
285
  }
276
286
  job.popObj();
277
- writeGradientPolygon(bb, gid, job);
287
+ writeGradientPolygon(clip, gid, job);
278
288
  }
279
289
 
280
290
  /**
@@ -296,17 +306,17 @@ function resolveBgGradientSpec(g: Graph, bgcolorAttr: string): BgGradientSpec |
296
306
  * Separated to keep svgBeginGraph under the 30-line body limit.
297
307
  * @see lib/common/emit.c:emit_background:1476
298
308
  */
299
- function emitBgcolorBackground(g: Graph, bb: Box, bgcolorAttr: string | undefined, job: RenderJob): void {
309
+ function emitBgcolorBackground(g: Graph, clip: Box, bgcolorAttr: string | undefined, job: RenderJob): void {
300
310
  if (!bgcolorAttr || bgcolorAttr.length === 0 || bgcolorAttr === 'transparent') {
301
- emitGraphBackground(bb, resolveGraphBgcolor(bgcolorAttr), job);
311
+ emitGraphBackground(clip, resolveGraphBgcolor(bgcolorAttr), job);
302
312
  return;
303
313
  }
304
314
  const spec = resolveBgGradientSpec(g, bgcolorAttr);
305
315
  if (spec !== null) {
306
- emitGradientBackground(bb, spec, job);
316
+ emitGradientBackground(clip, spec, job);
307
317
  return;
308
318
  }
309
- emitGraphBackground(bb, resolveGraphBgcolor(bgcolorAttr), job);
319
+ emitGraphBackground(clip, resolveGraphBgcolor(bgcolorAttr), job);
310
320
  }
311
321
 
312
322
  /**
@@ -344,7 +354,25 @@ export function svgBeginGraph(g: Graph, job: RenderJob): void {
344
354
  }
345
355
  job.write(SVG_DOCTYPE);
346
356
  job.write(SVG_GENERATOR_COMMENT);
347
- emitSvgTag(job);
357
+ // emit_graph: the root `comment` attribute, between svg_begin_job and
358
+ // emit_begin_graph. @see lib/common/emit.c:emit_graph
359
+ const comment = g.attrs.get('comment');
360
+ if (comment !== undefined && comment.length > 0) svgComment(comment, job);
361
+ emitTitlePagesComment(g, job);
362
+ emitSvgTag(job, g.attrs.get('svgid'));
363
+ }
364
+
365
+ /**
366
+ * svg_begin_graph's header comment: ` Title: <name>` for a user-named graph,
367
+ * then the page count (always 1 — the port emits a single page).
368
+ * @see plugin/core/gvrender_core_svg.c:svg_begin_graph
369
+ */
370
+ function emitTitlePagesComment(g: Graph, job: RenderJob): void {
371
+ job.write('<!--');
372
+ if (g.name.length > 0 && g.name[0] !== '%') {
373
+ job.write(' Title: ' + escapeXmlTitle(g.name));
374
+ }
375
+ job.write(' Pages: 1 -->\n');
348
376
  }
349
377
 
350
378
  /**
@@ -353,14 +381,16 @@ export function svgBeginGraph(g: Graph, job: RenderJob): void {
353
381
  * @see plugin/core/gvrender_core_svg.c:svg_begin_page
354
382
  */
355
383
  export function svgBeginPage(g: Graph, job: RenderJob): void {
356
- const bb = job.bb;
357
- // gvPostprocess zeroes bb.ll before render; tx is simply the padding.
384
+ const clip = job.clip;
385
+ // Portrait: translation.x = -clip.LL.x, with job->clip as setup_page builds
386
+ // it (setupPageClip: focus ∓ pageSize/2, i.e. bb ± pad up to rounding).
387
+ // gvPostprocess normally zeroes bb.ll, but a dot layout failure skips it
388
+ // (graphviz 16.1, corpus 2784: translate(31 ...)).
358
389
  // Landscape (ADR-3): setup_page's rotation branch (SVG = GVRENDER_Y_GOES_DOWN)
359
- // gives translation.x = -(focus.x + pageSize.x/2) = -(bb.ur.x + PAD) in the
360
- // zeroed-bb frame; translation.y is unchanged (focus.y + pageSize.y/2 =
361
- // bb.ur.y + PAD). pageSize = imageSize/zoom is Z-independent (outer scale
362
- // carries Z), so the formula holds for any size= fit. Pinned to b68 native
363
- // translate(-634 208.5). @see emit.c:setup_page; gvrender_core_svg.c:svg_begin_page
390
+ // gives translation.x = -clip.UR.x; translation.y = -clip.UR.y in both
391
+ // branches (printed negated). pageSize = imageSize/zoom is Z-independent
392
+ // (outer scale carries Z). Pinned to b68 native translate(-634 208.5).
393
+ // @see emit.c:setup_page; gvrender_core_svg.c:svg_begin_page
364
394
  //
365
395
  // margin (F6): setup_page adds `canvasBox.LL / job->zoom` on top of the
366
396
  // pad-only formula above -- canvasBox.LL is the FINAL (post swap-cancel)
@@ -374,9 +404,9 @@ export function svgBeginPage(g: Graph, job: RenderJob): void {
374
404
  // @see lib/common/emit.c:1566-1582 setup_page (CAUTION comment there)
375
405
  const z = job.scale.x;
376
406
  const tx = job.rotation !== 0
377
- ? -(bb.ur.x + job.pad.x) - job.margin.x / z
378
- : job.pad.x + job.margin.x / z;
379
- const ty = bb.ur.y + job.pad.y + job.margin.y / z;
407
+ ? -clip.ur.x - job.margin.x / z
408
+ : -clip.ll.x + job.margin.x / z;
409
+ const ty = clip.ur.y + job.margin.y / z;
380
410
  emitGraphGroupOpen(
381
411
  job.idLayerPrefix() + svgGraphId(g, job),
382
412
  svgGraphClass(g),
@@ -393,7 +423,7 @@ export function svgBeginPage(g: Graph, job: RenderJob): void {
393
423
  * @see lib/common/emit.c:emit_page (emit_background after begin_page)
394
424
  */
395
425
  export function svgPageBackground(g: Graph, job: RenderJob): void {
396
- emitBgcolorBackground(g, job.bb, g.attrs.get('bgcolor'), job);
426
+ emitBgcolorBackground(g, job.clip, g.attrs.get('bgcolor'), job);
397
427
  }
398
428
 
399
429
  /** Close the graph `<g>` group. @see svg_end_page */
@@ -421,8 +421,8 @@ export function svgPolyline(pts: Point[], job: RenderJob): void {
421
421
  job.write('"/>\n');
422
422
  }
423
423
 
424
- export function svgComment(text: string, job: RenderJob): void {
425
- job.write('<!-- ' + escapeXml(text) + ' -->\n');
424
+ export function svgComment(text: string, job: RenderJob): void { // svg_comment: gvputs_xml
425
+ job.write('<!-- ' + escapeXmlTitle(text) + ' -->\n');
426
426
  }
427
427
 
428
428
  // ---------------------------------------------------------------------------
package/src/render/svg.ts CHANGED
@@ -20,13 +20,12 @@ import type { Graph } from '../model/graph.js';
20
20
  import type { Node } from '../model/node.js';
21
21
  import type { Edge } from '../model/edge.js';
22
22
  import type { Point, Box } from '../model/geom.js';
23
- import { boxOverlap } from '../model/geom.js';
24
- import type { TextlabelT } from '../common/types.js';
25
23
  import type { TextSpan } from '../common/emit-types.js';
26
24
  import type { RendererPlugin, LabelType } from '../gvc/context.js';
27
25
  import type { RenderJob } from '../gvc/job.js';
28
26
  import { renderEdgeLabels } from '../gvc/edge-labels.js';
29
27
  import { beginAnchorIf } from '../gvc/anchor.js';
28
+ import { edgeHasDrawableContent } from '../common/edge-in-box.js';
30
29
  import {
31
30
  svgBeginGraph,
32
31
  svgEndGraph,
@@ -86,45 +85,9 @@ function emitMulticolorArrows(e: Edge, job: RenderJob, headColor: string, tailCo
86
85
  // @see plugin/core/gvrender_core_svg.c:svg_engine
87
86
  // ---------------------------------------------------------------------------
88
87
 
89
- /** overlap_label: does the label's box (pos ± dimen/2) overlap clip box b?
90
- * @see lib/common/utils.c:overlap_label */
91
- function overlapLabel(lp: TextlabelT, b: Box): boolean {
92
- const sx = lp.dimen.x / 2;
93
- const sy = lp.dimen.y / 2;
94
- const bb: Box = {
95
- ll: { x: lp.pos.x - sx, y: lp.pos.y - sy },
96
- ur: { x: lp.pos.x + sx, y: lp.pos.y + sy },
97
- };
98
- return boxOverlap(b, bb);
99
- }
100
-
101
- /**
102
- * Faithful-to-outcome `edge_in_box`: true when the edge has something to draw.
103
- * C's gvrender defers the `<g>` until a draw op, so an edge whose content falls
104
- * entirely outside the clip emits no group (emit.c:edge_in_box tests each piece
105
- * vs job->clip via boxf_overlap/overlap_label).
106
- *
107
- * The only piece that can legitimately fall OUTSIDE the clip on this corpus is a
108
- * degenerate labeled-flat label: it is spline-less, so map_edge leaves it at its
109
- * internal (un-normalized) x-frame position while the clip is the final frame.
110
- * The `overlapLabel` test on the main label reproduces C's draw-iff-overlap for
111
- * exactly that case — on-canvas (2368) → drawn; off-canvas (2368_1, negative
112
- * un-normalized x) → suppressed. A present spline always lies within the clip
113
- * (GD_bb contains it), and head/tail/xlabels are positioned at on-canvas
114
- * endpoints, so those keep their existing unconditional triggers — adding an
115
- * overlap test there only risks suppressing legitimately-drawn content (it
116
- * regressed neato/circo edges whose spl.bb is computed differently). The `set`
117
- * guard on the main label is kept: the port retains an unplaced (`pos=0,0`,
118
- * `set=false`) label on merged back-edges where C has none.
119
- * @see lib/common/emit.c:edge_in_box, lib/common/utils.c:overlap_label
120
- */
121
- export function edgeHasDrawableContent(e: Edge, clip: Box): boolean {
122
- const i = e.info;
123
- return i.spl !== undefined
124
- || (i.label !== undefined && i.label.set && overlapLabel(i.label, clip))
125
- || i.xlabel !== undefined
126
- || i.head_label !== undefined || i.tail_label !== undefined;
127
- }
88
+ // edge_in_box gate lives in common/edge-in-box.ts (shared with the device walk);
89
+ // re-exported for existing import sites.
90
+ export { edgeHasDrawableContent };
128
91
 
129
92
  export class SvgRenderer implements RendererPlugin {
130
93
  readonly type = 'svg';
package/src/xdot/parse.ts CHANGED
@@ -13,6 +13,9 @@ import type {
13
13
  } from "./types.js";
14
14
  import { XDOT_PARSE_ERROR } from "./types.js";
15
15
 
16
+ /** ULONG_MAX on an LP64 target: strtoul saturation value. */
17
+ const ULONG_MAX = 0xffff_ffff_ffff_ffffn;
18
+
16
19
  type PR<T> = { val: T; pos: number } | null;
17
20
  type OpResult = { op: XdotOp; pos: number } | "error";
18
21
  type OpMaybeResult = OpResult | null;
@@ -63,12 +66,23 @@ class XdotParser {
63
66
  return { val: parseInt(s.slice(start, i), 10), pos: i };
64
67
  }
65
68
 
69
+ /**
70
+ * strtoul(s, &endp, 10) cast to unsigned int: optional sign, a leading "-"
71
+ * negates modulo 2^64, magnitude overflow saturates to ULONG_MAX, and the
72
+ * result keeps the low 32 bits.
73
+ * @see lib/xdot/xdot.c:parseUInt
74
+ */
66
75
  static parseUInt_(s: string, pos: number): PR<number> {
67
76
  const i = XdotParser.skipWs(s, pos);
68
- const start = i;
69
- const end = XdotParser.consumeDigits(s, i);
77
+ let start = i;
78
+ const negative = i < s.length && s[i] === "-";
79
+ if (i < s.length && (s[i] === "-" || s[i] === "+")) start++;
80
+ const end = XdotParser.consumeDigits(s, start);
70
81
  if (end === start) return null;
71
- return { val: parseInt(s.slice(start, end), 10), pos: end };
82
+ const mag = BigInt(s.slice(start, end));
83
+ const clamped = mag > ULONG_MAX ? ULONG_MAX : mag;
84
+ const wrapped = negative && mag <= ULONG_MAX ? BigInt.asUintN(64, -clamped) : clamped;
85
+ return { val: Number(BigInt.asUintN(32, wrapped)), pos: end };
72
86
  }
73
87
 
74
88
  static parseRect(s: string, pos: number): PR<XdotRect> {
@@ -163,7 +177,7 @@ class XdotParser {
163
177
  const ry0 = XdotParser.parseReal(s, rx0.pos); if (!ry0) return null;
164
178
  const rx1 = XdotParser.parseReal(s, ry0.pos); if (!rx1) return null;
165
179
  const ry1 = XdotParser.parseReal(s, rx1.pos); if (!ry1) return null;
166
- const rn = XdotParser.parseInt_(s, ry1.pos); if (!rn) return null;
180
+ const rn = XdotParser.parseUInt_(s, ry1.pos); if (!rn) return null;
167
181
  const rs = XdotParser.parseStops(s, rn.pos, rn.val); if (!rs) return null;
168
182
  return { x0: rx0.val, y0: ry0.val, x1: rx1.val, y1: ry1.val, stops: rs.stops };
169
183
  }
@@ -175,7 +189,7 @@ class XdotParser {
175
189
  const rx1 = XdotParser.parseReal(s, rr0.pos); if (!rx1) return null;
176
190
  const ry1 = XdotParser.parseReal(s, rx1.pos); if (!ry1) return null;
177
191
  const rr1 = XdotParser.parseReal(s, ry1.pos); if (!rr1) return null;
178
- const rn = XdotParser.parseInt_(s, rr1.pos); if (!rn) return null;
192
+ const rn = XdotParser.parseUInt_(s, rr1.pos); if (!rn) return null;
179
193
  const rs = XdotParser.parseStops(s, rn.pos, rn.val); if (!rs) return null;
180
194
  return { x0: rx0.val, y0: ry0.val, r0: rr0.val, x1: rx1.val, y1: ry1.val, r1: rr1.val, stops: rs.stops };
181
195
  }