@knowvah/dot-engine 2.6.11 → 2.6.12

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 (125) hide show
  1. package/dist/async/render-async.d.ts.map +1 -1
  2. package/dist/common/agerr.d.ts +37 -0
  3. package/dist/common/agerr.d.ts.map +1 -0
  4. package/dist/common/arrows-shapes-poly.d.ts.map +1 -1
  5. package/dist/common/color.d.ts.map +1 -1
  6. package/dist/common/crand.d.ts +0 -20
  7. package/dist/common/crand.d.ts.map +1 -1
  8. package/dist/common/emit-once.d.ts +6 -0
  9. package/dist/common/emit-once.d.ts.map +1 -0
  10. package/dist/common/graph-init.d.ts.map +1 -1
  11. package/dist/common/htmltable-attr-warn.d.ts.map +1 -1
  12. package/dist/common/htmltable-emit-fill.d.ts +9 -0
  13. package/dist/common/htmltable-emit-fill.d.ts.map +1 -1
  14. package/dist/common/htmltable-emit-rules.d.ts.map +1 -1
  15. package/dist/common/htmltable-lex-expat.d.ts +1 -6
  16. package/dist/common/htmltable-lex-expat.d.ts.map +1 -1
  17. package/dist/common/htmltable.d.ts +1 -7
  18. package/dist/common/htmltable.d.ts.map +1 -1
  19. package/dist/common/poly-gencode.d.ts.map +1 -1
  20. package/dist/common/poly-init.d.ts.map +1 -1
  21. package/dist/common/postproc.d.ts.map +1 -1
  22. package/dist/common/random.d.ts.map +1 -1
  23. package/dist/common/render-state.d.ts +8 -0
  24. package/dist/common/render-state.d.ts.map +1 -0
  25. package/dist/common/splines-geom.d.ts +8 -0
  26. package/dist/common/splines-geom.d.ts.map +1 -1
  27. package/dist/common/splines-routespl.d.ts.map +1 -1
  28. package/dist/common/textmeasure-lookup.d.ts.map +1 -1
  29. package/dist/common/textmeasure.d.ts.map +1 -1
  30. package/dist/gvc/anchor.d.ts.map +1 -1
  31. package/dist/gvc/device-cluster.d.ts.map +1 -1
  32. package/dist/gvc/device-labels.d.ts +14 -0
  33. package/dist/gvc/device-labels.d.ts.map +1 -1
  34. package/dist/gvc/device.d.ts.map +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +765 -451
  37. package/dist/index.js.map +4 -4
  38. package/dist/layout/dot/cluster.d.ts.map +1 -1
  39. package/dist/layout/dot/conc.d.ts.map +1 -1
  40. package/dist/layout/dot/decomp.d.ts.map +1 -1
  41. package/dist/layout/dot/edge-route-clip.d.ts +18 -10
  42. package/dist/layout/dot/edge-route-clip.d.ts.map +1 -1
  43. package/dist/layout/dot/mincross-build.d.ts.map +1 -1
  44. package/dist/layout/dot/mincross-cross.d.ts.map +1 -1
  45. package/dist/layout/dot/rank.d.ts.map +1 -1
  46. package/dist/layout/dot/splines.d.ts.map +1 -1
  47. package/dist/layout/fdp/index.d.ts.map +1 -1
  48. package/dist/layout/fdp/tlayout-parms.d.ts.map +1 -1
  49. package/dist/layout/fdp/xlayout.d.ts.map +1 -1
  50. package/dist/layout/neato/edge-len.d.ts.map +1 -1
  51. package/dist/layout/neato/fdp-adjust.d.ts.map +1 -1
  52. package/dist/layout/neato/init.d.ts +4 -10
  53. package/dist/layout/neato/init.d.ts.map +1 -1
  54. package/dist/layout/neato/kk.d.ts.map +1 -1
  55. package/dist/layout/neato/multispline-router.d.ts.map +1 -1
  56. package/dist/layout/neato/multispline.d.ts.map +1 -1
  57. package/dist/layout/neato/poly.d.ts.map +1 -1
  58. package/dist/layout/neato/sgd.d.ts.map +1 -1
  59. package/dist/layout/neato/start.d.ts.map +1 -1
  60. package/dist/ortho/index.d.ts.map +1 -1
  61. package/dist/parser/index.d.ts.map +1 -1
  62. package/dist/render/color-resolve.d.ts +49 -1
  63. package/dist/render/color-resolve.d.ts.map +1 -1
  64. package/dist/render/public.d.ts.map +1 -1
  65. package/dist/render/svg-edge-split.d.ts +2 -0
  66. package/dist/render/svg-edge-split.d.ts.map +1 -1
  67. package/dist/render/xdot-public.d.ts.map +1 -1
  68. package/dist/render.js +513 -200
  69. package/dist/render.js.map +4 -4
  70. package/package.json +1 -1
  71. package/src/async/render-async.ts +6 -0
  72. package/src/common/agerr.ts +96 -0
  73. package/src/common/arrows-shapes-poly.ts +19 -5
  74. package/src/common/color.ts +38 -10
  75. package/src/common/crand.ts +8 -2
  76. package/src/common/emit-once.ts +27 -0
  77. package/src/common/graph-init.ts +2 -1
  78. package/src/common/htmltable-attr-warn.ts +3 -4
  79. package/src/common/htmltable-emit-fill.ts +48 -0
  80. package/src/common/htmltable-emit-rules.ts +2 -0
  81. package/src/common/htmltable-lex-expat.ts +78 -23
  82. package/src/common/htmltable.ts +15 -16
  83. package/src/common/poly-gencode.ts +14 -2
  84. package/src/common/poly-init.ts +2 -1
  85. package/src/common/postproc.ts +4 -1
  86. package/src/common/random.ts +11 -1
  87. package/src/common/render-state.ts +36 -0
  88. package/src/common/splines-geom.ts +8 -4
  89. package/src/common/splines-routespl.ts +2 -1
  90. package/src/common/textmeasure-lookup.ts +10 -3
  91. package/src/common/textmeasure.ts +45 -2
  92. package/src/gvc/anchor.ts +1 -12
  93. package/src/gvc/device-cluster.ts +37 -9
  94. package/src/gvc/device-labels.ts +27 -0
  95. package/src/gvc/device.ts +18 -6
  96. package/src/index.ts +3 -0
  97. package/src/layout/dot/cluster.ts +2 -1
  98. package/src/layout/dot/conc.ts +6 -5
  99. package/src/layout/dot/decomp.ts +3 -0
  100. package/src/layout/dot/edge-route-clip.ts +27 -21
  101. package/src/layout/dot/mincross-build.ts +2 -0
  102. package/src/layout/dot/mincross-cross.ts +2 -0
  103. package/src/layout/dot/rank.ts +2 -0
  104. package/src/layout/dot/splines.ts +2 -1
  105. package/src/layout/fdp/index.ts +2 -1
  106. package/src/layout/fdp/tlayout-parms.ts +9 -2
  107. package/src/layout/fdp/xlayout.ts +10 -2
  108. package/src/layout/neato/cdt-surface.ts +6 -6
  109. package/src/layout/neato/edge-len.ts +2 -1
  110. package/src/layout/neato/fdp-adjust.ts +3 -2
  111. package/src/layout/neato/init.ts +11 -11
  112. package/src/layout/neato/kk.ts +2 -1
  113. package/src/layout/neato/multispline-router.ts +3 -3
  114. package/src/layout/neato/multispline.ts +3 -2
  115. package/src/layout/neato/poly.ts +3 -2
  116. package/src/layout/neato/sgd.ts +5 -2
  117. package/src/layout/neato/start.ts +14 -10
  118. package/src/ortho/index.ts +3 -1
  119. package/src/parser/dot.js +4 -1
  120. package/src/parser/dot.pegjs +5 -1
  121. package/src/parser/index.ts +4 -3
  122. package/src/render/color-resolve.ts +193 -4
  123. package/src/render/public.ts +2 -0
  124. package/src/render/svg-edge-split.ts +4 -2
  125. package/src/render/xdot-public.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowvah/dot-engine",
3
- "version": "2.6.11",
3
+ "version": "2.6.12",
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",
@@ -18,6 +18,7 @@ import type { EngineName } from '../gvc/context.js';
18
18
  import { createDefaultContext } from '../gvc/default-context.js';
19
19
  import { checkRenderArgs } from '../render/public.js';
20
20
  import { render as deviceRender } from '../gvc/device.js';
21
+ import { resetRenderState } from '../common/render-state.js';
21
22
  import type { ImageSizer } from '../common/htmltable-types.js';
22
23
  import type { ImageResolver } from '../gvc/image-resolver.js';
23
24
  import type { OutputFormat, RenderOptions } from '../render/public.js';
@@ -148,6 +149,11 @@ export async function renderAsync(
148
149
  const engine: EngineName = opts?.engine ?? 'dot';
149
150
  try {
150
151
  const { fontIssues, sizes, bytes } = await prefetchAll(g, opts);
152
+ // AD-4: reset after the last await, so the synchronous layout + render
153
+ // below starts from C's initial process state even when other renders ran
154
+ // while the prefetch was pending. renderSvgAsync and renderSvgInto reach
155
+ // this through renderAsync.
156
+ resetRenderState();
151
157
  // Created after fonts settle so the measurer sees the loaded faces.
152
158
  const ctx = createDefaultContext();
153
159
  if (opts?.imageSizer !== undefined) {
@@ -0,0 +1,96 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * The one stderr writer for messages C prints through cgraph's agerr family
5
+ * (`agerr`, `agerrorf`, `agwarningf`, `AGPREV` continuations). Sites whose C
6
+ * counterpart is `fprintf(stderr)`/`fputs`/`printf` do not use it.
7
+ */
8
+
9
+ import { registerRenderState } from './render-state.js';
10
+
11
+ /** `agerrlevel_t` as the port uses it: AGERR, AGWARN, AGPREV. */
12
+ export type AgerrLevel = 'error' | 'warning' | 'prev';
13
+
14
+ /** The console channel a line goes to. */
15
+ export type AgerrChannel = 'warn' | 'error';
16
+
17
+ const PREFIX_ERROR = 'Error';
18
+ const PREFIX_WARNING = 'Warning';
19
+ const PREFIX_SEP = ': ';
20
+ const DEL = 0x7f;
21
+ const LAST_CNTRL = 0x1f;
22
+ const WHITESPACE = new Set([0x09, 0x0a, 0x0b, 0x0c, 0x0d]);
23
+ const OCTAL_WIDTH = 3;
24
+ const OCTAL_RADIX = 8;
25
+
26
+ /**
27
+ * The level of the last non-AGPREV message, which AGPREV continues: C's
28
+ * `agerrno`, zero-initialised, i.e. AGWARN. Reset per render (AD-4).
29
+ * @see lib/cgraph/agerror.c:agerrno
30
+ */
31
+ let lastLevel: 'error' | 'warning' = 'warning';
32
+
33
+ registerRenderState(() => {
34
+ lastLevel = 'warning';
35
+ });
36
+
37
+ /**
38
+ * `default_usererrf`: every character that `gv_iscntrl(c) && !gv_isspace(c)`
39
+ * (0x00-0x1F except `\t\n\v\f\r`, and 0x7F) is written as `\ooo`.
40
+ * @see lib/cgraph/agerror.c:default_usererrf
41
+ */
42
+ export function escapeControl(message: string): string {
43
+ let out = '';
44
+ for (const ch of message) {
45
+ const c = ch.codePointAt(0)!;
46
+ if ((c <= LAST_CNTRL || c === DEL) && !WHITESPACE.has(c)) {
47
+ out += `\\${c.toString(OCTAL_RADIX).padStart(OCTAL_WIDTH, '0')}`;
48
+ } else out += ch;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Write one already-assembled stderr line (prefix included) on a console
55
+ * channel, escaped as `default_usererrf` would.
56
+ */
57
+ export function agerrLineOn(channel: AgerrChannel, line: string): void {
58
+ if (channel === 'warn') console.warn(escapeControl(line));
59
+ else console.error(escapeControl(line));
60
+ }
61
+
62
+ /**
63
+ * The part of C's `agerr(level, ...)` that is not formatting: the `Error: `/
64
+ * `Warning: ` prefix (none for AGPREV), escaping, and the console channel
65
+ * (`console.warn` for warnings, `console.error` otherwise; AGPREV continues
66
+ * the previous level's channel).
67
+ * @see lib/cgraph/agerror.c:out
68
+ */
69
+ export function agerr(level: AgerrLevel, msg: string, channel?: AgerrChannel): void {
70
+ if (level === 'prev') {
71
+ agerrLineOn(channel ?? (lastLevel === 'warning' ? 'warn' : 'error'), msg);
72
+ return;
73
+ }
74
+ lastLevel = level;
75
+ const prefix = level === 'error' ? PREFIX_ERROR : PREFIX_WARNING;
76
+ agerrLineOn(channel ?? (level === 'warning' ? 'warn' : 'error'), `${prefix}${PREFIX_SEP}${msg}`);
77
+ }
78
+
79
+ /** @see lib/cgraph/agerror.c:agerrorf */
80
+ export const agerrorf = (msg: string, channel?: AgerrChannel): void => agerr('error', msg, channel);
81
+
82
+ /**
83
+ * @see lib/cgraph/agerror.c:agwarningf
84
+ * `channel` overrides the console channel for sites that have always written
85
+ * their warning on `console.error`.
86
+ */
87
+ export const agwarningf = (msg: string, channel?: AgerrChannel): void =>
88
+ agerr('warning', msg, channel);
89
+
90
+ /**
91
+ * A finished line whose prefix is already part of the text. The channel
92
+ * follows the prefix (`Warning` -> `console.warn`) unless `channel` is given.
93
+ */
94
+ export function agerrFinished(line: string, channel?: AgerrChannel): void {
95
+ agerrLineOn(channel ?? (line.startsWith(PREFIX_WARNING) ? 'warn' : 'error'), line);
96
+ }
@@ -11,6 +11,7 @@
11
11
  import type { Point } from '../model/geom.js';
12
12
  import { ccwrotatepf, cwrotatepf } from '../model/geom.js';
13
13
  import { bezier } from './bezier.js';
14
+ import { fma } from './fma.js';
14
15
  import type { GenResult } from './arrows-shapes-util.js';
15
16
  import {
16
17
  vsub, vadd, miterShape, negUnit, backwardDelta, axialProjection,
@@ -206,14 +207,27 @@ function cubicHalf(c: Point[], rightHalf: boolean): Point[] {
206
207
  return half;
207
208
  }
208
209
 
209
- /** Build the four Bézier control points for the curve arms. */
210
+ /**
211
+ * Build the four Bézier control points for the curve arms.
212
+ *
213
+ * The oracle (libgvc 16.1 `_arrow_type_curve`) contracts only the
214
+ * `p.x +/- 0.95 * v.x` term into `fmadd(v.x, +/-0.95, p.x)`; every other add
215
+ * is a plain fadd, and `w.x * 4.0 / 3.0` is `(w.x * 4) / 3` (fmul, fdiv).
216
+ * Only the x arms carry the 0.95 term, so AF[1].y / AF[2].y stay unfused.
217
+ * @see lib/common/arrows.c:arrow_type_curve
218
+ */
210
219
  function curveControlPoints(p: Point, v: Point, w: Point, flag: number): Point[] {
211
220
  const AF0: Point = { x: p.x + v.x + w.x, y: p.y + v.y + w.y };
212
221
  const AF3: Point = { x: p.x - v.x + w.x, y: p.y - v.y + w.y };
213
- const s = flag & ARR_MOD_INV ? 1 : -1; // inv bulges the curve the other way
214
- const AF1: Point = { x: p.x + 0.95 * v.x + w.x + s * w.x * (4.0 / 3.0), y: AF0.y + s * w.y * (4.0 / 3.0) };
215
- const AF2: Point = { x: p.x - 0.95 * v.x + w.x + s * w.x * (4.0 / 3.0), y: AF3.y + s * w.y * (4.0 / 3.0) };
216
- return [AF0, AF1, AF2, AF3];
222
+ const bulgeX = (w.x * 4.0) / 3.0;
223
+ const bulgeY = (w.y * 4.0) / 3.0;
224
+ const x1 = fma(v.x, 0.95, p.x) + w.x;
225
+ const x2 = fma(v.x, -0.95, p.x) + w.x;
226
+ if (flag & ARR_MOD_INV) { /* ----(-| */
227
+ return [AF0, { x: x1 + bulgeX, y: AF0.y + bulgeY }, { x: x2 + bulgeX, y: AF3.y + bulgeY }, AF3];
228
+ }
229
+ /* ----)-| */
230
+ return [AF0, { x: x1 - bulgeX, y: AF0.y - bulgeY }, { x: x2 - bulgeX, y: AF3.y - bulgeY }, AF3];
217
231
  }
218
232
 
219
233
  /** @see lib/common/arrows.c:arrow_type_curve */
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { isExactlyEqual } from '../util/math.js';
9
9
  import { COLOR_LIB } from './colorData.js';
10
+ import { registerRenderState } from './render-state.js';
10
11
 
11
12
  // ---------------------------------------------------------------------------
12
13
  // Public types
@@ -37,6 +38,7 @@ interface HsvaTuple { H: number; S: number; V: number; A: number }
37
38
  // ---------------------------------------------------------------------------
38
39
 
39
40
  let colorScheme: string = '';
41
+ registerRenderState(() => { colorScheme = ''; });
40
42
 
41
43
  /** @see lib/common/colxlate.c:setColorScheme */
42
44
  export function setColorScheme(scheme: string): void {
@@ -256,17 +258,43 @@ function setBlackOpaque(color: GVColor, targetType: ColorType): void {
256
258
  // Hex and HSV string parsers
257
259
  // ---------------------------------------------------------------------------
258
260
 
259
- /** Parse "#rrggbb", "#rrggbbaa", or "#rgb". Returns null on failure. */
261
+ /** C isspace in the "C" locale. */
262
+ const SSCANF_SPACE = new Set([' ', '\t', '\n', '\v', '\f', '\r']);
263
+
264
+ /**
265
+ * `sscanf(p, "#" + "%<width>x" * max)`: the values converted before the first
266
+ * failure. Each `%x` skips leading white space then takes up to `width` hex
267
+ * digits, so a short or odd-length run still converts ("#12345" gives 0x12,
268
+ * 0x34, 0x5). Sign and `0x` prefixes are not modelled.
269
+ * @see lib/common/colxlate.c:colorxlate
270
+ */
271
+ function scanHexGroups(p: string, width: number, max: number): number[] {
272
+ const out: number[] = [];
273
+ if (p.charAt(0) !== '#') return out;
274
+ let i = 1;
275
+ while (out.length < max) {
276
+ while (SSCANF_SPACE.has(p.charAt(i))) i++;
277
+ let j = i;
278
+ while (j < p.length && j - i < width && /[0-9a-fA-F]/.test(p.charAt(j))) j++;
279
+ if (j === i) break;
280
+ out.push(parseInt(p.slice(i, j), 16));
281
+ i = j;
282
+ }
283
+ return out;
284
+ }
285
+
286
+ /**
287
+ * Parse "#rrggbb", "#rrggbbaa" (any run of at least three two-digit groups),
288
+ * or the 4-character "#rgb" form, as colorxlate's sscanf calls do. Returns
289
+ * null on failure.
290
+ */
260
291
  function parseHexColor(p: string): RgbaTuple | null {
261
- if (p.charAt(0) !== '#') return null;
262
- const m8 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(p);
263
- if (m8) return { r: parseInt(m8[1], 16), g: parseInt(m8[2], 16), b: parseInt(m8[3], 16), a: parseInt(m8[4], 16) };
264
- const m6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(p);
265
- if (m6) return { r: parseInt(m6[1], 16), g: parseInt(m6[2], 16), b: parseInt(m6[3], 16), a: 255 };
266
- const m3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(p);
267
- if (!m3) return null;
268
- const r = parseInt(m3[1], 16); const g = parseInt(m3[2], 16); const b = parseInt(m3[3], 16);
269
- return { r: r | (r << 4), g: g | (g << 4), b: b | (b << 4), a: 255 };
292
+ const g = scanHexGroups(p, 2, 4);
293
+ if (g.length >= 3) return { r: g[0]!, g: g[1]!, b: g[2]!, a: g[3] ?? 255 };
294
+ const short = p.length === 4 ? scanHexGroups(p, 1, 3) : [];
295
+ if (short.length !== 3) return null;
296
+ const [r, gg, b] = short as [number, number, number];
297
+ return { r: r | (r << 4), g: gg | (gg << 4), b: b | (b << 4), a: 255 };
270
298
  }
271
299
 
272
300
  /** Clamp a number to [0, 1]. */
@@ -21,11 +21,17 @@
21
21
  * @see lib/util/random.c:gv_random / gv_permutation (15.0.0)
22
22
  */
23
23
 
24
+ import { registerRenderState } from './render-state.js';
25
+
24
26
  /** RAND_MAX on the reference platform. */
25
27
  export const C_RAND_MAX = 2147483647;
26
28
 
27
- /** Current stream state; unseeded C rand() behaves as srand(1). */
28
- let state = 1;
29
+ /** Unseeded C rand() behaves as srand(1). */
30
+ const UNSEEDED_STATE = 1;
31
+
32
+ /** Current stream state. */
33
+ let state = UNSEEDED_STATE;
34
+ registerRenderState(() => { state = UNSEEDED_STATE; });
29
35
 
30
36
  /** srand(3). */
31
37
  export function csrand(seed: number): void {
@@ -0,0 +1,27 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * Once-per-process message gate. C keeps the seen strings in a file static
5
+ * dictionary that is cleared only by `emit_once_reset` in `gvFreeContext`; one
6
+ * native run is one process, so the port clears it per render (AD-4).
7
+ *
8
+ * @see lib/common/emit.c:emit_once
9
+ */
10
+
11
+ import { registerRenderState } from './render-state.js';
12
+
13
+ const seen = new Set<string>();
14
+
15
+ registerRenderState(() => {
16
+ seen.clear();
17
+ });
18
+
19
+ /**
20
+ * True the first time `str` is offered since the last reset, false after.
21
+ * @see lib/common/emit.c:emit_once
22
+ */
23
+ export function emitOnce(str: string): boolean {
24
+ if (seen.has(str)) return false;
25
+ seen.add(str);
26
+ return true;
27
+ }
@@ -29,6 +29,7 @@ import type { RatioKind } from '../model/layoutParams.js';
29
29
  import { doGraphLabel } from '../layout/dot/graph-label.js';
30
30
  import { mapbool, setClType, LOCAL, GLOBAL, NOCLUST } from '../layout/dot/rank.js';
31
31
  import { cround } from './arith.js';
32
+ import { agwarningf } from './agerr.js';
32
33
 
33
34
  // ---------------------------------------------------------------------------
34
35
  // Constants — @see lib/common/const.h
@@ -88,7 +89,7 @@ export function findCharset(g: Graph): number {
88
89
  }
89
90
  if (p === 'big-5' || p === 'big5') return CHAR_BIG5;
90
91
  if (p === 'utf-8' || p === 'utf8') return CHAR_UTF8;
91
- console.error(`Warning: Unsupported charset "${raw}" - assuming utf-8`);
92
+ agwarningf(`Unsupported charset "${raw}" - assuming utf-8`, 'error');
92
93
  return CHAR_UTF8;
93
94
  }
94
95
 
@@ -11,6 +11,8 @@
11
11
  * @see lib/common/htmllex.c:doAttrs
12
12
  */
13
13
 
14
+ import { agerrFinished } from './agerr.js';
15
+
14
16
  /** An attribute as startElement gets it: name in source case, value decoded by expat. */
15
17
  export type AttrEntry = readonly [name: string, value: string];
16
18
 
@@ -272,10 +274,7 @@ export class StderrLines {
272
274
  }
273
275
 
274
276
  /** A finished stderr line on the console channel the port uses for it. */
275
- export const consoleLine = (line: string): void => {
276
- if (line.startsWith('Warning')) console.warn(line);
277
- else console.error(line);
278
- };
277
+ export const consoleLine = (line: string): void => agerrFinished(line);
279
278
 
280
279
  /** One label's stderr: what make_html_label and make_label print, in order. */
281
280
  export const labelStderr = (): StderrLines => new StderrLines(consoleLine);
@@ -18,6 +18,7 @@ import { FillType, PenType } from '../gvc/context.js';
18
18
  import { transformPoint } from '../gvc/device.js';
19
19
  import { resolveRenderColor } from '../render/color-resolve.js';
20
20
  import { emitRoundedBezier } from './poly-shapes.js';
21
+ import type { PlacedHtml, PlacedCell } from './htmltable-pos.js';
21
22
  import { htmlStyle } from './htmltable-style.js';
22
23
  import {
23
24
  BORDER_LEFT,
@@ -26,6 +27,7 @@ import {
26
27
  BORDER_BOTTOM,
27
28
  BORDER_MASK,
28
29
  } from './htmltable-types.js';
30
+ import { registerRenderState } from './render-state.js';
29
31
 
30
32
  // ---------------------------------------------------------------------------
31
33
  // Gradient spec parser
@@ -136,6 +138,7 @@ export function htmlFillPenWidth(): number {
136
138
  export function resetHtmlFillPenWidth(): void {
137
139
  htmlFillPen = 1.0;
138
140
  }
141
+ registerRenderState(resetHtmlFillPenWidth);
139
142
 
140
143
  /** Map an HTML STYLE to the C pen type: dashed wins over dotted. @see lib/common/htmltable.c:doBorder */
141
144
  function penTypeOf(style: string | undefined): PenType {
@@ -366,3 +369,48 @@ function emitSidedPolylines(
366
369
  renderer.polyline(seq.map((i) => corners[i]!), job);
367
370
  }
368
371
  }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // htmlLabelColors — the colour names C resolves while emitting an HTML label
375
+ // ---------------------------------------------------------------------------
376
+
377
+ /** @see lib/common/const.h DEFAULT_COLOR */
378
+ const HTML_DEFAULT_COLOR = 'black';
379
+
380
+ /** setFill's names: the fill (and a gradient's stop). @see htmltable.c:setFill */
381
+ function bgNames(bgcolor: string): string[] {
382
+ const spec = parseGradientSpec(bgcolor);
383
+ return spec === null ? [bgcolor] : [spec[0], spec[1]];
384
+ }
385
+
386
+ /** One cell in emit_html_cell order: fill, border, then the child. */
387
+ function cellNames(cell: PlacedCell): string[] {
388
+ if (htmlStyle(cell.style).invisible) return [];
389
+ const out = cell.bgcolor !== undefined ? bgNames(cell.bgcolor) : [];
390
+ if (cell.border > 0) out.push(cell.color ?? HTML_DEFAULT_COLOR);
391
+ if (cell.nested !== undefined) out.push(...tableNames(cell.nested));
392
+ else for (const line of cell.lines) if (line.fontColor !== null) out.push(line.fontColor);
393
+ return out;
394
+ }
395
+
396
+ /** One table in emit_html_tbl order: fill, rules, border, cells. */
397
+ function tableNames(tbl: PlacedHtml): string[] {
398
+ if (htmlStyle(tbl.style).invisible) return [];
399
+ const out = tbl.bgcolor !== undefined ? bgNames(tbl.bgcolor) : [];
400
+ const color = tbl.color ?? HTML_DEFAULT_COLOR;
401
+ if (tbl.cells.some((c) => c.vruled === true || c.hruled === true)) out.push(color);
402
+ if (tbl.border > 0) out.push(color);
403
+ for (const cell of tbl.cells) out.push(...cellNames(cell));
404
+ return out;
405
+ }
406
+
407
+ /**
408
+ * The names an HTML label hands to gvrender_resolve_color, in C's order: the
409
+ * root table's pen first (emit_html_label), then emit_html_tbl's walk.
410
+ * @see lib/common/htmltable.c:emit_html_label
411
+ * @see lib/common/htmltable.c:emit_html_tbl
412
+ * @see lib/common/htmltable.c:emit_html_cell
413
+ */
414
+ export function htmlLabelColors(label: PlacedHtml): string[] {
415
+ return [label.color ?? HTML_DEFAULT_COLOR, ...tableNames(label)];
416
+ }
@@ -14,6 +14,7 @@ import type { RenderJob } from '../gvc/job.js';
14
14
  import type { RendererPlugin } from '../gvc/context.js';
15
15
  import type { PlacedCell, PlacedHtml } from './htmltable-pos.js';
16
16
  import { doSide, withHtmlPaint } from './htmltable-emit-fill.js';
17
+ import { registerRenderState } from './render-state.js';
17
18
 
18
19
  // ---------------------------------------------------------------------------
19
20
  // Rule context
@@ -217,6 +218,7 @@ export function resetHtmlAnchorIds(): void {
217
218
  anchorEnv.objLabel = null;
218
219
  anchorEnv.imgscale = 'false';
219
220
  }
221
+ registerRenderState(resetHtmlAnchorIds);
220
222
 
221
223
  /**
222
224
  * Open an anchor when the data carries href/title/target.
@@ -270,12 +270,19 @@ 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
- /** Character data at `p` up to the next `<`: one `text` token, then the index it ends at. */
274
- function scanText(text: string, p: number, base: number, tokens: ExpatToken[]): number {
273
+ /**
274
+ * Character data at `p` up to the next `<`: one `text` token, then the index it
275
+ * 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)
278
+ */
279
+ function scanText(text: string, p: number, base: number, tokens: ExpatToken[]): { at: number; held: boolean } {
275
280
  const e = text.indexOf('<', p);
276
- const end = e < 0 ? text.length : e;
277
- tokens.push({ kind: 'text', start: base + p, end: base + end });
278
- return end;
281
+ const slice = text.slice(p, e < 0 ? text.length : e);
282
+ const from = e < 0 ? heldRefStart(slice) : undefined;
283
+ const end = from === undefined ? p + slice.length : p + from;
284
+ if (end > p) tokens.push({ kind: 'text', start: base + p, end: base + end });
285
+ return { at: end, held: from !== undefined };
279
286
  }
280
287
 
281
288
  /** The fed text of the label source between two offsets. */
@@ -295,7 +302,9 @@ function scanContent(text: string, base: number, inCdata: boolean, dataOf: Cdata
295
302
  continue;
296
303
  }
297
304
  if (text[p] !== '<') {
298
- p = scanText(text, p, base, tokens);
305
+ const scanned = scanText(text, p, base, tokens);
306
+ if (scanned.held) return { tokens, consumed: scanned.at, inCdata: false };
307
+ p = scanned.at;
299
308
  continue;
300
309
  }
301
310
  const { scan, kind } = scanMarkup(text, p);
@@ -408,33 +417,79 @@ export class ExpatFeed {
408
417
  /** Entities expat knows without a DTD; `scanEntity` leaves them as written. */
409
418
  const XML_PREDEFINED_ENTITIES = new Set(['lt', 'gt', 'amp', 'quot', 'apos']);
410
419
 
411
- const ENTITY_NAME = /^[A-Za-z_:][\w.:-]*$/;
420
+ /** An `&name;` that `scanEntity` rewrites: 2 to 8 characters (alphanumeric) that name an HTML entity. */
421
+ const SCANNED_ENTITY = /^&([^;&<]*);/;
422
+
423
+ /**
424
+ * The reference the content tokenizer reads at the `&` at `t`: the index after
425
+ * its `;`, or the index of the character that makes it an invalid token (the
426
+ * end of `text` when the reference runs out: `</HTML>` or another tag follows).
427
+ * Runs the attribute value scanner over the reference alone, closed by a quote,
428
+ * which is scanRef on the same bytes.
429
+ * @see expat lib/xmltok_impl.c:PREFIX(contentTok) (BT_AMP)
430
+ * @see expat lib/xmltok_impl.c:PREFIX(scanRef)
431
+ * @see expat lib/xmltok_impl.c:PREFIX(scanCharRef)
432
+ * @see expat lib/xmltok_impl.c:PREFIX(scanHexCharRef)
433
+ */
434
+ function contentRef(text: string, t: number): { end: number } | { invalid: number } {
435
+ const semi = text.indexOf(';', t);
436
+ const ref = text.slice(t, semi < 0 ? text.length : semi + 1);
437
+ const scan = scanAttValue(`"${ref}"`, 0);
438
+ if ('close' in scan) return { end: t + scan.close - 1 };
439
+ return { invalid: 'invalid' in scan ? t + scan.invalid - 1 : text.length };
440
+ }
412
441
 
413
- const CHAR_REF = /^&#(?:x[0-9A-Fa-f]+|[0-9]+);/;
442
+ /** A reference problem of character data: expat's error, where its event pointer is, and the `&` it began at. */
443
+ interface RefProblem {
444
+ message: string;
445
+ offset: number;
446
+ from: number;
447
+ }
414
448
 
415
449
  /**
416
- * The first reference of character data that expat rejects, at its offset: a
417
- * `&name;` scanEntity leaves alone (neither an HTML entity nor predefined), or
418
- * a character reference XML does not allow.
419
- * @see expat lib/xmlparse.c:doContent (XML_TOK_ENTITY_REF, XML_TOK_CHAR_REF)
450
+ * The first reference of character data that expat rejects: an invalid token
451
+ * (a bare `&`, `&#;`, `&#X41;`, `&#65q`, ..), a `&name;` scanEntity leaves
452
+ * alone (neither an HTML entity nor predefined), or a character reference XML
453
+ * does not allow. An invalid token at the very end of `text` is a reference
454
+ * expat is still waiting on.
455
+ * @see lib/common/utils.c:scanEntity
456
+ * @see expat lib/xmlparse.c:doContent (XML_TOK_INVALID, XML_TOK_ENTITY_REF, XML_TOK_CHAR_REF)
420
457
  */
421
- export function textRefError(text: string): { message: string; offset: number } | undefined {
422
- for (let t = text.indexOf('&'); t >= 0; t = text.indexOf('&', t + 1)) {
423
- if (text[t + 1] === '#') {
424
- const ref = CHAR_REF.exec(text.slice(t))?.[0];
425
- if (ref !== undefined && charRefNumber(ref) < 0) return { message: XML_ERROR_BAD_CHAR_REF, offset: t };
426
- continue;
458
+ function firstRefProblem(text: string): RefProblem | undefined {
459
+ let t = text.indexOf('&');
460
+ while (t >= 0) {
461
+ if (text[t + 1] !== '#') {
462
+ const name = SCANNED_ENTITY.exec(text.slice(t))?.[1];
463
+ if (name !== undefined && name.length >= 2 && Object.hasOwn(HTML_ENTITIES, name)) {
464
+ t = text.indexOf('&', t + name.length + 2);
465
+ continue;
466
+ }
427
467
  }
428
- const semi = text.indexOf(';', t + 1);
429
- if (semi < 0) continue;
430
- const key = text.slice(t + 1, semi);
431
- if (ENTITY_NAME.test(key) && !Object.hasOwn(HTML_ENTITIES, key) && !XML_PREDEFINED_ENTITIES.has(key)) {
432
- return { message: XML_ERROR_UNDEFINED_ENTITY, offset: t };
468
+ const ref = contentRef(text, t);
469
+ if ('invalid' in ref) return { message: XML_ERROR_INVALID_TOKEN, offset: ref.invalid, from: t };
470
+ const key = text.slice(t + 1, ref.end - 1);
471
+ if (text[t + 1] === '#') {
472
+ if (charRefNumber(`&${key};`) < 0) return { message: XML_ERROR_BAD_CHAR_REF, offset: t, from: t };
473
+ } else if (!XML_PREDEFINED_ENTITIES.has(key)) {
474
+ return { message: XML_ERROR_UNDEFINED_ENTITY, offset: t, from: t };
433
475
  }
476
+ t = text.indexOf('&', ref.end);
434
477
  }
435
478
  return undefined;
436
479
  }
437
480
 
481
+ /** The first reference of character data that expat rejects, and the offset of its event pointer. */
482
+ export function textRefError(text: string): { message: string; offset: number } | undefined {
483
+ const problem = firstRefProblem(text);
484
+ return problem === undefined ? undefined : { message: problem.message, offset: problem.offset };
485
+ }
486
+
487
+ /** Where a reference that runs to the end of `text` begins: expat holds it until more data arrives. */
488
+ function heldRefStart(text: string): number | undefined {
489
+ const problem = firstRefProblem(text);
490
+ return problem?.message === XML_ERROR_INVALID_TOKEN && problem.offset === text.length ? problem.from : undefined;
491
+ }
492
+
438
493
  export interface TagChunk {
439
494
  close: boolean;
440
495
  selfClose: boolean;
@@ -169,25 +169,24 @@ const itemHasFlags = (i: HtmlTextItem): boolean =>
169
169
  i.subscript === true || i.superscript === true;
170
170
 
171
171
  /**
172
- * C's "simple" test: one item per line, no font flags, uniform
173
- * resolved face and size across all lines.
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.
174
176
  * @see lib/common/htmltable.c:size_html_txt (lines 946-986)
175
177
  */
176
- export const htmlTextIsSimple = (texts: HtmlText[], env: HtmlSizeEnv): boolean => {
178
+ const htmlTextIsSimple = (lines: SizedLine[], env: HtmlSizeEnv): boolean => {
177
179
  let prevFs = -1;
178
180
  let prevFace: string | null = null;
179
- let count = 0;
180
- for (const txt of texts) {
181
- for (const item of txt.items) {
182
- if (item.br) { count = 0; continue; }
183
- if (item.text === undefined) continue;
184
- if (++count > 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
- }
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;
191
190
  }
192
191
  return true;
193
192
  };
@@ -203,7 +202,7 @@ const sizeTextContent = (
203
202
  env: HtmlSizeEnv = defaultSizeEnv(),
204
203
  ): { w: number; h: number } => {
205
204
  const lines = splitSizedLines(texts, measurer, env);
206
- const simple = htmlTextIsSimple(texts, env);
205
+ const simple = htmlTextIsSimple(lines, env);
207
206
  let w = 0;
208
207
  let h = 0;
209
208
  for (const ln of lines) {
@@ -33,7 +33,8 @@ import {
33
33
  styleTokens,
34
34
  } from './style-resolve.js';
35
35
  import { stripedBox, wedgedEllipse } from '../render/svg-multicolor.js';
36
- import { resolveRenderColor } from '../render/color-resolve.js';
36
+ import { resolveRenderColor, reportColorNames } from '../render/color-resolve.js';
37
+ import { htmlLabelColors } from './htmltable-emit-fill.js';
37
38
  import { svgNodeId } from '../render/svg-id.js';
38
39
  import { drawRoundCorners, mcircleHack, underlineDraw } from './poly-shapes.js';
39
40
 
@@ -191,6 +192,8 @@ export function renderLabel(
191
192
  job: RenderJob,
192
193
  ): void {
193
194
  if (label.u.kind !== 'txt' || label.u.nspans < 1) return;
195
+ // gvrender_set_pencolor(job, lp->fontcolor), once, before the spans.
196
+ reportColorNames([label.fontcolor]);
194
197
  label.pos = coord;
195
198
  label.set = true;
196
199
  // First-span baseline per valign (labelloc). @see lib/common/labels.c:240-251
@@ -227,6 +230,7 @@ function renderNodeLabel(
227
230
  // Route the HTML label's box/fill/text ops into the node LABEL emit-state
228
231
  // (→ _ldraw_ for xdot), not the node DRAW state. SVG ignores emit_state.
229
232
  const html = label.u.html as PlacedHtml;
233
+ reportColorNames(htmlLabelColors(html));
230
234
  withLabelEmitState(job, () => emitHtmlLabel(html, coord, renderer, job));
231
235
  } else {
232
236
  renderLabel(label, coord, renderer, job);
@@ -367,8 +371,16 @@ export function applyNodeStyle(obj: ObjState, n: Node, job: RenderJob): boolean
367
371
  // the whole node emission (emit_begin_node..emit_end_node), which covers both
368
372
  // this color block and the label textspans. Both callers of this function are
369
373
  // codefns, so they already run inside that window. @see gvc/device.ts
374
+ const penwidth = nodeAttr(n, n.root, 'penwidth');
375
+ // record_gencode resolves the pen (penColor) before the fill; poly_gencode
376
+ // and point_gencode resolve the fill first. Only the order in which an
377
+ // unknown colour is reported differs. @see shapes.c:3890-3892 vs 2985-3004
378
+ if ((n.info.shape as ShapeDesc | undefined)?.kind === ShapeKind.SH_RECORD) {
379
+ applyPenState(obj, styleAttr, colorAttr, penwidth);
380
+ return applyFillState(obj, n, job);
381
+ }
370
382
  const filled = applyFillState(obj, n, job);
371
- applyPenState(obj, styleAttr, colorAttr, nodeAttr(n, n.root, 'penwidth'));
383
+ applyPenState(obj, styleAttr, colorAttr, penwidth);
372
384
  return filled;
373
385
  }
374
386