@knowvah/dot-engine 2.6.12 → 2.7.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 (198) hide show
  1. package/README.md +1 -1
  2. package/dist/api.js +18 -4
  3. package/dist/api.js.map +3 -3
  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/htmltable-emit-fill.d.ts +20 -2
  7. package/dist/common/htmltable-emit-fill.d.ts.map +1 -1
  8. package/dist/common/htmltable-emit.d.ts.map +1 -1
  9. package/dist/common/htmltable-lex-expat.d.ts.map +1 -1
  10. package/dist/common/htmltable-pos-runs.d.ts +7 -4
  11. package/dist/common/htmltable-pos-runs.d.ts.map +1 -1
  12. package/dist/common/htmltable.d.ts.map +1 -1
  13. package/dist/common/libm-trig.d.ts +5 -0
  14. package/dist/common/libm-trig.d.ts.map +1 -0
  15. package/dist/common/make-label.d.ts.map +1 -1
  16. package/dist/common/multicolor.d.ts +21 -13
  17. package/dist/common/multicolor.d.ts.map +1 -1
  18. package/dist/common/poly-gencode.d.ts +1 -2
  19. package/dist/common/poly-gencode.d.ts.map +1 -1
  20. package/dist/common/poly-init.d.ts +2 -12
  21. package/dist/common/poly-init.d.ts.map +1 -1
  22. package/dist/common/splines-clip.d.ts +7 -0
  23. package/dist/common/splines-clip.d.ts.map +1 -1
  24. package/dist/gvc/context.d.ts +6 -0
  25. package/dist/gvc/context.d.ts.map +1 -1
  26. package/dist/gvc/device-cluster.d.ts +15 -0
  27. package/dist/gvc/device-cluster.d.ts.map +1 -1
  28. package/dist/gvc/device.d.ts.map +1 -1
  29. package/dist/gvc/first-layer.d.ts +9 -0
  30. package/dist/gvc/first-layer.d.ts.map +1 -0
  31. package/dist/gvc/gvrender-set-style.d.ts +28 -0
  32. package/dist/gvc/gvrender-set-style.d.ts.map +1 -0
  33. package/dist/gvc/usershape.d.ts +18 -0
  34. package/dist/gvc/usershape.d.ts.map +1 -1
  35. package/dist/index.js +2478 -912
  36. package/dist/index.js.map +4 -4
  37. package/dist/layout/dot/compound.d.ts.map +1 -1
  38. package/dist/layout/dot/edge-route-clip.d.ts +14 -4
  39. package/dist/layout/dot/edge-route-clip.d.ts.map +1 -1
  40. package/dist/layout/dot/edge-route-routing.d.ts.map +1 -1
  41. package/dist/layout/dot/graph-label.d.ts.map +1 -1
  42. package/dist/layout/neato/__fixtures__/dijkstra-f.d.ts +11 -0
  43. package/dist/layout/neato/__fixtures__/dijkstra-f.d.ts.map +1 -0
  44. package/dist/layout/neato/__fixtures__/hier-imds.d.ts +36 -0
  45. package/dist/layout/neato/__fixtures__/hier-imds.d.ts.map +1 -0
  46. package/dist/layout/neato/__fixtures__/hier-majorization.d.ts +32 -0
  47. package/dist/layout/neato/__fixtures__/hier-majorization.d.ts.map +1 -0
  48. package/dist/layout/neato/cdt-surface.d.ts.map +1 -1
  49. package/dist/layout/neato/compute-hierarchy.d.ts +24 -0
  50. package/dist/layout/neato/compute-hierarchy.d.ts.map +1 -0
  51. package/dist/layout/neato/conjgrad-mkernel.d.ts +20 -0
  52. package/dist/layout/neato/conjgrad-mkernel.d.ts.map +1 -0
  53. package/dist/layout/neato/conjgrad.d.ts +19 -0
  54. package/dist/layout/neato/conjgrad.d.ts.map +1 -1
  55. package/dist/layout/neato/constrained-majorization.d.ts +26 -0
  56. package/dist/layout/neato/constrained-majorization.d.ts.map +1 -0
  57. package/dist/layout/neato/dijkstra.d.ts +33 -16
  58. package/dist/layout/neato/dijkstra.d.ts.map +1 -1
  59. package/dist/layout/neato/gts-triangle.d.ts +14 -0
  60. package/dist/layout/neato/gts-triangle.d.ts.map +1 -0
  61. package/dist/layout/neato/init.d.ts +2 -12
  62. package/dist/layout/neato/init.d.ts.map +1 -1
  63. package/dist/layout/neato/kkutils.d.ts +20 -0
  64. package/dist/layout/neato/kkutils.d.ts.map +1 -0
  65. package/dist/layout/neato/make-graph-data.d.ts +43 -0
  66. package/dist/layout/neato/make-graph-data.d.ts.map +1 -0
  67. package/dist/layout/neato/matrix-ops.d.ts +46 -0
  68. package/dist/layout/neato/matrix-ops.d.ts.map +1 -1
  69. package/dist/layout/neato/opt-arrangement.d.ts +20 -0
  70. package/dist/layout/neato/opt-arrangement.d.ts.map +1 -0
  71. package/dist/layout/neato/quad-prog-solve.d.ts +44 -0
  72. package/dist/layout/neato/quad-prog-solve.d.ts.map +1 -0
  73. package/dist/layout/neato/smart-ini-x.d.ts +9 -0
  74. package/dist/layout/neato/smart-ini-x.d.ts.map +1 -0
  75. package/dist/layout/neato/splines.d.ts +1 -1
  76. package/dist/layout/neato/splines.d.ts.map +1 -1
  77. package/dist/layout/neato/start.d.ts +4 -1
  78. package/dist/layout/neato/start.d.ts.map +1 -1
  79. package/dist/layout/neato/stress.d.ts +8 -0
  80. package/dist/layout/neato/stress.d.ts.map +1 -1
  81. package/dist/model/edge.d.ts +1 -1
  82. package/dist/model/edge.d.ts.map +1 -1
  83. package/dist/model/lazy-attrs.d.ts +10 -0
  84. package/dist/model/lazy-attrs.d.ts.map +1 -0
  85. package/dist/model/node.d.ts +1 -1
  86. package/dist/model/node.d.ts.map +1 -1
  87. package/dist/ortho/index.d.ts.map +1 -1
  88. package/dist/render/dot/edge-draw.d.ts +1 -1
  89. package/dist/render/dot/edge-draw.d.ts.map +1 -1
  90. package/dist/render/svg-multicolor.d.ts +2 -2
  91. package/dist/render/svg-multicolor.d.ts.map +1 -1
  92. package/dist/render.js +2358 -792
  93. package/dist/render.js.map +4 -4
  94. package/package.json +1 -1
  95. package/src/common/attr-get.ts +46 -0
  96. package/src/common/graph-init.ts +1 -1
  97. package/src/common/htmltable-emit-fill.ts +56 -11
  98. package/src/common/htmltable-emit.ts +4 -3
  99. package/src/common/htmltable-lex-expat.ts +21 -3
  100. package/src/common/htmltable-pos-runs.ts +11 -6
  101. package/src/common/htmltable.ts +28 -116
  102. package/src/common/libm-trig.ts +43 -0
  103. package/src/common/make-label.ts +14 -2
  104. package/src/common/multicolor.ts +88 -24
  105. package/src/common/poly-gencode.ts +16 -8
  106. package/src/common/poly-init.ts +23 -19
  107. package/src/common/postproc.ts +2 -2
  108. package/src/common/splines-clip.ts +15 -12
  109. package/src/common/splines-routespl.ts +2 -2
  110. package/src/common/textmeasure-lookup.ts +1 -1
  111. package/src/gvc/context.ts +8 -0
  112. package/src/gvc/device-cluster.ts +63 -8
  113. package/src/gvc/device.ts +16 -2
  114. package/src/gvc/first-layer.ts +26 -0
  115. package/src/gvc/gvrender-set-style.ts +114 -0
  116. package/src/gvc/usershape.ts +26 -0
  117. package/src/layout/dot/cluster.ts +1 -1
  118. package/src/layout/dot/compound.ts +3 -4
  119. package/src/layout/dot/conc.ts +1 -1
  120. package/src/layout/dot/edge-route-clip.ts +19 -7
  121. package/src/layout/dot/edge-route-routing.ts +11 -3
  122. package/src/layout/dot/fastgr.ts +2 -2
  123. package/src/layout/dot/graph-label.ts +1 -15
  124. package/src/layout/neato/__fixtures__/dijkstra-f.ts +17 -0
  125. package/src/layout/neato/__fixtures__/hier/c1554.gv +33 -0
  126. package/src/layout/neato/__fixtures__/hier/c1554.plain +26 -0
  127. package/src/layout/neato/__fixtures__/hier/c1554.xdot +161 -0
  128. package/src/layout/neato/__fixtures__/hier/cases/cycle.gv +1 -0
  129. package/src/layout/neato/__fixtures__/hier/cases/cycle.plain +10 -0
  130. package/src/layout/neato/__fixtures__/hier/cases/cycle.stderr +0 -0
  131. package/src/layout/neato/__fixtures__/hier/cases/dirnone.gv +1 -0
  132. package/src/layout/neato/__fixtures__/hier/cases/dirnone.plain +11 -0
  133. package/src/layout/neato/__fixtures__/hier/cases/dirnone.stderr +0 -0
  134. package/src/layout/neato/__fixtures__/hier/cases/epsilon.gv +1 -0
  135. package/src/layout/neato/__fixtures__/hier/cases/epsilon.plain +13 -0
  136. package/src/layout/neato/__fixtures__/hier/cases/epsilon.stderr +0 -0
  137. package/src/layout/neato/__fixtures__/hier/cases/levelsgap.gv +1 -0
  138. package/src/layout/neato/__fixtures__/hier/cases/levelsgap.plain +8 -0
  139. package/src/layout/neato/__fixtures__/hier/cases/levelsgap.stderr +0 -0
  140. package/src/layout/neato/__fixtures__/hier/cases/mds.gv +1 -0
  141. package/src/layout/neato/__fixtures__/hier/cases/mds.plain +10 -0
  142. package/src/layout/neato/__fixtures__/hier/cases/mds.stderr +0 -0
  143. package/src/layout/neato/__fixtures__/hier/cases/multiedge.gv +1 -0
  144. package/src/layout/neato/__fixtures__/hier/cases/multiedge.plain +10 -0
  145. package/src/layout/neato/__fixtures__/hier/cases/multiedge.stderr +0 -0
  146. package/src/layout/neato/__fixtures__/hier/cases/neglevelsgap.gv +1 -0
  147. package/src/layout/neato/__fixtures__/hier/cases/neglevelsgap.plain +8 -0
  148. package/src/layout/neato/__fixtures__/hier/cases/neglevelsgap.stderr +0 -0
  149. package/src/layout/neato/__fixtures__/hier/cases/startrandom.gv +1 -0
  150. package/src/layout/neato/__fixtures__/hier/cases/startrandom.plain +10 -0
  151. package/src/layout/neato/__fixtures__/hier/cases/startrandom.stderr +0 -0
  152. package/src/layout/neato/__fixtures__/hier/cases/startregular.gv +1 -0
  153. package/src/layout/neato/__fixtures__/hier/cases/startregular.plain +10 -0
  154. package/src/layout/neato/__fixtures__/hier/cases/startregular.stderr +0 -0
  155. package/src/layout/neato/__fixtures__/hier/cases/tdist.gv +1 -0
  156. package/src/layout/neato/__fixtures__/hier/cases/tdist.plain +13 -0
  157. package/src/layout/neato/__fixtures__/hier/cases/tdist.stderr +0 -0
  158. package/src/layout/neato/__fixtures__/hier/cases/undirected.gv +1 -0
  159. package/src/layout/neato/__fixtures__/hier/cases/undirected.plain +10 -0
  160. package/src/layout/neato/__fixtures__/hier/cases/undirected.stderr +0 -0
  161. package/src/layout/neato/__fixtures__/hier/hier-probe.gv +1 -0
  162. package/src/layout/neato/__fixtures__/hier/hier-probe.plain +16 -0
  163. package/src/layout/neato/__fixtures__/hier/hier-probe.xdot +62 -0
  164. package/src/layout/neato/__fixtures__/hier-imds.ts +151 -0
  165. package/src/layout/neato/__fixtures__/hier-majorization.ts +145 -0
  166. package/src/layout/neato/cdt-surface.ts +6 -16
  167. package/src/layout/neato/compute-hierarchy.ts +90 -0
  168. package/src/layout/neato/conjgrad-mkernel.ts +141 -0
  169. package/src/layout/neato/conjgrad.ts +208 -0
  170. package/src/layout/neato/constrained-majorization.ts +479 -0
  171. package/src/layout/neato/dijkstra.ts +218 -92
  172. package/src/layout/neato/edge-len.ts +2 -2
  173. package/src/layout/neato/fdp-adjust.ts +3 -3
  174. package/src/layout/neato/gts-triangle.ts +42 -0
  175. package/src/layout/neato/init.ts +57 -57
  176. package/src/layout/neato/kk.ts +2 -2
  177. package/src/layout/neato/kkutils.ts +96 -0
  178. package/src/layout/neato/make-graph-data.ts +166 -0
  179. package/src/layout/neato/matrix-ops.ts +138 -0
  180. package/src/layout/neato/opt-arrangement.ts +86 -0
  181. package/src/layout/neato/quad-prog-solve.ts +489 -0
  182. package/src/layout/neato/smart-ini-x.ts +503 -0
  183. package/src/layout/neato/splines.ts +4 -4
  184. package/src/layout/neato/start.ts +7 -6
  185. package/src/layout/neato/stress.ts +28 -0
  186. package/src/model/edge.ts +3 -2
  187. package/src/model/lazy-attrs.ts +36 -0
  188. package/src/model/node.ts +3 -2
  189. package/src/ortho/index.ts +2 -3
  190. package/src/parser/dot.js +1 -1
  191. package/src/parser/dot.pegjs +1 -1
  192. package/src/parser/index.ts +1 -1
  193. package/src/render/color-resolve.ts +2 -2
  194. package/src/render/dot/edge-draw.ts +6 -3
  195. package/src/render/dot.ts +1 -1
  196. package/src/render/svg-edge-split.ts +2 -2
  197. package/src/render/svg-multicolor.ts +4 -3
  198. package/src/render/svg-parallel-edge.ts +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "2.6.12",
3
+ "version": "2.7.1",
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",
@@ -0,0 +1,46 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * Attribute reads with cgraph's `agxget` semantics: an object's own value,
5
+ * else the declared default it was created under. A leaf module (model types
6
+ * only) so label and shape code can share it without an import cycle.
7
+ *
8
+ * @see lib/cgraph/attr.c:agxget
9
+ */
10
+
11
+ import type { Node } from '../model/node.js';
12
+ import type { Graph } from '../model/graph.js';
13
+
14
+ /**
15
+ * Read a node attribute: the node's own value, else the node-attribute defaults
16
+ * (`node [...]`) captured from the node's declaring scope up to root *at the
17
+ * time the node was created*. The snapshot makes a `node [...]` declared after
18
+ * the node — at any scope — correctly not apply, and lets a cluster-scoped
19
+ * default reach nodes declared in that cluster, as the oracle does.
20
+ *
21
+ * Programmatically created nodes have no snapshot; they fall back to the live
22
+ * defaults of the passed graph `g` and root.
23
+ * @see lib/cgraph/attr.c:agxget; src/parser/builder.ts:effectiveNodeDefaults
24
+ */
25
+ export function nodeAttr(n: Node, g: Graph, key: string): string | undefined {
26
+ const own = n.attrs.get(key);
27
+ if (own !== undefined) return own;
28
+ if (n.nodeDefaultsSnapshot !== undefined) return n.nodeDefaultsSnapshot.get(key);
29
+ return g.nodeDefaults.get(key)
30
+ ?? (g !== g.root ? g.root.nodeDefaults.get(key) : undefined);
31
+ }
32
+
33
+ /**
34
+ * Read a graph attribute with subgraph→root inheritance, mirroring C's
35
+ * agxget (late_nnstring(sg, agfindgraphattr(sg, key), ...) in do_graph_label):
36
+ * a cluster inherits `fontname`/`fontsize`/`fontcolor` set on an ancestor
37
+ * (e.g. root `graph[fontname=Arial]`) when it sets none of its own.
38
+ * @see lib/common/input.c:do_graph_label
39
+ */
40
+ export function graphAttrInherited(sg: Graph, key: string): string | undefined {
41
+ // Own value wins; otherwise the ancestor defaults SNAPSHOTTED when this
42
+ // subgraph opened (not a live parent walk) — so an ancestor attribute set
43
+ // later in statement order does not retroactively apply (2184).
44
+ // @see lib/cgraph/graph.c:agsubg (parse-time defval copy)
45
+ return sg.attrs.get(key) ?? sg.graphDefaultsSnapshot?.get(key);
46
+ }
@@ -89,7 +89,7 @@ export function findCharset(g: Graph): number {
89
89
  }
90
90
  if (p === 'big-5' || p === 'big5') return CHAR_BIG5;
91
91
  if (p === 'utf-8' || p === 'utf8') return CHAR_UTF8;
92
- agwarningf(`Unsupported charset "${raw}" - assuming utf-8`, 'error');
92
+ agwarningf(`Unsupported charset "${raw}" - assuming utf-8`);
93
93
  return CHAR_UTF8;
94
94
  }
95
95
 
@@ -16,10 +16,11 @@ import { ObjType, EmitState, MapShape } from '../gvc/job.js';
16
16
  import type { RendererPlugin } from '../gvc/context.js';
17
17
  import { FillType, PenType } from '../gvc/context.js';
18
18
  import { transformPoint } from '../gvc/device.js';
19
- import { resolveRenderColor } from '../render/color-resolve.js';
19
+ import { firstColor, resolveRenderColor } from '../render/color-resolve.js';
20
20
  import { emitRoundedBezier } from './poly-shapes.js';
21
21
  import type { PlacedHtml, PlacedCell } from './htmltable-pos.js';
22
22
  import { htmlStyle } from './htmltable-style.js';
23
+ import { gvrenderSetStyle } from '../gvc/gvrender-set-style.js';
23
24
  import {
24
25
  BORDER_LEFT,
25
26
  BORDER_TOP,
@@ -27,25 +28,54 @@ import {
27
28
  BORDER_BOTTOM,
28
29
  BORDER_MASK,
29
30
  } from './htmltable-types.js';
31
+ import { parseSegs, parseSegsSilent } from './multicolor.js';
32
+ import { agwarningf } from './agerr.js';
30
33
  import { registerRenderState } from './render-state.js';
31
34
 
32
35
  // ---------------------------------------------------------------------------
33
36
  // Gradient spec parser
34
37
  // ---------------------------------------------------------------------------
35
38
 
39
+ /** The colours and fraction findStopColor yields; `stop` null → DEFAULT_COLOR. */
40
+ export interface HtmlStopColor {
41
+ fill: string;
42
+ stop: string | null;
43
+ frac: number;
44
+ }
45
+
46
+ /** @see lib/common/emit.c:findStopColor */
47
+ const MSG_MANY_COLORS = 'More than 2 colors specified for a gradient - ignoring remaining';
48
+
49
+ /**
50
+ * Port of C's findStopColor over parseSegs: null when parseSegs returns
51
+ * non-zero (including the rv 3 over-full warning), has < 2 segments or has no
52
+ * first colour. `silent` skips every diagnostic (port-internal pre-passes);
53
+ * otherwise parseSegs' once-per-render messages and the more-than-2-colours
54
+ * warning print as C does (no AGPREV context).
55
+ *
56
+ * @see lib/common/emit.c:findStopColor
57
+ */
58
+ export function findHtmlStopColor(color: string, silent = false): HtmlStopColor | null {
59
+ const { segs, error } = silent ? parseSegsSilent(color) : parseSegs(color);
60
+ const first = segs[0];
61
+ const second = segs[1];
62
+ if (error !== 0 || first === undefined || second === undefined || first.color === null) {
63
+ return null;
64
+ }
65
+ if (segs.length > 2 && !silent) agwarningf(MSG_MANY_COLORS);
66
+ const frac = first.hasFraction ? first.t : second.hasFraction ? 1 - second.t : 0;
67
+ return { fill: first.color, stop: second.color, frac };
68
+ }
69
+
36
70
  /**
37
- * Parse a color spec for gradient: "color1:color2" → [color1, color2].
38
- * Single-color strings return null (not a gradient spec).
71
+ * [fill, stop] of a colour-list gradient spec, or null for a solid colour.
72
+ * Silent {@link findHtmlStopColor} for call sites that only need the colours.
39
73
  *
40
74
  * @see lib/common/emit.c:findStopColor
41
75
  */
42
76
  export function parseGradientSpec(color: string): [string, string] | null {
43
- const idx = color.indexOf(':');
44
- if (idx < 1) return null;
45
- const c0 = color.slice(0, idx).trim();
46
- const c1 = color.slice(idx + 1).trim();
47
- if (c0.length === 0) return null;
48
- return [c0, c1.length > 0 ? c1 : 'black'];
77
+ const spec = findHtmlStopColor(color, true);
78
+ return spec === null ? null : [spec.fill, spec.stop ?? 'black'];
49
79
  }
50
80
 
51
81
  // ---------------------------------------------------------------------------
@@ -104,6 +134,8 @@ export interface HtmlPaint {
104
134
  * C setFill's findStopColor hit. @see lib/common/htmltable.c:setFill
105
135
  */
106
136
  stop?: string;
137
+ /** findStopColor's fraction; defaults to 0. Ignored without `stop`. */
138
+ gradientFrac?: number;
107
139
  /** Gradient angle in degrees; defaults to 0. Ignored without `stop`. */
108
140
  gradientAngle?: number;
109
141
  /** True → radial gradient (RGRADIENT); else linear. Ignored without `stop`. */
@@ -141,6 +173,16 @@ export function resetHtmlFillPenWidth(): void {
141
173
  registerRenderState(resetHtmlFillPenWidth);
142
174
 
143
175
  /** Map an HTML STYLE to the C pen type: dashed wins over dotted. @see lib/common/htmltable.c:doBorder */
176
+ /** gvc->defaultlinestyle. @see lib/common/emit.c:105 */
177
+ const DEFAULT_LINE_STYLE: readonly string[] = ['solid', 'setlinewidth(1)'];
178
+
179
+ /** The style list doBorder hands to gvrender_set_style for a pen type. */
180
+ function htmlBorderStyle(pen: PenType): readonly string[] {
181
+ if (pen === PenType.Dashed) return ['dashed'];
182
+ if (pen === PenType.Dotted) return ['dotted'];
183
+ return DEFAULT_LINE_STYLE;
184
+ }
185
+
144
186
  function penTypeOf(style: string | undefined): PenType {
145
187
  const st = htmlStyle(style);
146
188
  if (st.dashed) return PenType.Dashed;
@@ -156,7 +198,7 @@ function penTypeOf(style: string | undefined): PenType {
156
198
  */
157
199
  export function withHtmlPaint(paint: HtmlPaint, job: RenderJob, drawFn: () => void): void {
158
200
  paintObj.fillColor = paint.fill !== undefined
159
- ? resolveRenderColor(paint.fill) : { type: 'none' };
201
+ ? resolveRenderColor(firstColor(paint.fill)) : { type: 'none' };
160
202
  paintObj.penColor = paint.pen !== undefined
161
203
  ? resolveRenderColor(paint.pen) : { type: 'string', s: 'transparent' };
162
204
  applyHtmlFill(paint);
@@ -166,6 +208,9 @@ export function withHtmlPaint(paint: HtmlPaint, job: RenderJob, drawFn: () => vo
166
208
  // so xdot emits `S 5 -solid` per border/fill. rawStyle carries it (SVG's
167
209
  // style emit treats "solid" as the default → no visible change).
168
210
  paintObj.rawStyle = paintObj.pen === PenType.Solid ? ['solid'] : [];
211
+ // emit_html_tbl/cell border: dashed or dotted by name, else defaultlinestyle.
212
+ // @see lib/common/htmltable.c:267 doBorder
213
+ gvrenderSetStyle(htmlBorderStyle(paintObj.pen));
169
214
  // Inherit the enclosing object's emit-state (the label state during an HTML
170
215
  // label emit) so the fill/border ops route to the same xbuf as the label
171
216
  // (_ldraw_), not the shape draw buffer. SVG ignores emit_state, so unchanged.
@@ -186,7 +231,7 @@ function applyHtmlFill(paint: HtmlPaint): void {
186
231
  paintObj.fill = paint.radial === true ? FillType.Radial : FillType.Linear;
187
232
  paintObj.stopColor = resolveRenderColor(paint.stop);
188
233
  paintObj.gradientAngle = paint.gradientAngle ?? 0;
189
- paintObj.gradientFrac = 0;
234
+ paintObj.gradientFrac = paint.gradientFrac ?? 0;
190
235
  return;
191
236
  }
192
237
  paintObj.fill = paint.fill !== undefined ? FillType.Solid : FillType.None;
@@ -31,7 +31,7 @@ import type { PlacedHtml, PlacedCell, PlacedLine, PlacedImage } from './htmltabl
31
31
  import { transformPoint } from '../gvc/device.js';
32
32
  import { gvrenderTextspan } from '../gvc/textspan-emit.js';
33
33
  import {
34
- withHtmlPaint, parseGradientSpec, doBorder, mkPts,
34
+ withHtmlPaint, findHtmlStopColor, doBorder, mkPts,
35
35
  htmlFillPenWidth, resetHtmlFillPenWidth, type HtmlPaint,
36
36
  } from './htmltable-emit-fill.js';
37
37
  import { emitRoundedBezier } from './poly-shapes.js';
@@ -121,10 +121,11 @@ function bgFillPaint(ctx: BgFillCtx): HtmlPaint {
121
121
  // penWidth = the leaked gvrender pen width (prior doBorder); a bordered cell's
122
122
  // fill carries it as stroke-width even with stroke="none". @see htmltable.c:setFill
123
123
  const penWidth = htmlFillPenWidth();
124
- const spec = parseGradientSpec(ctx.bgcolor);
124
+ const spec = findHtmlStopColor(ctx.bgcolor);
125
125
  if (spec === null) return { fill: ctx.bgcolor, penWidth };
126
126
  return {
127
- fill: spec[0], stop: spec[1], gradientAngle: ctx.gradientangle ?? 0,
127
+ fill: spec.fill, stop: spec.stop ?? 'black', gradientFrac: spec.frac,
128
+ gradientAngle: ctx.gradientangle ?? 0,
128
129
  radial: htmlStyle(ctx.style).radial, penWidth,
129
130
  };
130
131
  }
@@ -270,15 +270,32 @@ function scanCdata(text: string, p: number, base: number, dataOf: CdataText, tok
270
270
  return close < 0 ? -1 : close + CDATA_CLOSE.length;
271
271
  }
272
272
 
273
+ /**
274
+ * A character expat's tokenizer rejects in character data (BT_NONXML in the
275
+ * UTF-8 table): controls other than tab, LF and CR, U+FFFE and U+FFFF, and a
276
+ * lone surrogate (the UTF-8 form of one is invalid). DEL is BT_OTHER, valid.
277
+ * @see expat lib/asciitab.h
278
+ * @see expat lib/xmltok_impl.c:PREFIX(contentTok) (BT_NONXML)
279
+ */
280
+ const CONTENT_NON_XML = /[\x00-\x08\x0b\x0c\x0e-\x1f\ufffe\uffff]|[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/;
281
+
273
282
  /**
274
283
  * Character data at `p` up to the next `<`: one `text` token, then the index it
275
284
  * ends at. A reference cut off by the end of the data is not part of the token
276
- * (`held`): expat waits for the characters that settle it.
277
- * @see expat lib/xmltok_impl.c:PREFIX(contentTok) (XML_TOK_PARTIAL)
285
+ * (`held`): expat waits for the characters that settle it. A BT_NONXML character
286
+ * ends the data token; the next call finds it an invalid token (`invalid`).
287
+ * @see expat lib/xmltok_impl.c:PREFIX(contentTok) (XML_TOK_PARTIAL, BT_NONXML)
278
288
  */
279
- function scanText(text: string, p: number, base: number, tokens: ExpatToken[]): { at: number; held: boolean } {
289
+ function scanText(
290
+ text: string, p: number, base: number, tokens: ExpatToken[],
291
+ ): { at: number; held: boolean; invalid?: number } {
280
292
  const e = text.indexOf('<', p);
281
293
  const slice = text.slice(p, e < 0 ? text.length : e);
294
+ const bad = CONTENT_NON_XML.exec(slice)?.index;
295
+ if (bad !== undefined) {
296
+ if (bad > 0) tokens.push({ kind: 'text', start: base + p, end: base + p + bad });
297
+ return { at: p + bad, held: false, invalid: p + bad };
298
+ }
282
299
  const from = e < 0 ? heldRefStart(slice) : undefined;
283
300
  const end = from === undefined ? p + slice.length : p + from;
284
301
  if (end > p) tokens.push({ kind: 'text', start: base + p, end: base + end });
@@ -303,6 +320,7 @@ function scanContent(text: string, base: number, inCdata: boolean, dataOf: Cdata
303
320
  }
304
321
  if (text[p] !== '<') {
305
322
  const scanned = scanText(text, p, base, tokens);
323
+ if (scanned.invalid !== undefined) return { tokens, consumed: scanned.at, invalid: base + scanned.invalid, inCdata: false };
306
324
  if (scanned.held) return { tokens, consumed: scanned.at, inCdata: false };
307
325
  p = scanned.at;
308
326
  continue;
@@ -48,7 +48,10 @@ export interface HtmlFontInfo {
48
48
  fontcolor: string;
49
49
  }
50
50
 
51
- interface LineRun {
51
+ /** The label font defaults the line builder needs (sizing env or placement finfo). */
52
+ export type LabelFont = Pick<HtmlFontInfo, 'fontname' | 'fontsize'>;
53
+
54
+ export interface LineRun {
52
55
  items: HtmlTextItem[];
53
56
  width: number;
54
57
  /** Max raw font size over items (C mxfsize). */
@@ -84,7 +87,7 @@ export function itemFontFlags(item: HtmlTextItem): number {
84
87
  }
85
88
 
86
89
  /** Resolved face/size for an item, defaulting to the inherited finfo. */
87
- function itemFace(item: HtmlTextItem, finfo: HtmlFontInfo): { fs: number; face: string } {
90
+ function itemFace(item: HtmlTextItem, finfo: LabelFont): { fs: number; face: string } {
88
91
  return {
89
92
  fs: item.fontSize !== undefined ? item.fontSize : finfo.fontsize,
90
93
  face: item.fontFace !== undefined ? item.fontFace : finfo.fontname,
@@ -95,7 +98,7 @@ function itemFace(item: HtmlTextItem, finfo: HtmlFontInfo): { fs: number; face:
95
98
  interface ItemMetric { w: number; h: number; fs: number; yoffsetCenterline?: number; yoffsetLayout?: number; }
96
99
 
97
100
  /** Measure one text item; null when the item carries no text. */
98
- function measureItem(item: HtmlTextItem, finfo: HtmlFontInfo, measurer: TextMeasurer): ItemMetric | null {
101
+ function measureItem(item: HtmlTextItem, finfo: LabelFont, measurer: TextMeasurer): ItemMetric | null {
99
102
  if (item.text === undefined) return null;
100
103
  const { fs, face } = itemFace(item, finfo);
101
104
  const flags = itemFontFlags(item);
@@ -142,11 +145,13 @@ function accumulateItem(cur: LineRun, item: HtmlTextItem, m: ItemMetric): void {
142
145
  * in the current font (consecutive BRs = a real blank line with height);
143
146
  * - at end of text a line is added ONLY if items remain — a trailing BR does
144
147
  * NOT create a phantom empty line (this over-sized e.g. html2's node c).
148
+ * The single builder: size_html_txt (sizing) and emit_htextspans (placement)
149
+ * both read these runs, as C reads one htextspan_t list.
145
150
  * @see lib/common/htmlparse.y:appendFLineList / mkText
146
151
  */
147
152
  export function buildLineRuns(
148
153
  texts: HtmlText[],
149
- finfo: HtmlFontInfo,
154
+ finfo: LabelFont,
150
155
  measurer: TextMeasurer,
151
156
  ): LineRun[] {
152
157
  const runs: LineRun[] = [];
@@ -181,7 +186,7 @@ export function buildLineRuns(
181
186
  */
182
187
  function runSimpleKey(
183
188
  run: LineRun,
184
- finfo: HtmlFontInfo,
189
+ finfo: LabelFont,
185
190
  ): { fs: number; face: string } | null | undefined {
186
191
  if (run.items.length > 1) return null;
187
192
  const item = run.items[0];
@@ -195,7 +200,7 @@ function runSimpleKey(
195
200
  * uniform resolved face/size across lines.
196
201
  * @see lib/common/htmltable.c:size_html_txt (lines 946-986)
197
202
  */
198
- export function runsAreSimple(runs: LineRun[], finfo: HtmlFontInfo): boolean {
203
+ export function runsAreSimple(runs: LineRun[], finfo: LabelFont): boolean {
199
204
  let prevFs = -1;
200
205
  let prevFace: string | null = null;
201
206
  for (const run of runs) {
@@ -20,11 +20,13 @@ export type { TextMeasurer } from './textmeasure.js';
20
20
 
21
21
  import type {
22
22
  HtmlCell, HtmlCellContent, HtmlImage, HtmlLabel, HtmlTable, HtmlText,
23
- HtmlTextItem, ImageSizer,
23
+ ImageSizer,
24
24
  } from './htmltable-types.js';
25
- import { findImageSize } from '../gvc/usershape.js';
25
+ import { findImageSize, imageIsSupplied, imageOpenWarning } from '../gvc/usershape.js';
26
+ import { registerRenderState } from './render-state.js';
26
27
  import { PointSet } from './pointset.js';
27
28
  import type { TextMeasurer } from './textmeasure.js';
29
+ import { buildLineRuns, runsAreSimple } from './htmltable-pos-runs.js';
28
30
  import { withTableFont } from './htmltable-fontinfo.js';
29
31
  import { agerrLine, type StderrLines } from './htmltable-attr-warn.js';
30
32
 
@@ -90,107 +92,6 @@ export interface HtmlSizeEnv {
90
92
 
91
93
  const defaultSizeEnv = (): HtmlSizeEnv => ({ fontsize: DEFAULT_FONTSIZE, fontname: '' });
92
94
 
93
- /** One measured text line (a C "span"). @see size_html_txt */
94
- interface SizedLine {
95
- items: HtmlTextItem[];
96
- width: number;
97
- /** Max metric line height over items (C mxysize). */
98
- mxysize: number;
99
- /** Max raw font size over items (C mxfsize). */
100
- mxfsize: number;
101
- }
102
-
103
- const itemFont = (item: HtmlTextItem, env: HtmlSizeEnv): { fs: number; face: string } => ({
104
- fs: item.fontSize !== undefined ? item.fontSize : env.fontsize,
105
- face: item.fontFace !== undefined ? item.fontFace : env.fontname,
106
- });
107
-
108
- const measureItem = (
109
- item: HtmlTextItem,
110
- measurer: TextMeasurer,
111
- env: HtmlSizeEnv,
112
- ): { w: number; h: number } => {
113
- const { fs, face } = itemFont(item, env);
114
- // Variant widths per run flags. @see lib/common/htmltable.c:size_html_txt
115
- const flags = (item.bold === true || item.italic === true)
116
- ? { bold: item.bold === true, italic: item.italic === true }
117
- : undefined;
118
- return measurer.measure(item.text ?? '', face, fs, flags);
119
- };
120
-
121
- /**
122
- * Split items into measured lines at <BR/>, mirroring C's parser exactly
123
- * (htmlparse.y appendFLineList/mkText): a BR with no accumulated items yields
124
- * a line with one empty-string item in the current font (consecutive BRs = a
125
- * real blank line with height); at end of text a line is added ONLY if items
126
- * remain — a trailing BR does NOT create a phantom empty line. Must stay in
127
- * lockstep with buildLineRuns (htmltable-pos-runs.ts), the placement twin.
128
- * @see lib/common/htmlparse.y:appendFLineList / mkText
129
- * @see lib/common/htmltable.c:size_html_txt
130
- */
131
- const splitSizedLines = (
132
- texts: HtmlText[],
133
- measurer: TextMeasurer,
134
- env: HtmlSizeEnv,
135
- ): SizedLine[] => {
136
- const lines: SizedLine[] = [];
137
- let cur: SizedLine = { items: [], width: 0, mxysize: 0, mxfsize: 0 };
138
- const accumulate = (item: HtmlTextItem): void => {
139
- const sz = measureItem(item, measurer, env);
140
- cur.items.push(item);
141
- cur.width += sz.w;
142
- cur.mxysize = Math.max(cur.mxysize, sz.h);
143
- cur.mxfsize = Math.max(cur.mxfsize, itemFont(item, env).fs);
144
- };
145
- const flush = (br?: HtmlTextItem): void => {
146
- if (cur.items.length === 0 && br !== undefined) {
147
- // C: empty fitemList on a BR → one ""-item span in the current font.
148
- const { br: _b, brAlign: _a, ...fontProps } = br;
149
- accumulate({ text: '', ...fontProps });
150
- }
151
- lines.push(cur);
152
- cur = { items: [], width: 0, mxysize: 0, mxfsize: 0 };
153
- };
154
- for (const txt of texts) {
155
- for (const item of txt.items) {
156
- if (item.br) { flush(item); continue; }
157
- if (item.text === undefined) continue;
158
- accumulate(item);
159
- }
160
- if (cur.items.length > 0) flush(); // C mkText: only when items remain
161
- }
162
- return lines;
163
- };
164
-
165
- /** Does an item carry any font flag (C textfont_t.flags)? */
166
- const itemHasFlags = (i: HtmlTextItem): boolean =>
167
- i.bold === true || i.italic === true || i.underline === true ||
168
- i.strikethrough === true || i.overline === true ||
169
- i.subscript === true || i.superscript === true;
170
-
171
- /**
172
- * C's "simple" test over the line list (C spans): one item per line, no font
173
- * flags, uniform resolved face and size across all lines. It reads each
174
- * line's first item, so the "" item appendFLineList gives an empty <BR/> line
175
- * counts with its font: `k<U><BR/><BR/></U>` is not simple.
176
- * @see lib/common/htmltable.c:size_html_txt (lines 946-986)
177
- */
178
- const htmlTextIsSimple = (lines: SizedLine[], env: HtmlSizeEnv): boolean => {
179
- let prevFs = -1;
180
- let prevFace: string | null = null;
181
- for (const ln of lines) {
182
- // splitSizedLines never yields an itemless line (C: nitems >= 1).
183
- const item = ln.items[0]!;
184
- if (ln.items.length > 1 || itemHasFlags(item)) return false;
185
- const { fs, face } = itemFont(item, env);
186
- if (prevFs < 0) prevFs = fs;
187
- else if (fs !== prevFs) return false;
188
- if (prevFace === null) prevFace = face;
189
- else if (face !== prevFace) return false;
190
- }
191
- return true;
192
- };
193
-
194
95
  /**
195
96
  * Size a text block. C: per line lsize = mxysize when simple, else the
196
97
  * raw max font size (mxfsize); a single-line block always uses mxysize.
@@ -201,16 +102,17 @@ const sizeTextContent = (
201
102
  measurer: TextMeasurer,
202
103
  env: HtmlSizeEnv = defaultSizeEnv(),
203
104
  ): { w: number; h: number } => {
204
- const lines = splitSizedLines(texts, measurer, env);
205
- const simple = htmlTextIsSimple(lines, env);
105
+ // The one line builder, shared with placement (htmltable-pos-runs.ts).
106
+ const lines = buildLineRuns(texts, env, measurer);
107
+ const simple = runsAreSimple(lines, env);
206
108
  let w = 0;
207
109
  let h = 0;
208
110
  for (const ln of lines) {
209
111
  w = Math.max(w, ln.width);
210
- const lsize = simple ? ln.mxysize : ln.mxfsize;
112
+ const lsize = simple ? ln.height : ln.fontSize;
211
113
  h += ln.items.length > 0 ? lsize : DEFAULT_FONTSIZE;
212
114
  }
213
- if (lines.length === 1) h = lines[0]!.items.length > 0 ? lines[0]!.mxysize : DEFAULT_FONTSIZE;
115
+ if (lines.length === 1) h = lines[0]!.items.length > 0 ? lines[0]!.height : DEFAULT_FONTSIZE;
214
116
  return { w, h };
215
117
  };
216
118
 
@@ -228,25 +130,28 @@ const sizeTextContent = (
228
130
  * @see lib/common/htmltable.c:size_html_img (line 1080)
229
131
  */
230
132
  /** Images whose sizing failed: size_html_img's rv = 1 on every later call. */
231
- const missingImages = new WeakSet<HtmlImage>();
133
+ const missingImages = { set: new WeakSet<HtmlImage>() };
232
134
 
233
135
  const sizeHtmlImg = (img: HtmlImage, { imageSizer, stderr }: HtmlSizeEnv): number => {
234
136
  // C sizes each image once (processTbl); the port's size and pos passes
235
137
  // share layoutHtmlTable, so guard against re-sizing (and re-warning). The
236
138
  // returned rv still follows C on every call.
237
- if (img.width !== undefined && img.height !== undefined) return missingImages.has(img) ? 1 : 0;
139
+ if (img.width !== undefined && img.height !== undefined) return missingImages.set.has(img) ? 1 : 0;
238
140
  const sz = imageSizer !== undefined ? imageSizer(img.src) : findImageSize(img.src);
239
141
  if (sz !== null) {
240
142
  img.width = sz.w; img.height = sz.h;
241
143
  return 0;
242
144
  }
243
- // Missing-image: zero size + warning.
145
+ // Missing-image: gvusershape_open's warning (unless bytes are supplied),
146
+ // then zero size + the Error.
147
+ // @see lib/gvc/gvusershape.c:gvusershape_file_access
148
+ if (!imageIsSupplied(img.src, imageSizer)) agerrLine(stderr, `Warning: ${imageOpenWarning(img.src)}`);
244
149
  // C: agerrorf("No or improper image file=\"%s\"\n", img->src) — agerr
245
150
  // prefixes "Error: " on stderr.
246
151
  agerrLine(stderr, `Error: No or improper image file="${img.src}"`);
247
152
  img.width = 0;
248
153
  img.height = 0;
249
- missingImages.add(img);
154
+ missingImages.set.add(img);
250
155
  return 1;
251
156
  };
252
157
 
@@ -317,7 +222,14 @@ const WARN_TABLE_UNSPECIFIED = 'Warning: fixed table size with unspecified width
317
222
  * C sizes each cell/table exactly once; the port's size and pos passes both
318
223
  * run layoutHtmlTable, so the size warnings are emitted on first sight only.
319
224
  */
320
- const sizeWarned = new WeakSet<object>();
225
+ const sizeWarned = { set: new WeakSet<object>() };
226
+
227
+ // Native re-parses per run, so each render starts with both sets empty (AD-7);
228
+ // a WeakSet cannot be cleared, so the holder's field is swapped.
229
+ registerRenderState(() => {
230
+ missingImages.set = new WeakSet<HtmlImage>();
231
+ sizeWarned.set = new WeakSet<object>();
232
+ });
321
233
 
322
234
  /**
323
235
  * FIXED_FLAG branch of size_html_cell: C's rv (set at the warning sites; the
@@ -357,8 +269,8 @@ const getCellSize = (
357
269
  // @see lib/common/htmltable.c:size_html_cell (FIXED_FLAG branch + box.UR MAX)
358
270
  let szx = cw + margin;
359
271
  let szy = ch + margin;
360
- const warn = !sizeWarned.has(cell);
361
- sizeWarned.add(cell);
272
+ const warn = !sizeWarned.set.has(cell);
273
+ sizeWarned.set.add(cell);
362
274
  const fixed = fixedCellCheck(cell, { w: szx, h: szy }, warn, env.stderr);
363
275
  rv |= fixed.rv;
364
276
  if (fixed.zero) { szx = 0; szy = 0; }
@@ -504,8 +416,8 @@ export const layoutHtmlTable = (
504
416
  * @see lib/common/htmltable.c:size_html_tbl (:1683, :1689)
505
417
  */
506
418
  const warnFixedTable = (tbl: HtmlTable, dims: { wd: number; ht: number }, out: StderrLines | undefined): number => {
507
- const warn = !sizeWarned.has(tbl);
508
- sizeWarned.add(tbl);
419
+ const warn = !sizeWarned.set.has(tbl);
420
+ sizeWarned.set.add(tbl);
509
421
  if (tbl.fixedsize !== true) return 0;
510
422
  if (tbl.width && tbl.height) {
511
423
  if (tbl.width >= dims.wd && tbl.height >= dims.ht) return 0;
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * `cos`/`sin` as the native build host's libm computes them, at the only
5
+ * inputs Graphviz feeds with constant arguments: the corner angles
6
+ * 2π·(i ∓ 0.5)/8 of neatosplines.c's circumscribed ellipse 8-gon.
7
+ *
8
+ * V8's Math.sin/Math.cos are not correctly rounded everywhere, and the
9
+ * result depends on the CPU: at 9π/8 Node 22-26 on arm64 return
10
+ * -0.3826834323650896 (x64 and Node 20 return the correctly rounded value)
11
+ * where the reference libm (macOS arm64 libSystem) returns -0.38268343236508967.
12
+ * The ulp moves an obstacle corner off its node's centre line, which flips
13
+ * knife-edge triangulations in the multispline router. The table holds the
14
+ * host values for those nine angles (printed with `%a` by a clang -O2
15
+ * harness of the same expression, which also reproduces the oracle's
16
+ * makeObstacle vertices); any other input falls back to Math.cos/Math.sin.
17
+ *
18
+ * @see lib/neatogen/neatosplines.c:circumscribed_polygon_corner_about_ellipse
19
+ * @see docs/known-divergences.md (host libm)
20
+ */
21
+
22
+ /** angle → [cos, sin] from the host libm, angles 2π·(k + 0.5)/8, k = -1..7. */
23
+ const OCTAGON_TRIG: ReadonlyMap<number, readonly [number, number]> = new Map([
24
+ [-0.39269908169872414, [0.9238795325112867, -0.3826834323650898]],
25
+ [0.39269908169872414, [0.9238795325112867, 0.3826834323650898]],
26
+ [1.1780972450961724, [0.38268343236508984, 0.9238795325112867]],
27
+ [1.9634954084936207, [-0.3826834323650897, 0.9238795325112867]],
28
+ [2.748893571891069, [-0.9238795325112867, 0.3826834323650899]],
29
+ [3.5342917352885173, [-0.9238795325112868, -0.38268343236508967]],
30
+ [4.319689898685965, [-0.38268343236509034, -0.9238795325112865]],
31
+ [5.105088062083414, [0.38268343236509, -0.9238795325112866]],
32
+ [5.890486225480862, [0.9238795325112865, -0.3826834323650904]],
33
+ ] as const);
34
+
35
+ /** libm `cos(x)` of the native build host (table above, else Math.cos). */
36
+ export function libmCos(x: number): number {
37
+ return OCTAGON_TRIG.get(x)?.[0] ?? Math.cos(x);
38
+ }
39
+
40
+ /** libm `sin(x)` of the native build host (table above, else Math.sin). */
41
+ export function libmSin(x: number): number {
42
+ return OCTAGON_TRIG.get(x)?.[1] ?? Math.sin(x);
43
+ }
@@ -16,6 +16,8 @@ import { makeHtmlLabelTo } from './htmltable-label.js';
16
16
  import { labelStderr } from './htmltable-attr-warn.js';
17
17
  import { substObj, type GraphObj } from './subst.js';
18
18
  import { Edge } from '../model/edge.js';
19
+ import { Node } from '../model/node.js';
20
+ import { nodeAttr, graphAttrInherited } from './attr-get.js';
19
21
  import { Graph } from '../model/graph.js';
20
22
  import { htmlEntityUTF8 } from './html-entities.js';
21
23
 
@@ -33,17 +35,27 @@ export { htmlEntityUTF8 };
33
35
  /**
34
36
  * The object's pen color: `pencolor` attr, else `color` attr, else undefined
35
37
  * (no default). An HTML table/cell border with no explicit COLOR inherits it.
38
+ * Read as C's agget does, so declared defaults count: a node's `node [...]`
39
+ * snapshot and a subgraph's inherited graph attributes (894589). Edge
40
+ * defaults are already materialized into `attrs` by the builder.
36
41
  * @see lib/common/htmltable.c:getPenColor
37
42
  */
38
43
  function getPenColor(obj?: GraphObj): string | undefined {
39
44
  if (obj === undefined) return undefined;
40
- const pc = obj.attrs.get('pencolor');
45
+ const pc = aggetObj(obj, 'pencolor');
41
46
  if (pc !== undefined && pc !== '') return pc;
42
- const c = obj.attrs.get('color');
47
+ const c = aggetObj(obj, 'color');
43
48
  if (c !== undefined && c !== '') return c;
44
49
  return undefined;
45
50
  }
46
51
 
52
+ /** `agget(obj, key)` for any object kind. @see lib/cgraph/attr.c:agxget */
53
+ function aggetObj(obj: GraphObj, key: string): string | undefined {
54
+ if (obj instanceof Node) return nodeAttr(obj, obj.root, key);
55
+ if (obj instanceof Graph) return graphAttrInherited(obj, key);
56
+ return obj.attrs.get(key);
57
+ }
58
+
47
59
  /** The root graph owning `obj` (edges resolve through their tail node). */
48
60
  function rootOf(obj: GraphObj): Graph {
49
61
  return obj instanceof Edge ? obj.tail.root : obj.root;