@knowvah/dot-engine 1.8.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/README.md +232 -30
  2. package/dist/api/builder.d.ts +3 -0
  3. package/dist/api/builder.d.ts.map +1 -1
  4. package/dist/api/edge-ops.d.ts +7 -0
  5. package/dist/api/edge-ops.d.ts.map +1 -1
  6. package/dist/api/geometry.d.ts +5 -2
  7. package/dist/api/geometry.d.ts.map +1 -1
  8. package/dist/api.js +159 -31
  9. package/dist/api.js.map +3 -3
  10. package/dist/async/collect.d.ts +49 -0
  11. package/dist/async/collect.d.ts.map +1 -0
  12. package/dist/async/fonts.d.ts +20 -0
  13. package/dist/async/fonts.d.ts.map +1 -0
  14. package/dist/async/render-async.d.ts +91 -0
  15. package/dist/async/render-async.d.ts.map +1 -0
  16. package/dist/async/render-into.d.ts +38 -0
  17. package/dist/async/render-into.d.ts.map +1 -0
  18. package/dist/async/sanitize.d.ts +28 -0
  19. package/dist/async/sanitize.d.ts.map +1 -0
  20. package/dist/common/css-font.d.ts +9 -0
  21. package/dist/common/css-font.d.ts.map +1 -0
  22. package/dist/common/htmltable-types.d.ts +3 -3
  23. package/dist/common/htmltable-types.d.ts.map +1 -1
  24. package/dist/common/make-label.d.ts.map +1 -1
  25. package/dist/common/poly-shapes.d.ts.map +1 -1
  26. package/dist/common/textmeasure-factory.d.ts +2 -0
  27. package/dist/common/textmeasure-factory.d.ts.map +1 -1
  28. package/dist/common/textmeasure.d.ts +11 -1
  29. package/dist/common/textmeasure.d.ts.map +1 -1
  30. package/dist/common/utils-inputscale.d.ts +19 -0
  31. package/dist/common/utils-inputscale.d.ts.map +1 -0
  32. package/dist/errors.d.ts +61 -5
  33. package/dist/errors.d.ts.map +1 -1
  34. package/dist/gvc/context.d.ts +36 -4
  35. package/dist/gvc/context.d.ts.map +1 -1
  36. package/dist/gvc/device.d.ts +5 -2
  37. package/dist/gvc/device.d.ts.map +1 -1
  38. package/dist/gvc/image-resolver.d.ts +16 -16
  39. package/dist/gvc/image-resolver.d.ts.map +1 -1
  40. package/dist/gvc/job.d.ts +1 -9
  41. package/dist/gvc/job.d.ts.map +1 -1
  42. package/dist/gvc/usershape.d.ts +2 -12
  43. package/dist/gvc/usershape.d.ts.map +1 -1
  44. package/dist/index.d.ts +27 -8
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +7745 -5550
  47. package/dist/index.js.map +4 -4
  48. package/dist/label/index.d.ts.map +1 -1
  49. package/dist/label/node.d.ts +0 -6
  50. package/dist/label/node.d.ts.map +1 -1
  51. package/dist/label/rectangle.d.ts +1 -7
  52. package/dist/label/rectangle.d.ts.map +1 -1
  53. package/dist/layout/circo/circular.d.ts +8 -5
  54. package/dist/layout/circo/circular.d.ts.map +1 -1
  55. package/dist/layout/dot/pack-components.d.ts +0 -19
  56. package/dist/layout/dot/pack-components.d.ts.map +1 -1
  57. package/dist/layout/dot/position.d.ts +7 -2
  58. package/dist/layout/dot/position.d.ts.map +1 -1
  59. package/dist/layout/fdp/derive.d.ts.map +1 -1
  60. package/dist/layout/fdp/index.d.ts.map +1 -1
  61. package/dist/layout/fdp/init.d.ts.map +1 -1
  62. package/dist/layout/fdp/layout.d.ts.map +1 -1
  63. package/dist/layout/fdp/ports.d.ts +0 -10
  64. package/dist/layout/fdp/ports.d.ts.map +1 -1
  65. package/dist/layout/fdp/xlayout.d.ts +0 -16
  66. package/dist/layout/fdp/xlayout.d.ts.map +1 -1
  67. package/dist/layout/neato/adjust-info.d.ts +117 -0
  68. package/dist/layout/neato/adjust-info.d.ts.map +1 -0
  69. package/dist/layout/neato/cdt-surface.d.ts.map +1 -1
  70. package/dist/layout/neato/constraint-adjust.d.ts +40 -0
  71. package/dist/layout/neato/constraint-adjust.d.ts.map +1 -0
  72. package/dist/layout/neato/edge-len.d.ts +20 -0
  73. package/dist/layout/neato/edge-len.d.ts.map +1 -0
  74. package/dist/layout/neato/fdp-adjust.d.ts +32 -4
  75. package/dist/layout/neato/fdp-adjust.d.ts.map +1 -1
  76. package/dist/layout/neato/index.d.ts +9 -6
  77. package/dist/layout/neato/index.d.ts.map +1 -1
  78. package/dist/layout/neato/init.d.ts +4 -11
  79. package/dist/layout/neato/init.d.ts.map +1 -1
  80. package/dist/layout/neato/kk-paths.d.ts +50 -0
  81. package/dist/layout/neato/kk-paths.d.ts.map +1 -0
  82. package/dist/layout/neato/kk-solve.d.ts +14 -0
  83. package/dist/layout/neato/kk-solve.d.ts.map +1 -0
  84. package/dist/layout/neato/kk.d.ts +44 -0
  85. package/dist/layout/neato/kk.d.ts.map +1 -0
  86. package/dist/layout/neato/multispline-router.d.ts.map +1 -1
  87. package/dist/layout/neato/poly.d.ts +50 -0
  88. package/dist/layout/neato/poly.d.ts.map +1 -0
  89. package/dist/layout/neato/sgd-dijkstra.d.ts +20 -0
  90. package/dist/layout/neato/sgd-dijkstra.d.ts.map +1 -0
  91. package/dist/layout/neato/sgd.d.ts +14 -9
  92. package/dist/layout/neato/sgd.d.ts.map +1 -1
  93. package/dist/layout/neato/start.d.ts +51 -0
  94. package/dist/layout/neato/start.d.ts.map +1 -0
  95. package/dist/layout/neato/vpsc-adjust.d.ts +21 -0
  96. package/dist/layout/neato/vpsc-adjust.d.ts.map +1 -0
  97. package/dist/layout/sfdp/index.d.ts.map +1 -1
  98. package/dist/layout/sfdp/init.d.ts +0 -9
  99. package/dist/layout/sfdp/init.d.ts.map +1 -1
  100. package/dist/layout/sfdp/spring-driver.d.ts.map +1 -1
  101. package/dist/layout/twopi/circle.d.ts +0 -7
  102. package/dist/layout/twopi/circle.d.ts.map +1 -1
  103. package/dist/ortho/ortho-parallel.d.ts.map +1 -1
  104. package/dist/ortho/trap-query.d.ts.map +1 -1
  105. package/dist/parser/index.d.ts +9 -5
  106. package/dist/parser/index.d.ts.map +1 -1
  107. package/dist/render/index.d.ts +2 -0
  108. package/dist/render/index.d.ts.map +1 -1
  109. package/dist/render/public.d.ts +9 -3
  110. package/dist/render/public.d.ts.map +1 -1
  111. package/dist/render/xdot-public.d.ts +7 -2
  112. package/dist/render/xdot-public.d.ts.map +1 -1
  113. package/dist/render.js +7548 -5588
  114. package/dist/render.js.map +4 -4
  115. package/dist/util/xml.d.ts.map +1 -1
  116. package/dist/vpsc/Solver.d.ts +1 -0
  117. package/dist/vpsc/Solver.d.ts.map +1 -1
  118. package/package.json +1 -1
  119. package/src/api/builder.ts +73 -5
  120. package/src/api/edge-ops.ts +19 -0
  121. package/src/api/geometry.ts +22 -5
  122. package/src/async/collect.ts +204 -0
  123. package/src/async/fonts.ts +61 -0
  124. package/src/async/render-async.ts +205 -0
  125. package/src/async/render-into.ts +115 -0
  126. package/src/async/sanitize.ts +150 -0
  127. package/src/common/css-font.ts +109 -0
  128. package/src/common/htmltable-types.ts +4 -5
  129. package/src/common/make-label.ts +10 -1
  130. package/src/common/poly-shapes.ts +4 -1
  131. package/src/common/textmeasure-factory.ts +11 -0
  132. package/src/common/textmeasure.ts +41 -8
  133. package/src/common/utils-inputscale.ts +31 -0
  134. package/src/errors.ts +188 -5
  135. package/src/gvc/context.ts +96 -17
  136. package/src/gvc/device.ts +11 -6
  137. package/src/gvc/image-resolver.ts +25 -2
  138. package/src/gvc/job.ts +3 -1
  139. package/src/gvc/usershape.ts +6 -0
  140. package/src/index.ts +59 -43
  141. package/src/label/index.ts +6 -2
  142. package/src/label/node.ts +2 -1
  143. package/src/label/rectangle.ts +4 -2
  144. package/src/layout/circo/circular.ts +9 -6
  145. package/src/layout/dot/pack-components.ts +6 -2
  146. package/src/layout/dot/position.ts +21 -3
  147. package/src/layout/fdp/derive.ts +7 -6
  148. package/src/layout/fdp/index.ts +45 -5
  149. package/src/layout/fdp/init.ts +7 -6
  150. package/src/layout/fdp/layout.ts +2 -1
  151. package/src/layout/fdp/ports.ts +3 -2
  152. package/src/layout/fdp/xlayout.ts +3 -1
  153. package/src/layout/neato/adjust-info.ts +332 -0
  154. package/src/layout/neato/cdt-surface.ts +50 -30
  155. package/src/layout/neato/constraint-adjust.ts +466 -0
  156. package/src/layout/neato/edge-len.ts +36 -0
  157. package/src/layout/neato/fdp-adjust.ts +117 -12
  158. package/src/layout/neato/index.ts +36 -54
  159. package/src/layout/neato/init.ts +21 -36
  160. package/src/layout/neato/kk-paths.ts +146 -0
  161. package/src/layout/neato/kk-solve.ts +64 -0
  162. package/src/layout/neato/kk.ts +322 -0
  163. package/src/layout/neato/multispline-router.ts +4 -3
  164. package/src/layout/neato/poly.ts +493 -0
  165. package/src/layout/neato/sgd-dijkstra.ts +125 -0
  166. package/src/layout/neato/sgd.ts +63 -40
  167. package/src/layout/neato/start.ts +222 -0
  168. package/src/layout/neato/vpsc-adjust.ts +93 -0
  169. package/src/layout/sfdp/index.ts +4 -5
  170. package/src/layout/sfdp/init.ts +40 -4
  171. package/src/layout/sfdp/spring-driver.ts +30 -1
  172. package/src/layout/twopi/circle.ts +2 -1
  173. package/src/ortho/ortho-parallel.ts +2 -1
  174. package/src/ortho/trap-query.ts +2 -1
  175. package/src/parser/index.ts +11 -11
  176. package/src/render/index.ts +5 -0
  177. package/src/render/public.ts +24 -24
  178. package/src/render/svg.ts +1 -1
  179. package/src/render/xdot-public.ts +19 -18
  180. package/src/util/xml.ts +24 -30
  181. package/src/vpsc/Solver.ts +5 -3
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { type FontFamilyData } from "./textmeasure-lut-data.js";
11
11
  import { getFamilyMetrics, normalizeFontName } from "./textmeasure-lookup.js";
12
+ import { canvasFont } from "./css-font.js";
12
13
 
13
14
  /** Number of hard-coded font families in the LUT. */
14
15
  export const LUT_FAMILY_COUNT = 11;
@@ -264,12 +265,26 @@ export class LutTextMeasurer implements TextMeasurer {
264
265
  }
265
266
  }
266
267
 
268
+ /**
269
+ * Two distinct, always-valid fonts used to detect a rejected `ctx.font`
270
+ * assignment: a candidate is accepted when assigning it changes the read-back
271
+ * after either probe (it cannot serialize identically to both).
272
+ */
273
+ const FONT_PROBES: readonly string[] = ['1px serif', '2px monospace'];
274
+
267
275
  /**
268
276
  * Canvas-based TextMeasurer via CanvasRenderingContext2D.
269
- * Height is fontsize (matches C behavior).
277
+ * The font is the face the SVG emitter renders with (canvasFont), so browser
278
+ * measurement and rendering agree. Height is fontsize (matches C behavior).
270
279
  * @see lib/common/textspan.c:estimate_textspan_size
271
280
  */
272
281
  export class CanvasTextMeasurer implements TextMeasurer {
282
+ /**
283
+ * fontname|fontsize|bold|italic → accepted font string. Mirrors pango's
284
+ * font reuse (plugin/pango/gvtextlayout_pango.c:99-100).
285
+ */
286
+ private readonly fonts = new Map<string, string>();
287
+
273
288
  constructor(private readonly ctx: CanvasRenderingContext2D) {}
274
289
 
275
290
  measure(
@@ -278,16 +293,34 @@ export class CanvasTextMeasurer implements TextMeasurer {
278
293
  fontsize: number,
279
294
  flags?: TextVariantFlags,
280
295
  ): TextSize {
281
- const bold = flags?.bold === true;
282
- const italic = flags?.italic === true;
283
- const style = bold && italic ? 'bold italic'
284
- : bold ? 'bold'
285
- : italic ? 'italic'
286
- : '';
287
- this.ctx.font = style ? `${style} ${fontsize}px ${fontname}` : `${fontsize}px ${fontname}`;
296
+ this.ctx.font = this.fontFor(fontname, fontsize, flags);
288
297
  const m = this.ctx.measureText(text);
289
298
  return { w: m.width, h: fontsize };
290
299
  }
300
+
301
+ /** Cached font string; built and validated once per distinct font. */
302
+ private fontFor(fontname: string, fontsize: number, flags?: TextVariantFlags): string {
303
+ const key = `${fontname}|${fontsize}|${flags?.bold === true}|${flags?.italic === true}`;
304
+ let font = this.fonts.get(key);
305
+ if (font === undefined) {
306
+ font = canvasFont(fontname, fontsize, flags);
307
+ // A string the browser rejects is silently ignored, keeping the previous
308
+ // font AND size; fall back to the default family at the requested size.
309
+ if (!this.accepts(font)) font = canvasFont(null, fontsize, flags);
310
+ this.fonts.set(key, font);
311
+ }
312
+ return font;
313
+ }
314
+
315
+ /** True when the context accepts `font` as a `ctx.font` value. */
316
+ private accepts(font: string): boolean {
317
+ return FONT_PROBES.some((probe) => {
318
+ this.ctx.font = probe;
319
+ const before = this.ctx.font;
320
+ this.ctx.font = font;
321
+ return this.ctx.font !== before;
322
+ });
323
+ }
291
324
  }
292
325
 
293
326
  /** graphviz estimate_textspan_size line spacing. @see lib/common/const.h:70 */
@@ -0,0 +1,31 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+ import type { Graph } from '../model/graph.js';
3
+ import { lateDouble } from './nodeinit.js';
4
+
5
+ /** C's POINTS_PER_INCH, the value `inputscale=0` resolves to. */
6
+ const POINTS_PER_INCH = 72;
7
+
8
+ /**
9
+ * Effective inputscale for a layout run (C's PSinputscale as set by
10
+ * get_inputscale). The library has no CLI `-s` flag, so the "command line
11
+ * flag prevails" branch (`PSinputscale > 0`) is absent. Absent/unparseable
12
+ * gives -1 (no scaling); 0 and negatives (clamped to 0 by late_double's
13
+ * minimum) give 72. Callers scale only when the result is > 0.
14
+ * @see lib/common/utils.c:get_inputscale
15
+ */
16
+ export function getInputscale(g: Graph): number {
17
+ const d = lateDouble(g.root.attrs.get('inputscale'), -1, 0);
18
+ return d === 0 ? POINTS_PER_INCH : d;
19
+ }
20
+
21
+ /**
22
+ * Divisor for `x /= PSinputscale` guarded by `if (PSinputscale > 0)`:
23
+ * the inputscale when positive, else 1 (no scaling).
24
+ * @see lib/neatogen/neatoinit.c:user_pos
25
+ * @see lib/fdpgen/fdpinit.c:initialPositions
26
+ * @see lib/fdpgen/layout.c:chkPos
27
+ */
28
+ export function inputscaleDivisor(g: Graph): number {
29
+ const sc = getInputscale(g);
30
+ return sc > 0 ? sc : 1;
31
+ }
package/src/errors.ts CHANGED
@@ -27,6 +27,9 @@ export type GvErrorCode =
27
27
  | 'EDGE_OP_UNDIRECTED_IN_DIRECTED' // '--' used in a digraph
28
28
  | 'HTML_PARSE_ERROR' // HTML-like label parse failure
29
29
  | 'RENDER_ERROR' // known layout/render-stage failure
30
+ | 'INTERNAL_ERROR' // dot-engine bug (assert / invariant / foreign throw)
31
+ | 'UNKNOWN_LAYOUT' // graph layout="..." names no registered engine
32
+ | 'UNSUPPORTED_FEATURE' // graph uses a Graphviz feature not yet ported
30
33
  | 'GENERIC_ERROR'; // catch-all fallback
31
34
 
32
35
  /** Structured error contract shared by every error source. */
@@ -66,6 +69,11 @@ export const FRIENDLY_MESSAGES: Record<GvErrorCode, string> = {
66
69
  "An undirected edge '--' was used in a directed graph; use '->' instead.",
67
70
  HTML_PARSE_ERROR: 'An HTML-like label could not be parsed.',
68
71
  RENDER_ERROR: 'The graph could not be laid out or rendered.',
72
+ INTERNAL_ERROR:
73
+ 'dot-engine hit an internal bug while processing the graph. Please report it with the DOT source that triggered it.',
74
+ UNKNOWN_LAYOUT: 'The graph names a layout engine that is not available.',
75
+ UNSUPPORTED_FEATURE:
76
+ 'The graph uses a Graphviz feature that dot-engine does not support yet.',
69
77
  GENERIC_ERROR: 'An unexpected error occurred while rendering the graph.',
70
78
  };
71
79
 
@@ -77,19 +85,194 @@ export function friendlyMessageFor(code: GvErrorCode): string {
77
85
  return FRIENDLY_MESSAGES[code];
78
86
  }
79
87
 
88
+ /** Codes a {@link RenderError} types as `semantic` rather than `render`. */
89
+ const SEMANTIC_RENDER_CODES: ReadonlySet<GvErrorCode> = new Set<GvErrorCode>([
90
+ 'UNKNOWN_LAYOUT',
91
+ 'UNSUPPORTED_FEATURE',
92
+ ]);
93
+
80
94
  /**
81
- * Error thrown for known layout/render-stage failures. Only `RENDER_ERROR`
82
- * and `GENERIC_ERROR` are valid render-stage codes.
95
+ * Abstract base of every library-originated error: `instanceof
96
+ * DotEngineError` means dot-engine failed on this input (bad input, a fatal
97
+ * error Graphviz itself would report, or a dot-engine bug). Each subclass
98
+ * sets a hard-coded `name`.
83
99
  */
84
- export class RenderError extends Error implements GvError {
100
+ export abstract class DotEngineError extends Error implements GvError {
101
+ abstract readonly type: GvErrorType;
102
+ abstract readonly code: GvErrorCode;
103
+ abstract readonly friendlyMessage: string;
104
+
105
+ constructor(message: string, options?: ErrorOptions) {
106
+ super(message, options);
107
+ }
108
+ }
109
+
110
+ /** A dot-engine bug: failed assertion, broken invariant or foreign throw. */
111
+ export class InternalError extends DotEngineError {
85
112
  readonly type = 'render';
113
+ readonly code = 'INTERNAL_ERROR';
114
+ readonly friendlyMessage = friendlyMessageFor('INTERNAL_ERROR');
115
+
116
+ constructor(message: string, options?: ErrorOptions) {
117
+ super(message, options);
118
+ this.name = 'InternalError';
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Error thrown for known layout/render-stage failures. `type` is `semantic`
124
+ * for `UNKNOWN_LAYOUT` / `UNSUPPORTED_FEATURE`, otherwise `render`.
125
+ */
126
+ export class RenderError extends DotEngineError {
127
+ readonly type: GvErrorType;
86
128
  readonly code: GvErrorCode;
87
129
  readonly friendlyMessage: string;
88
130
 
89
- constructor(message: string, code: GvErrorCode = 'RENDER_ERROR') {
90
- super(message);
131
+ constructor(
132
+ message: string,
133
+ code: GvErrorCode = 'RENDER_ERROR',
134
+ options?: ErrorOptions,
135
+ ) {
136
+ super(message, options);
91
137
  this.name = 'RenderError';
92
138
  this.code = code;
139
+ this.type = SEMANTIC_RENDER_CODES.has(code) ? 'semantic' : 'render';
93
140
  this.friendlyMessage = friendlyMessageFor(code);
94
141
  }
95
142
  }
143
+
144
+ /** Guard shared by every boundary: structural check, works across bundles. */
145
+ export function isGvError(e: unknown): e is GvError {
146
+ if (typeof e !== 'object' || e === null) return false;
147
+ const c = e as { type?: unknown; code?: unknown };
148
+ return typeof c.type === 'string' && typeof c.code === 'string';
149
+ }
150
+
151
+ // ── Usage errors (caller mistakes; not DotEngineError, not GvError) ─────────
152
+
153
+ /** Node-style codes carried by caller-mistake errors. */
154
+ export type UsageErrorCode =
155
+ | 'ERR_INVALID_ARG_TYPE'
156
+ | 'ERR_INVALID_ARG_VALUE'
157
+ | 'ERR_OUT_OF_RANGE'
158
+ | 'ERR_INVALID_STATE';
159
+
160
+ /** TypeError carrying a usage code; `name` stays `TypeError`. */
161
+ export class UsageTypeError extends TypeError {
162
+ readonly code: UsageErrorCode;
163
+
164
+ constructor(message: string, code: UsageErrorCode) {
165
+ super(message);
166
+ this.code = code;
167
+ }
168
+ }
169
+
170
+ /** RangeError carrying a usage code; `name` stays `RangeError`. */
171
+ export class UsageRangeError extends RangeError {
172
+ readonly code: UsageErrorCode;
173
+
174
+ constructor(message: string, code: UsageErrorCode) {
175
+ super(message);
176
+ this.code = code;
177
+ }
178
+ }
179
+
180
+ /** Error carrying a usage code; `name` stays `Error`. */
181
+ export class UsageStateError extends Error {
182
+ readonly code: UsageErrorCode;
183
+
184
+ constructor(message: string, code: UsageErrorCode) {
185
+ super(message);
186
+ this.code = code;
187
+ }
188
+ }
189
+
190
+ /** Short description of a received value for usage-error messages. */
191
+ function describeReceived(v: unknown): string {
192
+ if (v === null || v === undefined) return String(v);
193
+ switch (typeof v) {
194
+ case 'string':
195
+ return `string ${JSON.stringify(v)}`;
196
+ case 'number':
197
+ case 'boolean':
198
+ case 'bigint':
199
+ return `${typeof v} ${String(v)}`;
200
+ case 'function':
201
+ return 'a function';
202
+ case 'symbol':
203
+ return 'a symbol';
204
+ default: {
205
+ const ctor = (v as { constructor?: { name?: unknown } }).constructor;
206
+ return typeof ctor?.name === 'string' && ctor.name !== ''
207
+ ? `an instance of ${ctor.name}`
208
+ : 'an object';
209
+ }
210
+ }
211
+ }
212
+
213
+ /** Wrong type, `null` or a missing required argument. */
214
+ export function invalidArgType(
215
+ param: string,
216
+ expected: string,
217
+ actual: unknown,
218
+ ): TypeError {
219
+ return new UsageTypeError(
220
+ `The "${param}" argument must be of type ${expected}. Received ${describeReceived(actual)}`,
221
+ 'ERR_INVALID_ARG_TYPE',
222
+ );
223
+ }
224
+
225
+ /** Unknown engine/format name (or other enum-like value) as an argument. */
226
+ export function invalidArgValue(
227
+ param: string,
228
+ value: unknown,
229
+ allowed: readonly string[],
230
+ ): TypeError {
231
+ return new UsageTypeError(
232
+ `The argument "${param}" is invalid. Received ${JSON.stringify(value)}; allowed: ${allowed.join(', ')}`,
233
+ 'ERR_INVALID_ARG_VALUE',
234
+ );
235
+ }
236
+
237
+ /** Numeric argument outside its range. */
238
+ export function outOfRange(
239
+ param: string,
240
+ range: string,
241
+ value: number,
242
+ ): RangeError {
243
+ return new UsageRangeError(
244
+ `The value of "${param}" is out of range. It must be ${range}. Received ${String(value)}`,
245
+ 'ERR_OUT_OF_RANGE',
246
+ );
247
+ }
248
+
249
+ /** Call made in the wrong state (e.g. `getLayout` before layout). */
250
+ export function invalidState(message: string): Error {
251
+ return new UsageStateError(message, 'ERR_INVALID_STATE');
252
+ }
253
+
254
+ /** True for errors built by the usage-error factories. */
255
+ export function isUsageError(e: unknown): boolean {
256
+ return (
257
+ e instanceof UsageTypeError ||
258
+ e instanceof UsageRangeError ||
259
+ e instanceof UsageStateError
260
+ );
261
+ }
262
+
263
+ // ── Public-boundary catch (shared by every entry point; not re-exported) ────
264
+
265
+ /** Message of any thrown value. */
266
+ export function messageOf(err: unknown): string {
267
+ return err instanceof Error ? err.message : String(err);
268
+ }
269
+
270
+ /**
271
+ * Boundary `catch` (ADR-5): usage errors and {@link GvError}s re-surface
272
+ * unchanged; anything else is a dot-engine bug and becomes an
273
+ * {@link InternalError} keeping the original as `cause`.
274
+ */
275
+ export function rethrowAtBoundary(err: unknown): never {
276
+ if (isUsageError(err) || isGvError(err)) throw err;
277
+ throw new InternalError(messageOf(err), { cause: err });
278
+ }
@@ -23,7 +23,10 @@ import type { Point, Box } from '../model/geom.js';
23
23
  import type { TextSpan } from '../common/emit-types.js';
24
24
  import type { TextMeasurer } from '../common/textmeasure.js';
25
25
  import type { DebugOptions } from '../debug.js';
26
+ import type { ImageSizer } from '../common/htmltable-types.js';
27
+ import type { ImageResolver } from './image-resolver.js';
26
28
  import type { RenderJob } from './job.js'; // scaffold in T25; full class in T26
29
+ import { RenderError, invalidArgType, invalidArgValue } from '../errors.js';
27
30
 
28
31
  // ---------------------------------------------------------------------------
29
32
  // Enums — match C definitions in lib/gvc/gvcjob.h exactly
@@ -165,6 +168,24 @@ class PluginRegistry {
165
168
  }
166
169
  }
167
170
 
171
+ /** Argument check for a TextMeasurer parameter (ADR-3). */
172
+ function checkMeasurer(m: unknown): void {
173
+ if (typeof m !== 'object' || m === null
174
+ || typeof (m as { measure?: unknown }).measure !== 'function') {
175
+ throw invalidArgType('measurer', 'TextMeasurer', m);
176
+ }
177
+ }
178
+
179
+ /** Argument check for `register` (ADR-3): shape of a plugin, not its behaviour. */
180
+ function checkPlugin(p: unknown): void {
181
+ const o = p as { type?: unknown; quality?: unknown; layout?: unknown; cleanup?: unknown };
182
+ const ok = typeof p === 'object' && p !== null && typeof o.type === 'string'
183
+ && ('quality' in o
184
+ ? typeof o.quality === 'number'
185
+ : typeof o.layout === 'function' && typeof o.cleanup === 'function');
186
+ if (!ok) throw invalidArgType('p', 'RendererPlugin or LayoutEngine', p);
187
+ }
188
+
168
189
  /**
169
190
  * Root Graphviz context. Owns the plugin registry, text measurer, and
170
191
  * the layout-engine dispatch.
@@ -180,15 +201,40 @@ export class GvcContext {
180
201
  textMeasurer: TextMeasurer;
181
202
  readonly debug: DebugOptions | undefined;
182
203
 
204
+ /**
205
+ * Per-context HTML `<IMG>` sizer; takes precedence over the global
206
+ * `setImageSizer`. Undefined by default. @see ADR-2 (async-api)
207
+ */
208
+ imageSizer?: ImageSizer;
209
+ /**
210
+ * Per-context image resolver for `inlineImages`; takes precedence over the
211
+ * global `setImageResolver`. Undefined by default. @see ADR-2 (async-api)
212
+ */
213
+ imageResolver?: ImageResolver;
214
+
215
+ /**
216
+ * @throws TypeError `ERR_INVALID_ARG_TYPE` if `measurer` has no `measure`
217
+ * function or `options` is neither undefined nor an object
218
+ */
183
219
  constructor(measurer: TextMeasurer, options?: { debug?: DebugOptions }) {
220
+ checkMeasurer(measurer);
221
+ if (options !== undefined && (typeof options !== 'object' || options === null)) {
222
+ throw invalidArgType('options', 'object or undefined', options);
223
+ }
184
224
  this.textMeasurer = measurer;
185
225
  this.debug = options?.debug;
186
226
  }
187
227
 
188
- /** Register a renderer or layout engine; overload discriminated by `quality`. */
228
+ /**
229
+ * Register a renderer or layout engine; overload discriminated by `quality`.
230
+ * @throws TypeError `ERR_INVALID_ARG_TYPE` if `p` is not a renderer plugin
231
+ * (string `type`, numeric `quality`) or a layout engine (string `type`,
232
+ * `layout` and `cleanup` functions)
233
+ */
189
234
  register(p: RendererPlugin): void;
190
235
  register(p: LayoutEngine): void;
191
236
  register(p: RendererPlugin | LayoutEngine): void {
237
+ checkPlugin(p);
192
238
  if ('quality' in p) {
193
239
  const idx = PluginRegistry.insertionIdx(this.renderers, p);
194
240
  this.renderers.splice(idx, 0, p);
@@ -202,14 +248,35 @@ export class GvcContext {
202
248
  * Because renderers are sorted quality-descending within a prefix, the first
203
249
  * match is always the highest-quality one (last-registered wins on tie).
204
250
  *
205
- * @throws Error if no renderer is registered for format
251
+ * @throws TypeError (ERR_INVALID_ARG_TYPE) if format is not a string
252
+ * @throws TypeError (ERR_INVALID_ARG_VALUE) if no renderer is registered
253
+ * for format
206
254
  * @see lib/gvc/gvplugin.c:gvplugin_find
207
255
  */
208
256
  bestRenderer(format: string): RendererPlugin {
257
+ if (typeof format !== 'string') {
258
+ throw invalidArgType('format', 'string', format);
259
+ }
209
260
  for (const r of this.renderers) {
210
261
  if (r.type.split(':')[0] === format) return r;
211
262
  }
212
- throw new Error(`no renderer registered for format: ${format}`);
263
+ const prefixes = new Set(this.renderers.map((r) => r.type.split(':')[0]!));
264
+ throw invalidArgValue('format', format, [...prefixes]);
265
+ }
266
+
267
+ /** Validate the `(g, engineName)` arguments shared by layout/freeLayout. */
268
+ private checkLayoutArgs(g: Graph, engineName: EngineName): LayoutEngine {
269
+ if (typeof g !== 'object' || g === null) {
270
+ throw invalidArgType('g', 'object', g);
271
+ }
272
+ if (typeof engineName !== 'string') {
273
+ throw invalidArgType('engine', 'string', engineName);
274
+ }
275
+ const engine = this.layouts.get(engineName);
276
+ if (engine === undefined) {
277
+ throw invalidArgValue('engine', engineName, [...this.layouts.keys()]);
278
+ }
279
+ return engine;
213
280
  }
214
281
 
215
282
  /**
@@ -218,27 +285,37 @@ export class GvcContext {
218
285
  * rendering happens in between and must see the layout state
219
286
  * (e.g. cluster arrays).
220
287
  *
221
- * @throws Error if the engine is not registered
288
+ * @throws TypeError (ERR_INVALID_ARG_TYPE / ERR_INVALID_ARG_VALUE) for a
289
+ * bad `g`, or an engine argument that is not registered
290
+ * @throws RenderError (UNKNOWN_LAYOUT) if the `layout` attribute names no
291
+ * registered engine
292
+ * @throws RenderError (RENDER_ERROR / UNSUPPORTED_FEATURE) or InternalError
293
+ * from the engine itself, unwrapped; a foreign throw from an engine bug
294
+ * also propagates unwrapped (only the public render functions wrap it)
222
295
  * @see lib/gvc/gvlayout.c:gvLayoutJobs
223
296
  */
224
297
  layout(g: Graph, engineName: EngineName): void {
298
+ const selected = this.checkLayoutArgs(g, engineName);
225
299
  // C gvLayoutJobs: the graph's `layout` ATTRIBUTE unconditionally
226
300
  // overrides the selected engine (-K / API choice); an unrecognized
227
301
  // value is an error, not a fallback. @see lib/gvc/gvlayout.c:66-73
228
302
  const attr = g.attrs?.get('layout'); // test doubles may lack attrs
229
- let name = engineName;
303
+ let engine = selected;
230
304
  if (attr !== undefined && attr !== '') {
231
- if (!this.layouts.has(attr as EngineName)) {
232
- throw new Error(`Layout type: "${attr}" not recognized`);
305
+ const override = this.layouts.get(attr);
306
+ if (override === undefined) {
307
+ throw new RenderError(
308
+ `Layout type: "${attr}" not recognized`,
309
+ 'UNKNOWN_LAYOUT',
310
+ );
233
311
  }
234
- name = attr as EngineName;
235
- }
236
- const engine = this.layouts.get(name);
237
- if (engine === undefined) {
238
- throw new Error(`no layout engine registered: ${name}`);
312
+ engine = override;
239
313
  }
240
314
  if (g.info) g.info.gvc = this;
241
315
  engine.layout(g);
316
+ // C: GD_cleanup(g) = gvle->cleanup — freeLayout must use the engine that
317
+ // actually ran, not its argument. @see lib/gvc/gvlayout.c:88
318
+ if (g.info) g.info.cleanup = (x: Graph): void => engine.cleanup(x);
242
319
  // Mark the graph laid-out so the public getLayout can reject a graph that
243
320
  // still carries calloc-zero geometry defaults.
244
321
  if (g.info) g.info.laidOut = true;
@@ -247,14 +324,16 @@ export class GvcContext {
247
324
  /**
248
325
  * Release engine layout state after rendering.
249
326
  *
250
- * @throws Error if the engine is not registered
327
+ * @throws TypeError (ERR_INVALID_ARG_TYPE / ERR_INVALID_ARG_VALUE) for a
328
+ * bad `g`, or an engine argument that is not registered
251
329
  * @see lib/gvc/gvlayout.c:gvFreeLayout
252
330
  */
253
331
  freeLayout(g: Graph, engineName: EngineName): void {
254
- const engine = this.layouts.get(engineName);
255
- if (engine === undefined) {
256
- throw new Error(`no layout engine registered: ${engineName}`);
332
+ this.checkLayoutArgs(g, engineName); // argument validation only
333
+ const cleanup = g.info?.cleanup;
334
+ if (cleanup) {
335
+ cleanup(g);
336
+ g.info.cleanup = undefined;
257
337
  }
258
- engine.cleanup(g);
259
338
  }
260
339
  }
package/src/gvc/device.ts CHANGED
@@ -18,8 +18,9 @@ import { parseDrawingSize, initJobViewportZoom, parseLandscape, parseGraphPad, p
18
18
  import type { Graph } from '../model/graph.js';
19
19
  import type { Node } from '../model/node.js';
20
20
  import type { Edge } from '../model/edge.js';
21
- import type { RendererPlugin, GvcContext } from './context.js';
22
- import { PenType } from './context.js';
21
+ import type { RendererPlugin } from './context.js';
22
+ import { GvcContext, PenType } from './context.js';
23
+ import { invalidArgType } from '../errors.js';
23
24
  import { gvrenderTextspan, withLabelEmitState } from './textspan-emit.js';
24
25
  import { resolveEdgeAnchor, resolveObjAnchor, beginAnchorIf } from './anchor.js';
25
26
  import type { ShapeDesc, TextlabelT } from '../common/types.js';
@@ -57,9 +58,8 @@ import { svgNodeId, svgEdgeId, svgClusterId, svgGraphId } from '../render/svg-id
57
58
  // ---------------------------------------------------------------------------
58
59
  // AD-1 (image-api): `inlineImages` on RenderJob via module augmentation
59
60
  //
60
- // Declared here (not as an edit to job.ts) so the field lives with the one
61
- // caller that sets it (render(), below) and the one caller that reads it
62
- // (svg.ts usershape()). Purely additive: an unset field reads `undefined`,
61
+ // Declared here (not in job.ts), beside render() which sets it; svg.ts
62
+ // usershape() reads it. Purely additive: an unset field reads `undefined`,
63
63
  // which is falsy, so any RenderJob built without going through this render()
64
64
  // (tests, other call sites) keeps today's raw-src passthrough unchanged.
65
65
  // @see src/render/public.ts:RenderOptions.inlineImages
@@ -536,13 +536,18 @@ function renderPage(g: Graph, renderer: RendererPlugin, job: RenderJob, info: La
536
536
  * (`setImageResolver`) and inlines a `data:` URI on a hit instead of the
537
537
  * raw src passthrough. Unset/false reproduces byte-identical pre-AD-1
538
538
  * output. @see src/render/public.ts:RenderOptions.inlineImages
539
- * @throws Error if no renderer is registered for format
539
+ * @throws TypeError `ERR_INVALID_ARG_TYPE` if `ctx` is not a GvcContext, `g`
540
+ * is not an object, or `format` is not a string
541
+ * @throws TypeError `ERR_INVALID_ARG_VALUE` if no renderer is registered for format
540
542
  * @see lib/gvc/gvrender.c:gvrender_select
541
543
  */
542
544
  export function render(ctx: GvcContext, g: Graph, format: string, inlineImages = false): string {
545
+ if (!(ctx instanceof GvcContext)) throw invalidArgType('ctx', 'GvcContext', ctx);
546
+ if (typeof g !== 'object' || g === null) throw invalidArgType('g', 'object', g);
543
547
  const renderer = ctx.bestRenderer(format);
544
548
  const job = new RenderJob(format, ctx.textMeasurer);
545
549
  job.inlineImages = inlineImages;
550
+ if (ctx.imageResolver !== undefined) job.imageResolver = ctx.imageResolver;
546
551
  // gvc->bb = GD_bb(g) verbatim -- no recompute fallback. Every layout engine
547
552
  // sets g.info.bb itself before render() runs (set_aspect for dot,
548
553
  // compute_bb-equivalent computeSubgraphBB calls in neato/circo/sfdp/fdp/
@@ -16,6 +16,8 @@
16
16
  * @see lib/gvc/gvusershape.c (ImageDict, gvusershape_find/size)
17
17
  */
18
18
 
19
+ import { invalidArgType } from '../errors.js';
20
+
19
21
  // ---------------------------------------------------------------------------
20
22
  // Resolver registry
21
23
  // ---------------------------------------------------------------------------
@@ -29,14 +31,30 @@ export type ImageResolver = (
29
31
  src: string,
30
32
  ) => { bytes: Uint8Array; mime?: string } | Uint8Array | null;
31
33
 
34
+ /**
35
+ * Per-render resolver on the render job (async-api ADR-2): device.ts render()
36
+ * copies `GvcContext.imageResolver` here; svg.ts usershape() passes it to
37
+ * findImageBytes, which then skips the global resolver.
38
+ */
39
+ declare module './job.js' {
40
+ interface RenderJob {
41
+ imageResolver?: ImageResolver;
42
+ }
43
+ }
44
+
32
45
  let activeResolver: ImageResolver | null = null;
33
46
 
34
47
  /**
35
48
  * Register (or clear, with null) the global image resolver consulted when
36
49
  * `RenderOptions.inlineImages` is set. Mirrors gvusershape's process-global
37
50
  * dictionary and `setImageSizer`'s registration shape.
51
+ * @throws TypeError `ERR_INVALID_ARG_TYPE` if `fn` is neither a function nor
52
+ * null
38
53
  */
39
54
  export function setImageResolver(fn: ImageResolver | null): void {
55
+ if (fn !== null && typeof fn !== 'function') {
56
+ throw invalidArgType('resolver', 'function or null', fn);
57
+ }
40
58
  activeResolver = fn;
41
59
  }
42
60
 
@@ -73,12 +91,17 @@ function inferMimeFromSrc(src: string): string {
73
91
  * when the resolver omits it). Returns `null` when no resolver is set or the
74
92
  * resolver itself returns `null` — the graceful-miss path that keeps the raw
75
93
  * `src` passthrough in `usershape()`.
94
+ *
95
+ * @param resolver - per-context resolver (ADR-2); when given it is consulted
96
+ * instead of the global one, with the same normalization.
76
97
  */
77
98
  export function findImageBytes(
78
99
  src: string,
100
+ resolver?: ImageResolver,
79
101
  ): { bytes: Uint8Array; mime: string } | null {
80
- if (activeResolver === null) return null;
81
- const result = activeResolver(src);
102
+ const active = resolver ?? activeResolver;
103
+ if (active === null) return null;
104
+ const result = active(src);
82
105
  if (result === null) return null;
83
106
  if (result instanceof Uint8Array) {
84
107
  return { bytes: result, mime: inferMimeFromSrc(src) };
package/src/gvc/job.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * @see lib/gvc/gvdevice.c:gvprintdouble
11
11
  */
12
12
 
13
+ import { InternalError } from '../errors.js';
13
14
  import type { Graph } from '../model/graph.js';
14
15
  import { FontnameKind } from '../model/layoutParams.js';
15
16
  import type { Node } from '../model/node.js';
@@ -361,11 +362,12 @@ export class RenderJob {
361
362
 
362
363
  /**
363
364
  * Pop the top object state from the stack.
365
+ * @see lib/common/emit.c:pop_obj_state (assert(obj) at emit.c:135)
364
366
  * @throws Error if the stack is empty.
365
367
  */
366
368
  popObj(): void {
367
369
  if (this.objStack.length === 0) {
368
- throw new Error('RenderJob.popObj: stack is empty');
370
+ throw new InternalError('RenderJob.popObj: stack is empty');
369
371
  }
370
372
  this.objStack.pop();
371
373
  }
@@ -13,6 +13,7 @@
13
13
  * @see lib/gvc/gvusershape.c (ImageDict, gvusershape_find/size)
14
14
  */
15
15
 
16
+ import { invalidArgType } from '../errors.js';
16
17
  import type { ImageSizer } from '../common/htmltable-types.js';
17
18
 
18
19
  export type { ImageSizer } from '../common/htmltable-types.js';
@@ -22,8 +23,13 @@ let activeSizer: ImageSizer | null = null;
22
23
  /**
23
24
  * Register (or clear, with null) the global image sizer consulted by
24
25
  * HTML <IMG> sizing. Mirrors gvusershape's process-global dictionary.
26
+ * @throws TypeError `ERR_INVALID_ARG_TYPE` if `sizer` is neither a function
27
+ * nor null
25
28
  */
26
29
  export function setImageSizer(sizer: ImageSizer | null): void {
30
+ if (sizer !== null && typeof sizer !== 'function') {
31
+ throw invalidArgType('sizer', 'function or null', sizer);
32
+ }
27
33
  activeSizer = sizer;
28
34
  }
29
35