@knowvah/dot-engine 2.6.10 → 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 (131) 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/html-entities.d.ts +13 -5
  12. package/dist/common/html-entities.d.ts.map +1 -1
  13. package/dist/common/htmltable-attr-warn.d.ts.map +1 -1
  14. package/dist/common/htmltable-emit-fill.d.ts +9 -0
  15. package/dist/common/htmltable-emit-fill.d.ts.map +1 -1
  16. package/dist/common/htmltable-emit-rules.d.ts.map +1 -1
  17. package/dist/common/htmltable-lex-chardata.d.ts +9 -0
  18. package/dist/common/htmltable-lex-chardata.d.ts.map +1 -1
  19. package/dist/common/htmltable-lex-expat.d.ts +1 -6
  20. package/dist/common/htmltable-lex-expat.d.ts.map +1 -1
  21. package/dist/common/htmltable.d.ts +1 -7
  22. package/dist/common/htmltable.d.ts.map +1 -1
  23. package/dist/common/poly-gencode.d.ts.map +1 -1
  24. package/dist/common/poly-init.d.ts.map +1 -1
  25. package/dist/common/postproc.d.ts.map +1 -1
  26. package/dist/common/random.d.ts.map +1 -1
  27. package/dist/common/render-state.d.ts +8 -0
  28. package/dist/common/render-state.d.ts.map +1 -0
  29. package/dist/common/splines-geom.d.ts +8 -0
  30. package/dist/common/splines-geom.d.ts.map +1 -1
  31. package/dist/common/splines-routespl.d.ts.map +1 -1
  32. package/dist/common/textmeasure-lookup.d.ts.map +1 -1
  33. package/dist/common/textmeasure.d.ts.map +1 -1
  34. package/dist/gvc/anchor.d.ts.map +1 -1
  35. package/dist/gvc/device-cluster.d.ts.map +1 -1
  36. package/dist/gvc/device-labels.d.ts +14 -0
  37. package/dist/gvc/device-labels.d.ts.map +1 -1
  38. package/dist/gvc/device.d.ts.map +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +821 -471
  41. package/dist/index.js.map +4 -4
  42. package/dist/layout/dot/cluster.d.ts.map +1 -1
  43. package/dist/layout/dot/conc.d.ts.map +1 -1
  44. package/dist/layout/dot/decomp.d.ts.map +1 -1
  45. package/dist/layout/dot/edge-route-clip.d.ts +18 -10
  46. package/dist/layout/dot/edge-route-clip.d.ts.map +1 -1
  47. package/dist/layout/dot/mincross-build.d.ts.map +1 -1
  48. package/dist/layout/dot/mincross-cross.d.ts.map +1 -1
  49. package/dist/layout/dot/rank.d.ts.map +1 -1
  50. package/dist/layout/dot/splines.d.ts.map +1 -1
  51. package/dist/layout/fdp/index.d.ts.map +1 -1
  52. package/dist/layout/fdp/tlayout-parms.d.ts.map +1 -1
  53. package/dist/layout/fdp/xlayout.d.ts.map +1 -1
  54. package/dist/layout/neato/edge-len.d.ts.map +1 -1
  55. package/dist/layout/neato/fdp-adjust.d.ts.map +1 -1
  56. package/dist/layout/neato/init.d.ts +4 -10
  57. package/dist/layout/neato/init.d.ts.map +1 -1
  58. package/dist/layout/neato/kk.d.ts.map +1 -1
  59. package/dist/layout/neato/multispline-router.d.ts.map +1 -1
  60. package/dist/layout/neato/multispline.d.ts.map +1 -1
  61. package/dist/layout/neato/poly.d.ts.map +1 -1
  62. package/dist/layout/neato/sgd.d.ts.map +1 -1
  63. package/dist/layout/neato/start.d.ts.map +1 -1
  64. package/dist/ortho/index.d.ts.map +1 -1
  65. package/dist/parser/index.d.ts.map +1 -1
  66. package/dist/render/color-resolve.d.ts +49 -1
  67. package/dist/render/color-resolve.d.ts.map +1 -1
  68. package/dist/render/public.d.ts.map +1 -1
  69. package/dist/render/svg-edge-split.d.ts +2 -0
  70. package/dist/render/svg-edge-split.d.ts.map +1 -1
  71. package/dist/render/xdot-public.d.ts.map +1 -1
  72. package/dist/render.js +569 -220
  73. package/dist/render.js.map +4 -4
  74. package/package.json +1 -1
  75. package/src/async/render-async.ts +6 -0
  76. package/src/common/agerr.ts +96 -0
  77. package/src/common/arrows-shapes-poly.ts +19 -5
  78. package/src/common/color.ts +38 -10
  79. package/src/common/crand.ts +8 -2
  80. package/src/common/emit-once.ts +27 -0
  81. package/src/common/graph-init.ts +2 -1
  82. package/src/common/html-entities.ts +77 -25
  83. package/src/common/htmltable-attr-warn.ts +3 -4
  84. package/src/common/htmltable-emit-fill.ts +48 -0
  85. package/src/common/htmltable-emit-rules.ts +2 -0
  86. package/src/common/htmltable-lex-chardata.ts +42 -8
  87. package/src/common/htmltable-lex-expat.ts +78 -23
  88. package/src/common/htmltable.ts +15 -16
  89. package/src/common/poly-gencode.ts +14 -2
  90. package/src/common/poly-init.ts +2 -1
  91. package/src/common/postproc.ts +4 -1
  92. package/src/common/random.ts +11 -1
  93. package/src/common/render-state.ts +36 -0
  94. package/src/common/splines-geom.ts +8 -4
  95. package/src/common/splines-routespl.ts +2 -1
  96. package/src/common/textmeasure-lookup.ts +10 -3
  97. package/src/common/textmeasure.ts +45 -2
  98. package/src/gvc/anchor.ts +1 -12
  99. package/src/gvc/device-cluster.ts +37 -9
  100. package/src/gvc/device-labels.ts +27 -0
  101. package/src/gvc/device.ts +18 -6
  102. package/src/index.ts +3 -0
  103. package/src/layout/dot/cluster.ts +2 -1
  104. package/src/layout/dot/conc.ts +6 -5
  105. package/src/layout/dot/decomp.ts +3 -0
  106. package/src/layout/dot/edge-route-clip.ts +27 -21
  107. package/src/layout/dot/mincross-build.ts +2 -0
  108. package/src/layout/dot/mincross-cross.ts +2 -0
  109. package/src/layout/dot/rank.ts +2 -0
  110. package/src/layout/dot/splines.ts +2 -1
  111. package/src/layout/fdp/index.ts +2 -1
  112. package/src/layout/fdp/tlayout-parms.ts +9 -2
  113. package/src/layout/fdp/xlayout.ts +10 -2
  114. package/src/layout/neato/cdt-surface.ts +6 -6
  115. package/src/layout/neato/edge-len.ts +2 -1
  116. package/src/layout/neato/fdp-adjust.ts +3 -2
  117. package/src/layout/neato/init.ts +11 -11
  118. package/src/layout/neato/kk.ts +2 -1
  119. package/src/layout/neato/multispline-router.ts +3 -3
  120. package/src/layout/neato/multispline.ts +3 -2
  121. package/src/layout/neato/poly.ts +3 -2
  122. package/src/layout/neato/sgd.ts +5 -2
  123. package/src/layout/neato/start.ts +14 -10
  124. package/src/ortho/index.ts +3 -1
  125. package/src/parser/dot.js +4 -1
  126. package/src/parser/dot.pegjs +5 -1
  127. package/src/parser/index.ts +4 -3
  128. package/src/render/color-resolve.ts +193 -4
  129. package/src/render/public.ts +2 -0
  130. package/src/render/svg-edge-split.ts +4 -2
  131. 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.10",
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
 
@@ -260,40 +260,92 @@ export const HTML_ENTITIES: Readonly<Record<string, number>> = {
260
260
  "zwnj": 8204,
261
261
  };
262
262
 
263
- /** The five basic XML/HTML entities → code point. */
264
- const BASIC_ENTITIES: Readonly<Record<string, number>> = {
265
- amp: 0x26, lt: 0x3c, gt: 0x3e, quot: 0x22, apos: 0x27,
266
- };
263
+ /**
264
+ * Longest name htmlEntity looks up: its scan for the `;` stops after this
265
+ * many characters.
266
+ * @see lib/common/entities.h:ENTITY_NAME_LENGTH_MAX
267
+ */
268
+ const ENTITY_NAME_LENGTH_MAX = 8;
267
269
 
268
- /** Decode a numeric entity body (`#123`, `#x1F`) to its code point, or null. */
269
- function numericEntityValue(body: string): number | null {
270
- const hex = body[1] === 'x' || body[1] === 'X';
271
- const v = hex ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
272
- return Number.isFinite(v) && v > 0 && v <= 0x10ffff ? v : null;
270
+ /** htmlEntity's result: the value (0 for none) and where scanning resumes. */
271
+ interface EntityScan {
272
+ value: number;
273
+ next: number;
274
+ }
275
+
276
+ /**
277
+ * The `#` branch of htmlEntity, `at` on the `#`. It reads up to six decimal
278
+ * or five hex digits (`x` or `X`); the `;` must come within the first eight
279
+ * characters, else nothing is consumed. A `;` right after the digits, even
280
+ * none, is consumed with value `n`, so `&#;` keeps its `&` and loses `#;`.
281
+ * @see lib/common/utils.c:htmlEntity
282
+ */
283
+ function numericEntity(s: string, at: number): EntityScan {
284
+ const hex = s[at + 1] === 'x' || s[at + 1] === 'X';
285
+ const digit = hex ? /^[0-9A-Fa-f]$/ : /^[0-9]$/;
286
+ let n = 0;
287
+ let i = hex ? 2 : 1;
288
+ for (; i < 8; i++) {
289
+ const c = s[at + i] ?? '';
290
+ if (!digit.test(c)) break;
291
+ n = n * (hex ? 16 : 10) + parseInt(c, 16);
292
+ }
293
+ if (i < 8 && s[at + i] === ';') return { value: n, next: at + i + 1 };
294
+ return { value: 0, next: at };
295
+ }
296
+
297
+ /**
298
+ * The name branch of htmlEntity, `at` on the first name character: a `;`
299
+ * within ENTITY_NAME_LENGTH_MAX characters ends a name looked up in
300
+ * entities.h (case-sensitive; no `apos`).
301
+ * @see lib/common/utils.c:htmlEntity
302
+ */
303
+ function namedEntity(s: string, at: number): EntityScan {
304
+ for (let i = 0; i < ENTITY_NAME_LENGTH_MAX; i++) {
305
+ const c = s[at + i];
306
+ if (c === undefined || c === '\0') break;
307
+ if (c === ';') {
308
+ const key = s.slice(at, at + i);
309
+ if (Object.hasOwn(HTML_ENTITIES, key)) return { value: HTML_ENTITIES[key]!, next: at + i + 1 };
310
+ break;
311
+ }
312
+ }
313
+ return { value: 0, next: at };
273
314
  }
274
315
 
275
316
  /**
276
- * Decode one entity body (`amp`, `alpha`, `#123`, `#x1F`) to its code point, or
277
- * null for an unknown name. Named entities cover the five basic XML entities
278
- * plus the full HTML 4 table (Symbol etc.). @see lib/common/utils.c:htmlEntity
317
+ * Check for an HTML entity at `at`, the character after an `&`.
318
+ * @see lib/common/utils.c:htmlEntity
279
319
  */
280
- function entityValue(body: string): number | null {
281
- if (body[0] === '#') return numericEntityValue(body);
282
- return BASIC_ENTITIES[body] ?? HTML_ENTITIES[body] ?? null;
320
+ function htmlEntity(s: string, at: number): EntityScan {
321
+ return s[at] === '#' ? numericEntity(s, at) : namedEntity(s, at);
283
322
  }
284
323
 
285
324
  /**
286
- * Decode XML/numeric (&#NNN; / &#xHH;) and HTML 4 named (`&alpha;`) entities to
287
- * their characters, leaving unknown names literal — C's make_label UTF-8 branch.
288
- * Latin-1 inputs are decoded at the byte->string boundary by the caller (the
289
- * survey's render-one), so this UTF-8 branch covers them too.
290
- * @see lib/common/utils.c:htmlEntityUTF8 / htmlEntity
325
+ * Substitute entities like `&#123;` and `&amp;` with their characters: C's
326
+ * make_label UTF-8 branch. An `&` whose entity has no value stays (scanning
327
+ * resumes where htmlEntity left it). Latin-1 inputs are decoded at the
328
+ * byte->string boundary by the caller (the survey's render-one), so this
329
+ * covers them too, as does latin1ToUTF8's identical entity handling.
330
+ *
331
+ * C appends a value's bytes itself: below 0x7F one byte, below 0x7FF two,
332
+ * else three, truncated to `char`. For every value whose bytes are valid
333
+ * UTF-8 that is the character itself. 0x7F, 0x7FF, surrogates and values
334
+ * above 0xFFFF come out as invalid UTF-8, which a JS string cannot hold; the
335
+ * port keeps the character there (documented divergence).
336
+ * @see lib/common/utils.c:htmlEntityUTF8
337
+ * @see lib/common/utils.c:latin1ToUTF8
291
338
  * @see lib/common/labels.c:make_label (CHAR_LATIN1 vs default)
292
339
  */
293
340
  export function htmlEntityUTF8(s: string): string {
294
- if (s.indexOf('&') < 0) return s;
295
- return s.replace(/&(#x[0-9a-fA-F]+|#[0-9]+|[A-Za-z]+);/g, (m, body: string) => {
296
- const v = entityValue(body);
297
- return v === null ? m : String.fromCodePoint(v);
298
- });
341
+ let amp = s.indexOf('&');
342
+ if (amp < 0) return s;
343
+ let out = '';
344
+ let from = 0;
345
+ for (; amp >= 0; amp = s.indexOf('&', from)) {
346
+ const e = htmlEntity(s, amp + 1);
347
+ out += s.slice(from, amp) + (e.value ? String.fromCodePoint(e.value) : '&');
348
+ from = e.next;
349
+ }
350
+ return out + s.slice(from);
299
351
  }
@@ -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.
@@ -12,7 +12,8 @@
12
12
  * @see lib/common/htmllex.c:characterData
13
13
  */
14
14
 
15
- import { htmlEntityUTF8 } from './html-entities.js';
15
+ import { HTML_ENTITIES } from './html-entities.js';
16
+ import { charRefNumber } from './htmltable-lex-attvalue.js';
16
17
  import type { TagChunk } from './htmltable-lex-expat.js';
17
18
  import type { Token } from './htmltable-types.js';
18
19
 
@@ -77,15 +78,48 @@ function cleanText(decoded: string) {
77
78
  return decoded.replace(/[\x00-\x1f]/g, '');
78
79
  }
79
80
 
81
+ /** expat's predefined entities: no DTD declares others. */
82
+ const XML_PREDEFINED: Readonly<Record<string, string>> = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
83
+
84
+ /** Shortest and longest name scanEntity looks up. @see lib/common/utils.c:scanEntity */
85
+ const SCAN_ENTITY_MIN = 2;
86
+ const SCAN_ENTITY_MAX = 8;
87
+
88
+ const TEXT_REF = /&(#x[0-9A-Fa-f]+|#[0-9]+|[^&;]+);/g;
89
+
90
+ /** One reference of a text run, as scanEntity then expat leave it. */
91
+ function textRefValue(ref: string, body: string): string {
92
+ if (body[0] === '#') {
93
+ const v = charRefNumber(ref);
94
+ return v < 0 ? ref : String.fromCodePoint(v);
95
+ }
96
+ const n = body.length;
97
+ if (n >= SCAN_ENTITY_MIN && n <= SCAN_ENTITY_MAX && Object.hasOwn(HTML_ENTITIES, body)) {
98
+ return String.fromCodePoint(HTML_ENTITIES[body]!);
99
+ }
100
+ return Object.hasOwn(XML_PREDEFINED, body) ? XML_PREDEFINED[body]! : ref;
101
+ }
102
+
103
+ /**
104
+ * The characters expat hands characterData for a text run: scanEntity first
105
+ * rewrites each entities.h name to `&#N;` (its `&` only when no `#` follows),
106
+ * then expat decodes character references, of any length, and its five
107
+ * predefined entities. Runs expat rejects never get here (textRefError).
108
+ * @see lib/common/htmllex.c:findNext
109
+ * @see lib/common/utils.c:scanEntity
110
+ */
111
+ export function decodeTextRefs(raw: string): string {
112
+ return raw.indexOf('&') < 0 ? raw : raw.replace(TEXT_REF, textRefValue);
113
+ }
114
+
80
115
  /** The text token of a text run, if anything is left after cleaning. */
81
116
  export function textTokens(raw: string): Token[] {
82
- // Decode XML/HTML entities in the text run. C's HTML lexer resolves entities
83
- // via expat during tokenization (htmllex.c), so both sizing and emit see the
84
- // decoded UTF-8 text. Skipping this measured the raw "&#91;el..." (9-10 chars)
85
- // instead of "[el..." (6 chars), inflating edge-label vnodes and shifting
86
- // layout (corpus 1949: +18.7px). Plain-string labels already decode via the
87
- // make_label path, so only HTML-delimited labels diverged.
88
- return charDataTokens(htmlEntityUTF8(raw));
117
+ // C's HTML lexer resolves entities via scanEntity and expat during
118
+ // tokenization (htmllex.c), so both sizing and emit see the decoded text.
119
+ // Skipping this measured the raw "&#91;el..." (9-10 chars) instead of
120
+ // "[el..." (6 chars), inflating edge-label vnodes and shifting layout
121
+ // (corpus 1949: +18.7px).
122
+ return charDataTokens(decodeTextRefs(raw));
89
123
  }
90
124
 
91
125
  /** characterData on what expat decoded: a T_string when any character survives. */