@knowvah/dot-engine 2.7.0 → 2.8.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 (122) hide show
  1. package/dist/api.js.map +2 -2
  2. package/dist/common/agerr.d.ts +8 -1
  3. package/dist/common/agerr.d.ts.map +1 -1
  4. package/dist/common/attr-get.d.ts +30 -0
  5. package/dist/common/attr-get.d.ts.map +1 -0
  6. package/dist/common/fma.d.ts.map +1 -1
  7. package/dist/common/htmltable-emit-fill.d.ts.map +1 -1
  8. package/dist/common/htmltable-label.d.ts +3 -0
  9. package/dist/common/htmltable-label.d.ts.map +1 -1
  10. package/dist/common/htmltable-lex-attvalue.d.ts.map +1 -1
  11. package/dist/common/htmltable.d.ts +5 -0
  12. package/dist/common/htmltable.d.ts.map +1 -1
  13. package/dist/common/layers.d.ts +12 -0
  14. package/dist/common/layers.d.ts.map +1 -1
  15. package/dist/common/layout-state.d.ts +8 -0
  16. package/dist/common/layout-state.d.ts.map +1 -0
  17. package/dist/common/make-label.d.ts.map +1 -1
  18. package/dist/common/multicolor.d.ts +6 -3
  19. package/dist/common/multicolor.d.ts.map +1 -1
  20. package/dist/common/nodeinit.d.ts.map +1 -1
  21. package/dist/common/poly-init.d.ts +13 -12
  22. package/dist/common/poly-init.d.ts.map +1 -1
  23. package/dist/common/poly-sizing.d.ts +1 -0
  24. package/dist/common/poly-sizing.d.ts.map +1 -1
  25. package/dist/common/postproc.d.ts.map +1 -1
  26. package/dist/common/style-resolve.d.ts +11 -9
  27. package/dist/common/style-resolve.d.ts.map +1 -1
  28. package/dist/gvc/device-cluster.d.ts +5 -6
  29. package/dist/gvc/device-cluster.d.ts.map +1 -1
  30. package/dist/gvc/device.d.ts.map +1 -1
  31. package/dist/gvc/first-layer.d.ts +21 -5
  32. package/dist/gvc/first-layer.d.ts.map +1 -1
  33. package/dist/gvc/gvrender-set-style.d.ts.map +1 -1
  34. package/dist/gvc/usershape.d.ts +20 -0
  35. package/dist/gvc/usershape.d.ts.map +1 -1
  36. package/dist/index.js +512 -286
  37. package/dist/index.js.map +4 -4
  38. package/dist/layout/circo/index.d.ts.map +1 -1
  39. package/dist/layout/dot/graph-label.d.ts.map +1 -1
  40. package/dist/layout/dot/init.d.ts +10 -0
  41. package/dist/layout/dot/init.d.ts.map +1 -1
  42. package/dist/layout/dot/splines.d.ts.map +1 -1
  43. package/dist/layout/fdp/init.d.ts.map +1 -1
  44. package/dist/layout/neato/conjgrad-mkernel.d.ts.map +1 -1
  45. package/dist/layout/neato/constrained-majorization.d.ts.map +1 -1
  46. package/dist/layout/neato/edge-len.d.ts +6 -2
  47. package/dist/layout/neato/edge-len.d.ts.map +1 -1
  48. package/dist/layout/neato/init.d.ts.map +1 -1
  49. package/dist/layout/neato/kk.d.ts.map +1 -1
  50. package/dist/layout/neato/make-graph-data.d.ts.map +1 -1
  51. package/dist/layout/neato/matrix-ops.d.ts +0 -6
  52. package/dist/layout/neato/matrix-ops.d.ts.map +1 -1
  53. package/dist/layout/neato/smart-ini-x.d.ts.map +1 -1
  54. package/dist/layout/neato/splines.d.ts.map +1 -1
  55. package/dist/layout/neato/start.d.ts.map +1 -1
  56. package/dist/layout/neato/stress-kernel.d.ts +2 -5
  57. package/dist/layout/neato/stress-kernel.d.ts.map +1 -1
  58. package/dist/layout/neato/stress.d.ts +6 -0
  59. package/dist/layout/neato/stress.d.ts.map +1 -1
  60. package/dist/layout/osage/index.d.ts.map +1 -1
  61. package/dist/layout/patchwork/index.d.ts.map +1 -1
  62. package/dist/layout/sfdp/init.d.ts.map +1 -1
  63. package/dist/layout/twopi/init.d.ts +12 -6
  64. package/dist/layout/twopi/init.d.ts.map +1 -1
  65. package/dist/model/cgraph-ops.d.ts +9 -0
  66. package/dist/model/cgraph-ops.d.ts.map +1 -1
  67. package/dist/render/color-resolve.d.ts +7 -0
  68. package/dist/render/color-resolve.d.ts.map +1 -1
  69. package/dist/render/json.d.ts +17 -2
  70. package/dist/render/json.d.ts.map +1 -1
  71. package/dist/render/map.d.ts +3 -5
  72. package/dist/render/map.d.ts.map +1 -1
  73. package/dist/render.js +518 -292
  74. package/dist/render.js.map +4 -4
  75. package/package.json +1 -1
  76. package/src/common/agerr.ts +69 -8
  77. package/src/common/attr-get.ts +46 -0
  78. package/src/common/fma.ts +11 -1
  79. package/src/common/htmltable-emit-fill.ts +11 -7
  80. package/src/common/htmltable-label.ts +6 -3
  81. package/src/common/htmltable-lex-attvalue.ts +17 -1
  82. package/src/common/htmltable.ts +10 -5
  83. package/src/common/layers.ts +31 -2
  84. package/src/common/layout-state.ts +35 -0
  85. package/src/common/make-label.ts +20 -4
  86. package/src/common/multicolor.ts +12 -6
  87. package/src/common/nodeinit.ts +2 -1
  88. package/src/common/poly-init.ts +22 -19
  89. package/src/common/poly-sizing.ts +4 -4
  90. package/src/common/postproc.ts +4 -1
  91. package/src/common/style-resolve.ts +118 -50
  92. package/src/gvc/device-cluster.ts +31 -39
  93. package/src/gvc/device.ts +55 -43
  94. package/src/gvc/first-layer.ts +60 -11
  95. package/src/gvc/gvrender-set-style.ts +1 -6
  96. package/src/gvc/usershape.ts +40 -0
  97. package/src/layout/circo/index.ts +4 -0
  98. package/src/layout/dot/graph-label.ts +1 -15
  99. package/src/layout/dot/init.ts +32 -0
  100. package/src/layout/dot/splines.ts +3 -0
  101. package/src/layout/fdp/init.ts +17 -0
  102. package/src/layout/neato/conjgrad-mkernel.ts +2 -3
  103. package/src/layout/neato/constrained-majorization.ts +2 -1
  104. package/src/layout/neato/edge-len.ts +39 -7
  105. package/src/layout/neato/init.ts +8 -3
  106. package/src/layout/neato/kk.ts +2 -1
  107. package/src/layout/neato/make-graph-data.ts +1 -2
  108. package/src/layout/neato/matrix-ops.ts +1 -11
  109. package/src/layout/neato/smart-ini-x.ts +1 -1
  110. package/src/layout/neato/splines.ts +3 -1
  111. package/src/layout/neato/start.ts +3 -2
  112. package/src/layout/neato/stress-kernel.ts +45 -86
  113. package/src/layout/neato/stress.ts +19 -0
  114. package/src/layout/osage/index.ts +8 -1
  115. package/src/layout/patchwork/index.ts +4 -0
  116. package/src/layout/sfdp/init.ts +3 -0
  117. package/src/layout/twopi/init.ts +21 -9
  118. package/src/layout/twopi/pipeline.ts +3 -3
  119. package/src/model/cgraph-ops.ts +19 -0
  120. package/src/render/color-resolve.ts +17 -2
  121. package/src/render/json.ts +9 -10
  122. package/src/render/map.ts +5 -6
@@ -89,6 +89,7 @@ export interface PolySizeParams {
89
89
  * @see lib/common/shapes.c:2132-2145
90
90
  */
91
91
  nojustify?: boolean;
92
+ imageSize?: Point; // padded image size in points, shapes.c:2020-2051 imagesize
92
93
  }
93
94
 
94
95
  /** Node geometry in points: left/right half-widths and height. */
@@ -550,7 +551,8 @@ export function polySize(p: PolySizeParams): PolySizeResult {
550
551
  const init = initialSizePts(p);
551
552
  const dimen = quantize(padLabelDimen(p.labelDimen, p.margin, p.isPlain), p.quantumIn);
552
553
  const { sides, isBox } = effectiveShape(p);
553
- let bb: Point = { x: dimen.x, y: dimen.y };
554
+ const img = p.imageSize ?? { x: 0, y: 0 }; // shapes.c:2055: bb = max(dimen, imagesize)
555
+ let bb: Point = { x: Math.max(dimen.x, img.x), y: Math.max(dimen.y, img.y) };
554
556
  // C shapes.c:2073-2085 dispatch: box → exact fit; poly_desc shapes (star,
555
557
  // cylinder) → their size_gen; everything else → generic ellipse fit.
556
558
  if (isBox) {
@@ -583,8 +585,6 @@ export function polySize(p: PolySizeParams): PolySizeResult {
583
585
  * Compute the label's justification borders (ND_label->space) and vertical
584
586
  * alignment — the "space available for label" block of poly_init. `bb` is the
585
587
  * final (constrained) box; `minBb` is the pre-constraint shape-fit box.
586
- * Image sizing is unported, so `imagesize` is 0 (which matches C for the
587
- * missing-image repro cases); revisit when image loading lands.
588
588
  * @see lib/common/shapes.c:poly_init (2132-2152)
589
589
  */
590
590
  function labelSpace(
@@ -604,7 +604,7 @@ function labelSpace(
604
604
  spaceX = dimen.x - spacex;
605
605
  }
606
606
  // space.y: !fixedshape → dimen.y + (bb.y - min_bb.y); fixedshape keeps dimen.y.
607
- const spaceY = fixedshape ? dimen.y : dimen.y + (bb.y - minBb.y);
607
+ const spaceY = fixedshape ? dimen.y : dimen.y + ((bb.y - minBb.y) + Math.max(0, (p.imageSize?.y ?? 0) - dimen.y));
608
608
  return { space: { x: spaceX, y: spaceY }, valign: labelValign(p) };
609
609
  }
610
610
 
@@ -27,6 +27,7 @@ import {
27
27
  } from '../layout/dot/init.js';
28
28
  import { addXLabels } from './xlabels-place.js';
29
29
  import { registerRenderState } from './render-state.js';
30
+ import { getState, GVSPLINES } from './layout-state.js';
30
31
  import { agerrorf } from './agerr.js';
31
32
 
32
33
  /** Concentrated multi-edge duplicate — never drawn. @see lib/common/const.h:IGNORED */
@@ -221,7 +222,9 @@ function translateDrawing(g: Graph): void {
221
222
 
222
223
  for (const v of g.nodes.values()) {
223
224
  mapNode(v);
224
- // State == GVSPLINES is always true post-spline-routing in the dot pipeline
225
+ // C: `if (State == GVSPLINES)` guards the whole edge loop; layouts that
226
+ // never route (patchwork, osage) leave State at GVBEGIN.
227
+ if (getState() !== GVSPLINES) continue;
225
228
  for (const e of g.edges) {
226
229
  if (e.tail !== v) continue;
227
230
  // C map_edge: a spline-less (non-concentrate, non-IGNORED) edge was
@@ -22,8 +22,9 @@
22
22
  */
23
23
 
24
24
  import { PenType } from '../gvc/context.js';
25
+ import { agerrorf, agwarningf } from './agerr.js';
25
26
  import { parseGradientSpec } from './htmltable-emit-fill.js';
26
- import { parseSegs } from './multicolor.js';
27
+ import { MSG_MANY_COLORS, parseSegs } from './multicolor.js';
27
28
 
28
29
  // ---------------------------------------------------------------------------
29
30
  // Types
@@ -156,28 +157,113 @@ function zeroFlags(): PolyStyleFlags {
156
157
  };
157
158
  }
158
159
 
159
- /** C parse_style truncates the style list at FUNLIMIT tokens. @see lib/common/emit.c:4001 */
160
+ /** C parse_style truncates the style list at FUNLIMIT tokens. @see lib/common/emit.c:3998 FUNLIMIT */
160
161
  const FUNLIMIT = 64;
161
162
 
163
+ /** Characters `gv_isspace` accepts. @see lib/cgraph/gv_ctype.h:gv_isspace */
164
+ const STYLE_SPACE: ReadonlySet<string> = new Set([' ', '\t', '\n', '\v', '\f', '\r']);
165
+
166
+ /** @see lib/common/emit.c:3949 is_style_delim ('\0' is the end of the C string) */
167
+ function isStyleDelim(c: string | undefined): boolean {
168
+ return c === undefined || c === '(' || c === ')' || c === ',';
169
+ }
170
+
171
+ /** One `style_token` result: `(`, `)`, an identifier, or the end (`null`). */
172
+ interface StyleToken {
173
+ readonly type: '(' | ')' | 'id';
174
+ readonly text: string;
175
+ /** Index in the style string just past the token. */
176
+ readonly next: number;
177
+ }
178
+
179
+ /**
180
+ * Skips spaces and commas, then reads one token: a parenthesis, or everything
181
+ * up to the next delimiter (trailing spaces included). Null at the end.
182
+ * @see lib/common/emit.c:3974 style_token
183
+ */
184
+ function styleToken(s: string, from: number): StyleToken | null {
185
+ let p = from;
186
+ while (p < s.length && (STYLE_SPACE.has(s[p]) || s[p] === ',')) p++;
187
+ if (p >= s.length) return null;
188
+ if (s[p] === '(' || s[p] === ')') return { type: s[p] as '(' | ')', text: s[p], next: p + 1 };
189
+ const start = p;
190
+ while (!isStyleDelim(s[p])) p++;
191
+ return { type: 'id', text: s.slice(start, p), next: p };
192
+ }
193
+
194
+ /** Why `parseStyle` produced an empty list, or null when it did not. */
195
+ type StyleFault = 'nesting' | 'unmatched-close' | 'unmatched-open' | 'truncate';
196
+
197
+ /** Result of the tokenizer: the list (empty on a fault) and the fault, if any. */
198
+ interface StyleParse {
199
+ readonly tokens: string[];
200
+ readonly fault: StyleFault | null;
201
+ }
202
+
203
+ const NO_TOKENS: StyleParse['tokens'] = [];
204
+
162
205
  /**
163
- * The raw style tokens C's parse_style produces (comma-separated, trimmed,
164
- * non-empty), truncated to empty past FUNLIMIT. These are what xdot's
165
- * `xdot_style` re-emits as `S` ops (it skips filled/bold/setlinewidth). The
166
- * port resolves style into a PenType for SVG; xdot needs the original tokens.
206
+ * parse_style without the diagnostics. A token's `(args)` are appended to it,
207
+ * as C stores them after the token's NUL: `setlinewidth( 2 )` → `setlinewidth(2 )`.
208
+ * @see lib/common/emit.c:4010 parse_style
209
+ */
210
+ function parseStyleQuiet(s: string): StyleParse {
211
+ const end = s.indexOf('\0');
212
+ const str = end < 0 ? s : s.slice(0, end);
213
+ const names: string[] = [];
214
+ const args: string[][] = [];
215
+ let inParens = false;
216
+ for (let c = styleToken(str, 0); c !== null; c = styleToken(str, c.next)) {
217
+ if (c.type === '(') {
218
+ if (inParens) return { tokens: NO_TOKENS, fault: 'nesting' };
219
+ inParens = true;
220
+ } else if (c.type === ')') {
221
+ if (!inParens) return { tokens: NO_TOKENS, fault: 'unmatched-close' };
222
+ inParens = false;
223
+ } else if (inParens) {
224
+ args[names.length - 1]?.push(c.text);
225
+ } else {
226
+ if (names.length === FUNLIMIT - 1) return { tokens: NO_TOKENS, fault: 'truncate' };
227
+ names.push(c.text);
228
+ args.push([]);
229
+ }
230
+ }
231
+ if (inParens) return { tokens: NO_TOKENS, fault: 'unmatched-open' };
232
+ const tokens = names.map((n, i) => (args[i].length === 0 ? n : `${n}(${args[i].join(',')})`));
233
+ return { tokens, fault: null };
234
+ }
235
+
236
+ /** The agerr line for a fault, byte for byte. @see lib/common/emit.c:4027-4062 */
237
+ function reportStyleFault(fault: StyleFault, s: string): void {
238
+ if (fault === 'nesting') agerrorf(`nesting not allowed in style: ${s}`);
239
+ else if (fault === 'unmatched-close') agerrorf(`unmatched ')' in style: ${s}`);
240
+ else if (fault === 'unmatched-open') agerrorf(`unmatched '(' in style: ${s}`);
241
+ else agwarningf(`truncating style '${s}'`);
242
+ }
243
+
244
+ /**
245
+ * The raw style tokens C's `parse_style` produces, untrimmed, `(args)`
246
+ * attached; empty on a fault, which it reports (agerr) on every call. These
247
+ * are what xdot's `xdot_style` re-emits as `S` ops. Call it where C calls
248
+ * `parse_style`; use `styleTokensQuiet` to read a list C has already parsed.
167
249
  * @see lib/common/emit.c:4010 parse_style · gvrender_core_dot.c:161 xdot_style
168
250
  */
169
251
  export function styleTokens(style: string | undefined): string[] {
170
252
  if (!style) return [];
171
- const out: string[] = [];
172
- let fun = 0;
173
- for (const raw of style.split(',')) {
174
- const token = raw.trim();
175
- if (token.length === 0) continue;
176
- if (fun === FUNLIMIT - 1) return [];
177
- fun++;
178
- out.push(token);
179
- }
180
- return out;
253
+ const { tokens, fault } = parseStyleQuiet(style);
254
+ if (fault !== null) reportStyleFault(fault, style);
255
+ return tokens;
256
+ }
257
+
258
+ /** `styleTokens` without the diagnostics, for re-reads of a style C parsed once. */
259
+ export function styleTokensQuiet(style: string | undefined): string[] {
260
+ return style ? parseStyleQuiet(style).tokens : [];
261
+ }
262
+
263
+ /** The name C compares: parse_style stores a token's `(args)` after its NUL. */
264
+ export function styleName(token: string): string {
265
+ const paren = token.indexOf('(');
266
+ return paren < 0 ? token : token.slice(0, paren);
181
267
  }
182
268
 
183
269
  /**
@@ -187,9 +273,9 @@ export function styleTokens(style: string | undefined): string[] {
187
273
  * @see lib/gvc/gvrender.c:501
188
274
  */
189
275
  function parseSetLineWidth(token: string): number | null {
190
- const m = /^setlinewidth\s*\(\s*([^)]*)\)\s*$/.exec(token);
191
- if (m === null) return null;
192
- const v = parseFloat(m[1]);
276
+ if (styleName(token) !== 'setlinewidth') return null;
277
+ const m = /^[^(]*\(([^,)]*)/.exec(token);
278
+ const v = m === null ? Number.NaN : parseFloat(m[1]);
193
279
  return Number.isNaN(v) ? 0 : v;
194
280
  }
195
281
 
@@ -227,29 +313,19 @@ function firstSolidColor(raw: string): string {
227
313
  /**
228
314
  * Parse a graphviz style attribute string into a flag set.
229
315
  *
230
- * Splits on commas and trims. A `setlinewidth(N)` token captures its pen-width
231
- * argument; other tokens containing '(' are ignored. When the non-empty token
232
- * count reaches FUNLIMIT (64), C's parse_style returns early before building
233
- * the style list — the observable effect is an empty style list (no flags
234
- * applied), which we reproduce by returning zeroFlags().
316
+ * Tokenizes like C's parse_style (untrimmed tokens, empty list on a fault) and
317
+ * reports nothing: C's callers parse once and reuse the list. A
318
+ * `setlinewidth(N)` token captures its pen-width argument.
235
319
  *
236
320
  * @see lib/common/emit.c:4010 parse_style (FUNLIMIT truncation)
237
321
  * @see lib/gvc/gvrender.c:481 gvrender_set_style (setlinewidth → penwidth)
238
322
  */
239
323
  export function parseStyleFlags(style: string | undefined): PolyStyleFlags {
240
324
  const flags = zeroFlags();
241
- if (!style) return flags;
242
- let fun = 0;
243
- for (const raw of style.split(',')) {
244
- const token = raw.trim();
245
- if (token.length === 0) continue;
246
- // C truncates at the FUNLIMIT-th token, skipping the pointer-construction
247
- // loop → the returned list is empty → no flags apply.
248
- if (fun === FUNLIMIT - 1) return zeroFlags();
249
- fun++;
325
+ for (const token of styleTokensQuiet(style)) {
250
326
  const lw = parseSetLineWidth(token);
251
327
  if (lw !== null) { flags.setLineWidth = lw; continue; }
252
- if (!token.includes('(')) applyToken(flags, token);
328
+ applyToken(flags, styleName(token));
253
329
  }
254
330
  return flags;
255
331
  }
@@ -274,16 +350,7 @@ export function parseStyleFlags(style: string | undefined): PolyStyleFlags {
274
350
  * @see lib/common/shapes.c:point_gencode (point_style whitelist)
275
351
  */
276
352
  export function styleHasInvisibleAlias(style: string | undefined): boolean {
277
- if (!style) return false;
278
- let fun = 0;
279
- for (const raw of style.split(',')) {
280
- const token = raw.trim();
281
- if (token.length === 0) continue;
282
- if (fun === FUNLIMIT - 1) return false;
283
- fun++;
284
- if (token === 'invisible') return true;
285
- }
286
- return false;
353
+ return styleTokensQuiet(style).some((token) => styleName(token) === 'invisible');
287
354
  }
288
355
 
289
356
  export function resolvePenType(flags: PolyStyleFlags): PenType {
@@ -418,11 +485,10 @@ export function findStopColor(
418
485
  colorlist: string,
419
486
  ): { fillColor: string; stopColor: string; frac: number } | null {
420
487
  const { segs, error } = parseSegs(colorlist);
421
- if (error === 1 || error === 2) return null;
422
- if (segs.length < 2) return null;
423
- const seg0 = segs[0]!;
424
- const seg1 = segs[1]!;
425
- if (seg0.color === null) return null;
488
+ const seg0 = segs[0];
489
+ const seg1 = segs[1];
490
+ if (error !== 0 || seg0 === undefined || seg1 === undefined || seg0.color === null) return null;
491
+ if (segs.length > 2) agwarningf(MSG_MANY_COLORS);
426
492
  const fillColor = seg0.color;
427
493
  const stopColor = seg1.color ?? DEFAULT_COLOR;
428
494
  const frac = seg0.hasFraction ? seg0.t
@@ -445,7 +511,9 @@ function buildResolvedFill(
445
511
  angle: number,
446
512
  ): ResolvedFill {
447
513
  const stop = findStopColor(rawColor);
448
- if (stop === null) return { kind: 'solid', color: rawColor };
514
+ // gvrender_set_fillcolor cuts a colour list at the first ':' (and so does the
515
+ // unknown-colour warning it can raise).
516
+ if (stop === null) return { kind: 'solid', color: rawColor.split(':', 1)[0]! };
449
517
  const kind = flags.radial ? 'radial' : 'linear';
450
518
  return { kind, fillColor: stop.fillColor, stopColor: stop.stopColor, frac: stop.frac, angle };
451
519
  }
@@ -17,8 +17,9 @@ import {
17
17
  resolveClusterFillEx, styleTokens,
18
18
  } from '../common/style-resolve.js';
19
19
  import { gvrenderSetStyle, checkClusterStyleTokens } from './gvrender-set-style.js';
20
- import { resolveRenderColor, withColorScheme, reportColorNames } from '../render/color-resolve.js';
20
+ import { resolveRenderColor, reportColorNames } from '../render/color-resolve.js';
21
21
  import { parseSegs } from '../common/multicolor.js';
22
+ import { graphAttrInherited } from '../common/attr-get.js';
22
23
 
23
24
  /**
24
25
  * Copy gradient fields from a linear/radial ResolvedFill onto obj.
@@ -36,19 +37,14 @@ function applyClusterGradient(
36
37
  }
37
38
 
38
39
  /**
39
- * Read a cluster graph-attribute with ancestor inheritance (agxget): a cluster
40
- * with no value of its own inherits an ancestor's `graph[key=...]`. C resolves
41
- * every cluster attr this way (late_string(sg, agfindgraphattr(sg, key), ...)),
42
- * so a root-level style/color/pencolor/fillcolor/bgcolor/penwidth/gradientangle
43
- * applies to the cluster boundary as the oracle does.
44
- * @see lib/common/emit.c:emit_clusters (cluster attrs via agxget)
40
+ * Read a cluster graph-attribute as C's `agget(sg, key)` does: the cluster's
41
+ * own value, else the ancestor defaults snapshotted when `agsubg` opened it.
42
+ * A root `graph[key=...]` declared after the cluster is NOT seen (item 89).
43
+ * @see lib/cgraph/graph.c:agsubg
44
+ * @see lib/common/emit.c:emit_clusters (cluster attrs via agget/agxget)
45
45
  */
46
46
  export function clusterAttr(sg: Graph, key: string): string | undefined {
47
- for (let g: Graph | null = sg; g !== null; g = g.parent) {
48
- const v = g.attrs.get(key);
49
- if (v !== undefined) return v;
50
- }
51
- return undefined;
47
+ return graphAttrInherited(sg, key);
52
48
  }
53
49
 
54
50
  /** Cluster `style` with ancestor inheritance. @see clusterAttr */
@@ -107,15 +103,13 @@ export function applyClusterObjState(sg: Graph, job: RenderJob): boolean {
107
103
  // emit_clusters: gvrender_set_style(job, checkClusterStyle list) precedes the
108
104
  // colour resolution. @see lib/common/emit.c:3808
109
105
  gvrenderSetStyle(checkClusterStyleTokens(styleTokens(clusterStyle(sg))));
110
- // C wraps the cluster color block with setColorScheme. @see emit.c:3800/3943
111
- return withColorScheme(clusterAttr(sg, 'colorscheme'), () => {
112
- // C resolves the fill (set_fillcolor / gradient vals) before the pen,
113
- // which is set just before gvrender_box. @see emit.c:3846-3868 vs 3907-3913
114
- const filled = applyClusterFill(obj, fillRes);
115
- applyClusterPenState(obj, sg);
116
- reportColorNames(stripedClusterColors(sg));
117
- return filled;
118
- });
106
+ // Runs inside renderCluster's setColorScheme window. @see emit.c:3800/3943
107
+ // C resolves the fill (set_fillcolor / gradient vals) before the pen,
108
+ // which is set just before gvrender_box. @see emit.c:3846-3868 vs 3907-3913
109
+ const filled = applyClusterFill(obj, fillRes);
110
+ applyClusterPenState(obj, sg);
111
+ reportColorNames(stripedClusterColors(sg));
112
+ return filled;
119
113
  }
120
114
 
121
115
  /**
@@ -164,24 +158,22 @@ function stripedClusterColors(sg: Graph): string[] {
164
158
  export function renderStripedClusterBox(
165
159
  sg: Graph, renderer: RendererPlugin, job: RenderJob,
166
160
  ): void {
167
- // stripedBox runs inside emit_clusters' setColorScheme window (3800/3943).
168
- withColorScheme(clusterAttr(sg, 'colorscheme'), () => {
169
- const { ll, ur } = sg.info.bb!;
170
- const af = [
171
- { x: ll.x, y: ll.y }, { x: ur.x, y: ll.y }, { x: ur.x, y: ur.y }, { x: ll.x, y: ur.y },
172
- ].map((p) => transformPoint(p, job));
173
- // stripedBox passes filled=1 (never GRADIENT) to gvrender_polygon; the port's
174
- // renderers read the gradient from obj.fill, so show them a solid fill.
175
- const obj = job.obj;
176
- const savedFill = obj?.fill;
177
- if (obj !== null && savedFill !== undefined) obj.fill = FillType.Solid;
178
- stripedBox(job, af, stripedFillSpec(sg), false, renderer, () => `in cluster ${sg.name}`);
179
- if (obj !== null && savedFill !== undefined) obj.fill = savedFill;
180
- const rawPts = [
181
- { x: ll.x, y: ll.y }, { x: ll.x, y: ur.y }, { x: ur.x, y: ur.y }, { x: ur.x, y: ll.y },
182
- ];
183
- renderer.polygon(rawPts.map((p) => transformPoint(p, job)), false, job);
184
- });
161
+ // stripedBox runs inside renderCluster's setColorScheme window (3800/3943).
162
+ const { ll, ur } = sg.info.bb!;
163
+ const af = [
164
+ { x: ll.x, y: ll.y }, { x: ur.x, y: ll.y }, { x: ur.x, y: ur.y }, { x: ll.x, y: ur.y },
165
+ ].map((p) => transformPoint(p, job));
166
+ // stripedBox passes filled=1 (never GRADIENT) to gvrender_polygon; the port's
167
+ // renderers read the gradient from obj.fill, so show them a solid fill.
168
+ const obj = job.obj;
169
+ const savedFill = obj?.fill;
170
+ if (obj !== null && savedFill !== undefined) obj.fill = FillType.Solid;
171
+ stripedBox(job, af, stripedFillSpec(sg), false, renderer, () => `in cluster ${sg.name}`);
172
+ if (obj !== null && savedFill !== undefined) obj.fill = savedFill;
173
+ const rawPts = [
174
+ { x: ll.x, y: ll.y }, { x: ll.x, y: ur.y }, { x: ur.x, y: ur.y }, { x: ur.x, y: ll.y },
175
+ ];
176
+ renderer.polygon(rawPts.map((p) => transformPoint(p, job)), false, job);
185
177
  }
186
178
 
187
179
  /** The fill half of {@link applyClusterObjState}; true when the box is filled. */
package/src/gvc/device.ts CHANGED
@@ -26,7 +26,7 @@ import type { ShapeDesc, TextlabelT } from '../common/types.js';
26
26
  import { type LayerInfo, parseLayers } from '../common/layers.js';
27
27
  import { RenderJob, EMIT_CLUSTERS_LAST, createObjState, ObjType, EmitState } from './job.js';
28
28
  import { walkNodesAndEdges } from './emit-walk.js';
29
- import { warnLayersUnsupported } from './first-layer.js';
29
+ import { firstLayer, nextLayer, numPhysicalLayers, validLayer } from './first-layer.js';
30
30
  import { polyInit } from '../common/poly-init.js';
31
31
  import {
32
32
  setHtmlAnchorObj,
@@ -48,7 +48,7 @@ import {
48
48
  setRendererKnownColors,
49
49
  } from '../render/color-resolve.js';
50
50
  import { emitRoundedBezier } from '../common/poly-shapes.js';
51
- import { applyClusterObjState, clusterStyle, clusterPeripheries, isStripedCluster, renderStripedClusterBox } from './device-cluster.js';
51
+ import { applyClusterObjState, clusterAttr, clusterStyle, clusterPeripheries, isStripedCluster, renderStripedClusterBox } from './device-cluster.js';
52
52
  import { transformPoint } from './device-transform.js';
53
53
  import { gvrenderSetStyle, edgeStyleResets } from './gvrender-set-style.js';
54
54
  import { renderOneLabel, renderNodeXLabel, renderClusterLabel, reportEdgeLabelColors } from './device-labels.js';
@@ -156,7 +156,7 @@ export function renderNode(n: Node, renderer: RendererPlugin, job: RenderJob, do
156
156
  * Set the edge pen color/type/width on job.obj from the edge's attrs — mirrors
157
157
  * emit_edge_graphics. (AD1: style flows through job.obj, not ad-hoc renderer reads.)
158
158
  */
159
- function setEdgePen(e: Edge, job: RenderJob): void {
159
+ function setEdgePen(e: Edge, job: RenderJob, styles: readonly string[]): void {
160
160
  if (job.obj === null) return;
161
161
  const obj = job.obj;
162
162
  const flags = parseStyleFlags(e.attrs.get('style'));
@@ -166,7 +166,7 @@ function setEdgePen(e: Edge, job: RenderJob): void {
166
166
  obj.penWidth = resolvePenWidth(flags, e.attrs.get('penwidth'));
167
167
  // Raw style tokens for xdot's `S` ops (e.g. explicit style="solid"); SVG
168
168
  // reads only obj.pen, so this is xdot-only. @see gvrender_core_dot.c xdot_style
169
- obj.rawStyle = styleTokens(e.attrs.get('style'));
169
+ obj.rawStyle = [...styles];
170
170
  // emit_begin_edge: `if (styles && ED_spl(e)) gvrender_set_style(job, styles)`
171
171
  // @see lib/common/emit.c:2726
172
172
  if (e.info.spl !== undefined) gvrenderSetStyle(obj.rawStyle);
@@ -178,13 +178,16 @@ export function renderEdge(e: Edge, renderer: RendererPlugin, job: RenderJob): v
178
178
  // before emit_begin_edge. @see lib/common/emit.c:emit_edge (style "invis" return)
179
179
  // The "invisible" alias likewise suppresses all edge draw ops via PEN_NONE
180
180
  // (edges never use the point-shape whitelist). @see lib/gvc/gvrender.c:497
181
+ // emit_edge parses the style once (reporting its faults); the rest reuse the list.
182
+ // @see lib/common/emit.c:2013 emit_edge
181
183
  const edgeStyle = e.attrs.get('style');
184
+ const styles = styleTokens(edgeStyle);
182
185
  if (parseStyleFlags(edgeStyle).invis || styleHasInvisibleAlias(edgeStyle)) return;
183
186
  // push_obj_state in emit_begin_edge (line 2715)
184
187
  const obj = createObjState(ObjType.Edge);
185
188
  obj.emitState = EmitState.EDraw;
186
189
  job.pushObj(obj);
187
- setEdgePen(e, job);
190
+ setEdgePen(e, job, styles);
188
191
  try {
189
192
  // @see lib/common/emit.c:emit_begin_edge / getObjId
190
193
  setHtmlAnchorObj(svgEdgeId(e, job), labelTextOf(e.info.label), job.obj ?? undefined);
@@ -202,7 +205,6 @@ export function renderEdge(e: Edge, renderer: RendererPlugin, job: RenderJob): v
202
205
  reportEdgeColors(e);
203
206
  // arrow_gen resets the style; C sets the edge's styles again after each
204
207
  // flagged bezier of a multi-bezier spline. @see emit.c:2047, :2677
205
- const styles = styleTokens(e.attrs.get('style'));
206
208
  for (let i = edgeStyleResets(e, styles); i > 0; i--) gvrenderSetStyle(styles);
207
209
  reportEdgeLabelColors(e);
208
210
  renderer.endEdge(e, job);
@@ -252,6 +254,41 @@ function renderClusterBox(
252
254
  renderer.polygon(rawPts.map(p => transformPoint(p, job)), filled, job);
253
255
  }
254
256
 
257
+ /**
258
+ * The body of emit_clusters between setColorScheme and its restore.
259
+ * @see lib/common/emit.c:3800-3943 emit_clusters
260
+ */
261
+ function drawClusterInScheme(sg: Graph, renderer: RendererPlugin, job: RenderJob): void {
262
+ // Resolve and wire cluster fill/pen into job.obj before the polygon.
263
+ // emit_clusters:3805-3874 — must happen before gvrender_box.
264
+ const filled = applyClusterObjState(sg, job);
265
+ // Cluster id is clust<AGSEQ seq> (getObjId); obj.id feeds emitGradientDefs
266
+ // the "clustN_l_0" gradient prefix. seq is stable on the subgraph, so the
267
+ // begin/assign order below is no longer load-bearing.
268
+ // @see lib/common/emit.c:209 getObjId (AGRAPH/cluster: pfx="clust")
269
+ renderer.beginCluster?.(sg, job);
270
+ if (job.obj !== null) job.obj.id = svgClusterId(sg, job);
271
+ // @see lib/common/emit.c:emit_begin_cluster / getObjId
272
+ setHtmlAnchorObj(svgClusterId(sg, job), labelTextOf(sg.info.label), job.obj ?? undefined);
273
+ // Resolve and open the cluster anchor around the box + label (closed before
274
+ // the cluster's child nodes — sibling of them). @see emit.c:3803.
275
+ if (job.obj !== null) {
276
+ resolveObjAnchor(sg, labelTextOf(sg.info.label), svgClusterId(sg, job), job.obj);
277
+ }
278
+ const anchored = beginAnchorIf(renderer, job);
279
+ // C draws the boundary box only when peripheries != 0, or (peripheries == 0
280
+ // and the cluster is filled). peripheries=0 + unfilled emits nothing.
281
+ // style=invis sets PEN_NONE and gvrender_polygon skips the whole box
282
+ // (fill included); the group/title still emit.
283
+ // @see lib/common/emit.c:3907-3917, lib/gvc/gvrender.c:543
284
+ if ((clusterPeripheries(sg) !== 0 || filled || isStripedCluster(sg)) && job.obj?.pen !== PenType.None) {
285
+ renderClusterBox(sg, filled, renderer, job);
286
+ }
287
+ renderClusterLabel(sg, renderer, job);
288
+ if (anchored) renderer.endAnchor?.(job);
289
+ renderer.endCluster?.(sg, job);
290
+ }
291
+
255
292
  /**
256
293
  * Render one cluster: box, label, endCluster, then recurse into sub-clusters.
257
294
  * push_obj_state in emit_begin_cluster (3762); pop in emit_end_cluster (3774).
@@ -271,34 +308,10 @@ function renderOneCluster(sg: Graph, renderer: RendererPlugin, job: RenderJob):
271
308
  obj.emitState = EmitState.CDraw;
272
309
  job.pushObj(obj);
273
310
  try {
274
- // Resolve and wire cluster fill/pen into job.obj before the polygon.
275
- // emit_clusters:3805-3874 — must happen before gvrender_box.
276
- const filled = applyClusterObjState(sg, job);
277
- // Cluster id is clust<AGSEQ seq> (getObjId); obj.id feeds emitGradientDefs
278
- // the "clustN_l_0" gradient prefix. seq is stable on the subgraph, so the
279
- // begin/assign order below is no longer load-bearing.
280
- // @see lib/common/emit.c:209 getObjId (AGRAPH/cluster: pfx="clust")
281
- renderer.beginCluster?.(sg, job);
282
- if (job.obj !== null) job.obj.id = svgClusterId(sg, job);
283
- // @see lib/common/emit.c:emit_begin_cluster / getObjId
284
- setHtmlAnchorObj(svgClusterId(sg, job), labelTextOf(sg.info.label), job.obj ?? undefined);
285
- // Resolve and open the cluster anchor around the box + label (closed before
286
- // the cluster's child nodes — sibling of them). @see emit.c:3803.
287
- if (job.obj !== null) {
288
- resolveObjAnchor(sg, labelTextOf(sg.info.label), svgClusterId(sg, job), job.obj);
289
- }
290
- const anchored = beginAnchorIf(renderer, job);
291
- // C draws the boundary box only when peripheries != 0, or (peripheries == 0
292
- // and the cluster is filled). peripheries=0 + unfilled emits nothing.
293
- // style=invis sets PEN_NONE and gvrender_polygon skips the whole box
294
- // (fill included); the group/title still emit.
295
- // @see lib/common/emit.c:3907-3917, lib/gvc/gvrender.c:543
296
- if ((clusterPeripheries(sg) !== 0 || filled || isStripedCluster(sg)) && job.obj?.pen !== PenType.None) {
297
- renderClusterBox(sg, filled, renderer, job);
298
- }
299
- renderClusterLabel(sg, renderer, job);
300
- if (anchored) renderer.endAnchor?.(job);
301
- renderer.endCluster?.(sg, job);
311
+ // C opens setColorScheme right after emit_begin_cluster and restores it after
312
+ // emit_label(EMIT_CLABEL): the window spans fill, pen, box and label.
313
+ // @see lib/common/emit.c:3800 / :3943 emit_clusters
314
+ withColorScheme(clusterAttr(sg, 'colorscheme'), () => drawClusterInScheme(sg, renderer, job));
302
315
  } finally {
303
316
  // pop_obj_state in emit_end_cluster (line 3774), before sub-cluster recursion
304
317
  job.popObj();
@@ -341,18 +354,17 @@ export function renderGraph(g: Graph, job: RenderJob, renderer: RendererPlugin):
341
354
  job.pushObj(obj);
342
355
  try {
343
356
  const info = parseLayers(g);
344
- // Only layer-capable renderers (SVG) do the layer loop; others collapse to
345
- // one pass. @see lib/common/emit.c:1140 (GVDEVICE_DOES_LAYERS)
346
- const layered = renderer.beginLayer !== undefined && info.numLayers > 1;
347
- job.numLayers = layered ? info.numLayers : 1;
357
+ // emit_graph sets job->layerNum = 0 before emit_begin_graph.
358
+ job.layerNum = 0;
348
359
  job.layerIDs = info.layerIDs;
349
360
  renderer.beginGraph(g, job);
350
- warnLayersUnsupported(job.format, info.numLayers, renderer.beginLayer !== undefined);
351
- for (let ln = 1; ln <= job.numLayers; ln++) {
352
- job.layerNum = ln;
353
- if (layered) renderer.beginLayer!(info.layerIDs[ln]!, job);
361
+ const cur = firstLayer(job, info, renderer.beginLayer !== undefined);
362
+ for (; validLayer(job); nextLayer(job, cur)) {
363
+ const layered = numPhysicalLayers(job, info) > 1;
364
+ // gvrender_begin_layer/end_layer are no-ops without a plugin callback.
365
+ if (layered) renderer.beginLayer?.(info.layerIDs[job.layerNum]!, job);
354
366
  renderPage(g, renderer, job, info);
355
- if (layered) renderer.endLayer!(job);
367
+ if (layered) renderer.endLayer?.(job);
356
368
  }
357
369
  renderer.endGraph(g, job);
358
370
  } finally {
@@ -1,11 +1,13 @@
1
1
  // SPDX-License-Identifier: EPL-2.0
2
2
  /**
3
- * The `GVDEVICE_DOES_LAYERS` check of `firstlayer`: a device without it prints
4
- * one layer and warns. The one-layer effect is `renderGraph`'s `layered` flag
5
- * (only svg has `beginLayer`; the other native layer-capable devices, ps/pov,
6
- * are not ported); this file owns the warning.
3
+ * The layer walk of `emit_graph`: `for (firstlayer; validlayer; nextlayer)`
4
+ * over either every layer or the `layerselect` list, plus the
5
+ * `GVDEVICE_DOES_LAYERS` warning. `GVDEVICE_DOES_LAYERS` is
6
+ * `renderer.beginLayer !== undefined`.
7
7
  */
8
8
  import { agwarningf } from '../common/agerr.js';
9
+ import type { LayerInfo } from '../common/layers.js';
10
+ import type { RenderJob } from './job.js';
9
11
 
10
12
  /** Port format ids that spell a native `-T` name differently (`output_langname`). */
11
13
  const NATIVE_LANGNAME: ReadonlyMap<string, string> = new Map([
@@ -13,14 +15,61 @@ const NATIVE_LANGNAME: ReadonlyMap<string, string> = new Map([
13
15
  ['imap-np', 'imap_np'],
14
16
  ]);
15
17
 
18
+ /** C's `int **listp`: the selected-layer list and the read position in it. */
19
+ export interface LayerCursor {
20
+ list: number[] | null;
21
+ pos: number;
22
+ }
23
+
24
+ function warnUnsupported(job: RenderJob): void {
25
+ agwarningf(`layers not supported in ${NATIVE_LANGNAME.get(job.format) ?? job.format} output`);
26
+ }
27
+
28
+ /**
29
+ * Number of layers emitted (begin/end layer wrap them only when above one).
30
+ * @see lib/common/emit.c:numPhysicalLayers
31
+ */
32
+ export function numPhysicalLayers(job: RenderJob, info: LayerInfo): number {
33
+ return info.layerList !== null ? info.layerList[0]! : job.numLayers;
34
+ }
35
+
16
36
  /**
17
- * Print `layers not supported in <output_langname> output` when the graph has
18
- * more than one layer and the device cannot draw layers. Called after
19
- * beginGraph, as emit_graph calls firstlayer after emit_begin_graph: for
20
- * `-Tjson` the nested xdot render in json_begin_graph therefore warns first.
37
+ * Called after beginGraph, as emit_graph calls firstlayer after
38
+ * emit_begin_graph: for `-Tjson` the nested xdot render in json_begin_graph
39
+ * therefore warns first. With a list, a device without layers keeps only the
40
+ * first entry by overwriting the second with the sentinel (as C does, in place).
21
41
  * @see lib/common/emit.c:firstlayer
22
42
  */
23
- export function warnLayersUnsupported(format: string, numLayers: number, doesLayers: boolean): void {
24
- if (numLayers <= 1 || doesLayers) return;
25
- agwarningf(`layers not supported in ${NATIVE_LANGNAME.get(format) ?? format} output`);
43
+ export function firstLayer(job: RenderJob, info: LayerInfo, doesLayers: boolean): LayerCursor {
44
+ job.numLayers = info.numLayers;
45
+ const list = info.layerList;
46
+ if (list !== null) {
47
+ if (list[0]! > 1 && !doesLayers) {
48
+ warnUnsupported(job);
49
+ list[2] = job.numLayers + 1; // only one layer printed
50
+ }
51
+ job.layerNum = list[1]!;
52
+ return { list, pos: 2 };
53
+ }
54
+ if (job.numLayers > 1 && !doesLayers) {
55
+ warnUnsupported(job);
56
+ job.numLayers = 1;
57
+ }
58
+ job.layerNum = 1;
59
+ return { list: null, pos: 0 };
60
+ }
61
+
62
+ /** @see lib/common/emit.c:validlayer */
63
+ export function validLayer(job: RenderJob): boolean {
64
+ return job.layerNum <= job.numLayers;
65
+ }
66
+
67
+ /** @see lib/common/emit.c:nextlayer */
68
+ export function nextLayer(job: RenderJob, cur: LayerCursor): void {
69
+ if (cur.list !== null) {
70
+ job.layerNum = cur.list[cur.pos]!;
71
+ cur.pos++;
72
+ } else {
73
+ job.layerNum++;
74
+ }
26
75
  }