@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
@@ -0,0 +1,205 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * Async render entry points (async-api ADR-1, 3, 4, 5, 7).
5
+ *
6
+ * Parse, collect the fonts and images the graph will request, await them,
7
+ * then run the unchanged synchronous `layout → render → freeLayout` on a fresh
8
+ * per-call context whose image hooks are sync closures over per-call Maps.
9
+ *
10
+ * @see src/render/public.ts:render
11
+ * @see src/index.ts:renderSvg
12
+ */
13
+
14
+ import type { Graph } from '../model/graph.js';
15
+ import { parse } from '../parser/index.js';
16
+ import { invalidArgType, rethrowAtBoundary } from '../errors.js';
17
+ import type { EngineName } from '../gvc/context.js';
18
+ import { createDefaultContext } from '../gvc/default-context.js';
19
+ import { render as deviceRender } from '../gvc/device.js';
20
+ import type { ImageSizer } from '../common/htmltable-types.js';
21
+ import type { ImageResolver } from '../gvc/image-resolver.js';
22
+ import type { OutputFormat, RenderOptions } from '../render/public.js';
23
+ import { collectResources } from './collect.js';
24
+ import { loadFonts } from './fonts.js';
25
+ import type { FontIssue, FontSetLike } from './fonts.js';
26
+
27
+ export type { FontIssue, FontSetLike } from './fonts.js';
28
+
29
+ /** Default font-load deadline in milliseconds (ADR-3). */
30
+ export const DEFAULT_FONT_TIMEOUT_MS = 3000;
31
+
32
+ /** Result of an async image-size lookup; `null` is a miss. */
33
+ export type AsyncImageSize = { w: number; h: number } | null;
34
+
35
+ /** Result of an async image-bytes lookup; `null` is a miss. */
36
+ export type AsyncImageBytes = { bytes: Uint8Array; mime?: string } | Uint8Array | null;
37
+
38
+ /** Options for {@link renderAsync}. */
39
+ export interface AsyncRenderOptions extends RenderOptions {
40
+ /** Async HTML `<IMG>` dimension lookup; a throw/reject counts as a miss. */
41
+ imageSizer?: (src: string) => Promise<AsyncImageSize>;
42
+ /** Async image bytes lookup for `inlineImages`; a throw/reject counts as a miss. */
43
+ imageResolver?: (src: string) => Promise<AsyncImageBytes>;
44
+ /** Font-load deadline in ms, shared by all faces. Default 3000. */
45
+ fontTimeoutMs?: number;
46
+ /** FontFaceSet-like to load faces from. Default `document.fonts` when present. */
47
+ fontSet?: FontSetLike;
48
+ }
49
+
50
+ /** Options for {@link renderSvgAsync}: {@link AsyncRenderOptions} minus `engine`. */
51
+ export type AsyncSvgOptions = Omit<AsyncRenderOptions, 'engine'>;
52
+
53
+ /** Resolved value of {@link renderAsync}. */
54
+ export interface AsyncRenderResult {
55
+ output: string;
56
+ fontIssues: FontIssue[];
57
+ }
58
+
59
+ /** Resolved value of {@link renderSvgAsync}. */
60
+ export interface AsyncSvgResult {
61
+ svg: string;
62
+ fontIssues: FontIssue[];
63
+ }
64
+
65
+ /** Reject a bad `g` / `format` / `opts` before any work starts (mirrors `render`). */
66
+ function checkRenderArgs(g: unknown, format: unknown, opts: unknown): void {
67
+ if (typeof g !== 'object' || g === null) throw invalidArgType('g', 'object', g);
68
+ if (typeof format !== 'string') throw invalidArgType('format', 'string', format);
69
+ if (opts !== undefined && (typeof opts !== 'object' || opts === null)) {
70
+ throw invalidArgType('opts', 'object or undefined', opts);
71
+ }
72
+ }
73
+
74
+ /** `document.fonts` when a document exposes one (absent in Node and Workers). */
75
+ function defaultFontSet(): FontSetLike | undefined {
76
+ const doc: unknown = Reflect.get(globalThis, 'document');
77
+ if (typeof doc !== 'object' || doc === null) return undefined;
78
+ const fonts: unknown = Reflect.get(doc, 'fonts');
79
+ if (typeof fonts !== 'object' || fonts === null) return undefined;
80
+ return fonts as FontSetLike; // structural: FontFaceSet.load
81
+ }
82
+
83
+ /** Call `hook` once per distinct src; a throw or reject is a `null` miss. */
84
+ async function prefetch<T>(
85
+ srcs: readonly string[],
86
+ hook: ((src: string) => Promise<T | null>) | undefined,
87
+ ): Promise<Map<string, T | null>> {
88
+ const out = new Map<string, T | null>();
89
+ if (hook === undefined) return out;
90
+ await Promise.all(
91
+ srcs.map(async (src) => {
92
+ try {
93
+ out.set(src, await hook(src));
94
+ } catch {
95
+ // ADR-5: a failing async hook is a miss, as in the sync hooks' null path.
96
+ out.set(src, null);
97
+ }
98
+ }),
99
+ );
100
+ return out;
101
+ }
102
+
103
+ /** Everything awaited before layout starts. */
104
+ interface Prefetched {
105
+ fontIssues: FontIssue[];
106
+ sizes: Map<string, AsyncImageSize>;
107
+ bytes: Map<string, AsyncImageBytes>;
108
+ }
109
+
110
+ async function prefetchAll(g: Graph, opts: AsyncRenderOptions | undefined): Promise<Prefetched> {
111
+ const res = collectResources(g, { inlineImages: opts?.inlineImages ?? false });
112
+ const [fontIssues, sizes, bytes] = await Promise.all([
113
+ loadFonts(
114
+ opts?.fontSet ?? defaultFontSet(),
115
+ res.fonts,
116
+ opts?.fontTimeoutMs ?? DEFAULT_FONT_TIMEOUT_MS,
117
+ ),
118
+ prefetch(res.sizeSrcs, opts?.imageSizer),
119
+ prefetch(res.bytesSrcs, opts?.imageResolver),
120
+ ]);
121
+ return { fontIssues, sizes, bytes };
122
+ }
123
+
124
+ /**
125
+ * Render a (parsed or built) graph to the requested format after prefetching
126
+ * web fonts and image data, so layout measures real faces and sizes.
127
+ *
128
+ * Every failure, including usage `TypeError`s, is a promise rejection with the
129
+ * same classes and codes as {@link render}. Font problems never reject; they
130
+ * are returned in `fontIssues` (and `console.warn`ed).
131
+ *
132
+ * @remarks
133
+ * Security: for the markup formats (`svg`, `cmapx`, `imap`), treat the output
134
+ * as attacker-controlled when the source graph came from untrusted DOT.
135
+ * Attribute values are XML-escaped, but URL schemes and resource origins
136
+ * (`href`/`URL`/`image`/`stylesheet`) are passed through unfiltered, matching
137
+ * native Graphviz. Apply a Content-Security-Policy or sanitize before embedding
138
+ * — see the README "Security" section.
139
+ *
140
+ * @param g - graph produced by `parse(...)` or the builder API
141
+ * @param format - target output format
142
+ * @param opts - engine, `inlineImages`, async image hooks and font options
143
+ * @returns the rendered string and any font issues
144
+ * @throws TypeError `ERR_INVALID_ARG_TYPE` / `ERR_INVALID_ARG_VALUE` (as rejection)
145
+ * @throws RenderError, InternalError (as rejection), as {@link render}
146
+ */
147
+ export async function renderAsync(
148
+ g: Graph,
149
+ format: OutputFormat,
150
+ opts?: AsyncRenderOptions,
151
+ ): Promise<AsyncRenderResult> {
152
+ checkRenderArgs(g, format, opts);
153
+ const engine: EngineName = opts?.engine ?? 'dot';
154
+ try {
155
+ const { fontIssues, sizes, bytes } = await prefetchAll(g, opts);
156
+ // Created after fonts settle so the measurer sees the loaded faces.
157
+ const ctx = createDefaultContext();
158
+ if (opts?.imageSizer !== undefined) {
159
+ const sizer: ImageSizer = (src) => sizes.get(src) ?? null;
160
+ ctx.imageSizer = sizer;
161
+ }
162
+ if (opts?.imageResolver !== undefined) {
163
+ const resolver: ImageResolver = (src) => bytes.get(src) ?? null;
164
+ ctx.imageResolver = resolver;
165
+ }
166
+ ctx.layout(g, engine);
167
+ const output = deviceRender(ctx, g, format, opts?.inlineImages ?? false);
168
+ ctx.freeLayout(g, engine);
169
+ return { output, fontIssues };
170
+ } catch (err: unknown) {
171
+ return rethrowAtBoundary(err);
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Async counterpart of `renderSvg`: parse DOT, prefetch fonts/images, render.
177
+ *
178
+ * @remarks
179
+ * Security: same untrusted-input caveat as `renderSvg` and {@link renderAsync}
180
+ * — the returned `svg` is attacker-controlled markup for untrusted `dotSource`;
181
+ * apply a CSP or sanitize before embedding. See the README "Security" section.
182
+ *
183
+ * @param dotSource - DOT-language graph source
184
+ * @param engine - layout engine name
185
+ * @param opts - `inlineImages`, async image hooks and font options
186
+ * @returns the SVG string and any font issues
187
+ * @throws TypeError `ERR_INVALID_ARG_TYPE` / `ERR_INVALID_ARG_VALUE` (as rejection)
188
+ * @throws ParseError, RenderError, InternalError (as rejection), as `renderSvg`
189
+ */
190
+ export async function renderSvgAsync(
191
+ dotSource: string,
192
+ engine: EngineName,
193
+ opts?: AsyncSvgOptions,
194
+ ): Promise<AsyncSvgResult> {
195
+ if (typeof dotSource !== 'string') throw invalidArgType('dotSource', 'string', dotSource);
196
+ if (typeof engine !== 'string') throw invalidArgType('engine', 'string', engine);
197
+ let g: Graph;
198
+ try {
199
+ g = parse(dotSource);
200
+ } catch (err: unknown) {
201
+ return rethrowAtBoundary(err);
202
+ }
203
+ const { output, fontIssues } = await renderAsync(g, 'svg', { ...opts, engine });
204
+ return { svg: output, fontIssues };
205
+ }
@@ -0,0 +1,115 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * `renderSvgInto` (async-api ADR-6): render DOT to SVG and insert it into a
5
+ * DOM element, sanitizing by default. Insertion goes through
6
+ * `DOMParser('image/svg+xml')` + `importNode` + `replaceChildren`; never
7
+ * `innerHTML`. DOM-coupled by design (owner decision 4); browser-safe.
8
+ *
9
+ * @see src/async/render-async.ts:renderSvgAsync
10
+ * @see src/async/sanitize.ts:scrubSvgDocument
11
+ */
12
+
13
+ import { RenderError, invalidArgType, invalidArgValue, invalidState } from '../errors.js';
14
+ import type { EngineName } from '../gvc/context.js';
15
+ import { renderSvgAsync } from './render-async.js';
16
+ import type { AsyncSvgOptions } from './render-async.js';
17
+ import type { FontIssue } from './fonts.js';
18
+ import { scrubSvgDocument } from './sanitize.js';
19
+ import type { SvgParserLike } from './sanitize.js';
20
+
21
+ const SVG_MIME = 'image/svg+xml';
22
+ const PARSER_ERROR_NAME = 'parsererror';
23
+
24
+ /** Options for {@link renderSvgInto}. */
25
+ export interface RenderSvgIntoOptions extends AsyncSvgOptions {
26
+ /** Custom sanitizer: receives the SVG string, returns the markup to insert. */
27
+ sanitize?: (svg: string) => string;
28
+ /** Insert the SVG as rendered, with no sanitizing at all. Default false. */
29
+ trusted?: boolean;
30
+ /** Document to look up `id` in and insert into. Default `globalThis.document`. */
31
+ document?: Document;
32
+ /** Parser override (test seam). Default: the document window's `DOMParser`. */
33
+ domParser?: SvgParserLike;
34
+ }
35
+
36
+ /** Resolved value of {@link renderSvgInto}. */
37
+ export interface RenderSvgIntoResult {
38
+ /** The inserted (imported) root `<svg>` element. */
39
+ element: SVGSVGElement;
40
+ fontIssues: FontIssue[];
41
+ }
42
+
43
+ function resolveDocument(opts: RenderSvgIntoOptions | undefined): Document {
44
+ const doc: unknown = opts?.document ?? Reflect.get(globalThis, 'document');
45
+ if (typeof doc !== 'object' || doc === null) {
46
+ throw invalidState('renderSvgInto requires a document; pass opts.document or run in a browser');
47
+ }
48
+ return doc as Document; // structural: used only via getElementById/importNode
49
+ }
50
+
51
+ function resolveParser(doc: Document, opts: RenderSvgIntoOptions | undefined): SvgParserLike {
52
+ if (opts?.domParser !== undefined) return opts.domParser;
53
+ const view: unknown = doc.defaultView ?? globalThis;
54
+ const ctor: unknown = typeof view === 'object' && view !== null ? Reflect.get(view, 'DOMParser') : undefined;
55
+ if (typeof ctor !== 'function') {
56
+ throw invalidState('renderSvgInto requires a DOMParser; pass opts.domParser');
57
+ }
58
+ return new (ctor as new () => SvgParserLike)(); // structural: DOMParser.parseFromString
59
+ }
60
+
61
+ function hasParserError(doc: Document): boolean {
62
+ const root = doc.documentElement as Element | null;
63
+ if (root === null || root.nodeName.toLowerCase() === PARSER_ERROR_NAME) return true;
64
+ return root.getElementsByTagName(PARSER_ERROR_NAME).length > 0;
65
+ }
66
+
67
+ function parseSvg(parser: SvgParserLike, svg: string): Document {
68
+ let doc: Document;
69
+ try {
70
+ doc = parser.parseFromString(svg, SVG_MIME);
71
+ } catch (err: unknown) {
72
+ throw new RenderError('renderSvgInto could not parse the rendered SVG', 'RENDER_ERROR', {
73
+ cause: err,
74
+ });
75
+ }
76
+ if (hasParserError(doc)) {
77
+ throw new RenderError('renderSvgInto could not parse the rendered SVG', 'RENDER_ERROR');
78
+ }
79
+ return doc;
80
+ }
81
+
82
+ /**
83
+ * Render `src` and replace the children of the element with id `id` by the
84
+ * resulting `<svg>`.
85
+ *
86
+ * Order (ADR-6): `trusted === true` inserts as rendered; else `sanitize(svg)`
87
+ * if given; else the built-in scrubber (`scrubSvgDocument`).
88
+ *
89
+ * @param id - id of the target element in the document
90
+ * @param src - DOT source
91
+ * @param engine - layout engine name
92
+ * @param opts - async render options plus `sanitize`, `trusted`, `document`
93
+ * @returns the inserted `<svg>` element and any font issues
94
+ * @throws TypeError `ERR_INVALID_ARG_TYPE` / `ERR_INVALID_ARG_VALUE` /
95
+ * `ERR_INVALID_STATE` (as rejection); RenderError if the SVG does not parse
96
+ */
97
+ export async function renderSvgInto(
98
+ id: string,
99
+ src: string,
100
+ engine: EngineName,
101
+ opts?: RenderSvgIntoOptions,
102
+ ): Promise<RenderSvgIntoResult> {
103
+ if (typeof id !== 'string') throw invalidArgType('id', 'string', id);
104
+ const doc = resolveDocument(opts);
105
+ const target = doc.getElementById(id);
106
+ if (target === null) throw invalidArgValue('id', id, ['the id of an element in the document']);
107
+ const parser = resolveParser(doc, opts);
108
+ const { svg, fontIssues } = await renderSvgAsync(src, engine, opts);
109
+ const custom = opts?.trusted !== true ? opts?.sanitize : undefined;
110
+ const parsed = parseSvg(parser, custom !== undefined ? custom(svg) : svg);
111
+ if (opts?.trusted !== true && custom === undefined) scrubSvgDocument(parsed);
112
+ const element = doc.importNode(parsed.documentElement, true) as unknown as SVGSVGElement;
113
+ target.replaceChildren(element);
114
+ return { element, fontIssues };
115
+ }
@@ -0,0 +1,150 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+
3
+ /**
4
+ * Built-in SVG scrubber (ADR-6). Uses only DOM Level 2 members of
5
+ * `Document`/`Element`/`Attr`/`Node`, so it runs on browser documents and on
6
+ * xmldom documents alike. This is a deny-list for the vectors the library's
7
+ * own output can carry (README "Security"); callers needing a stricter policy
8
+ * pass their own `sanitize` or a CSP.
9
+ */
10
+
11
+ const ELEMENT_NODE = 1;
12
+ const PROCESSING_INSTRUCTION_NODE = 7;
13
+
14
+ /** Elements removed outright, with their whole subtree (local name, lowercase). */
15
+ const BANNED_ELEMENTS: ReadonlySet<string> = new Set(['script', 'foreignobject']);
16
+
17
+ /** SMIL elements that can write an attribute value (local name, lowercase). */
18
+ const ANIMATION_ELEMENTS: ReadonlySet<string> = new Set([
19
+ 'set',
20
+ 'animate',
21
+ 'animatemotion',
22
+ 'animatetransform',
23
+ ]);
24
+
25
+ const EVENT_ATTRIBUTE_PREFIX = 'on';
26
+ const HREF_LOCAL_NAME = 'href';
27
+ const IMAGE_ELEMENT = 'image';
28
+ const ATTRIBUTE_NAME = 'attributeName';
29
+ const XML_STYLESHEET_TARGET = 'xml-stylesheet';
30
+
31
+ const SCRIPT_SCHEMES: readonly string[] = ['javascript:', 'vbscript:'];
32
+ const DATA_SCHEME = 'data:';
33
+ const DATA_IMAGE_PREFIX = 'data:image/';
34
+
35
+ // ASCII whitespace plus every C0 control and DEL; browsers drop tab/CR/LF
36
+ // anywhere in a URL and C0/space at its ends, so stripping all of them is a
37
+ // superset that cannot let an obfuscated scheme through.
38
+ // eslint-disable-next-line no-control-regex
39
+ const IGNORED_URL_CHARS = /[\u0000- \u007f]/g;
40
+
41
+ /** Structural subset of `DOMParser` used by {@link scrubSvgString}. */
42
+ export interface SvgParserLike {
43
+ parseFromString(source: string, mimeType: 'image/svg+xml'): Document;
44
+ }
45
+
46
+ /** Structural subset of `XMLSerializer` used by {@link scrubSvgString}. */
47
+ export interface SvgSerializerLike {
48
+ serializeToString(node: Node): string;
49
+ }
50
+
51
+ function localNameOf(node: { localName?: string | null; nodeName: string }): string {
52
+ const local = node.localName;
53
+ const name = typeof local === 'string' && local !== '' ? local : node.nodeName;
54
+ const colon = name.lastIndexOf(':');
55
+ return (colon >= 0 ? name.slice(colon + 1) : name).toLowerCase();
56
+ }
57
+
58
+ function normalizeUrl(value: string): string {
59
+ return value.replace(IGNORED_URL_CHARS, '').toLowerCase();
60
+ }
61
+
62
+ function isHostileUrl(value: string, element: Element): boolean {
63
+ const url = normalizeUrl(value);
64
+ if (SCRIPT_SCHEMES.some((scheme) => url.startsWith(scheme))) return true;
65
+ if (!url.startsWith(DATA_SCHEME)) return false;
66
+ return !(localNameOf(element) === IMAGE_ELEMENT && url.startsWith(DATA_IMAGE_PREFIX));
67
+ }
68
+
69
+ function isEventAttribute(attr: Attr): boolean {
70
+ return (
71
+ attr.name.toLowerCase().startsWith(EVENT_ATTRIBUTE_PREFIX) ||
72
+ localNameOf(attr).startsWith(EVENT_ATTRIBUTE_PREFIX)
73
+ );
74
+ }
75
+
76
+ function isHostileAttribute(attr: Attr, element: Element): boolean {
77
+ if (isEventAttribute(attr)) return true;
78
+ return localNameOf(attr) === HREF_LOCAL_NAME && isHostileUrl(attr.value, element);
79
+ }
80
+
81
+ /**
82
+ * An animation targeting `href` (any prefix or case) or an event handler can
83
+ * write a hostile value after the static attribute scrub ran.
84
+ */
85
+ function isHostileAnimation(element: Element): boolean {
86
+ if (!ANIMATION_ELEMENTS.has(localNameOf(element))) return false;
87
+ const target = (element.getAttribute(ATTRIBUTE_NAME) ?? '').trim().toLowerCase();
88
+ const local = target.slice(target.lastIndexOf(':') + 1);
89
+ return local === HREF_LOCAL_NAME || local.startsWith(EVENT_ATTRIBUTE_PREFIX);
90
+ }
91
+
92
+ function scrubAttributes(element: Element): void {
93
+ const doomed: Attr[] = [];
94
+ for (let i = 0; i < element.attributes.length; i++) {
95
+ const attr = element.attributes.item(i);
96
+ if (attr !== null && isHostileAttribute(attr, element)) doomed.push(attr);
97
+ }
98
+ for (const attr of doomed) element.removeAttributeNode(attr);
99
+ }
100
+
101
+ function isDoomedNode(node: Node): boolean {
102
+ if (node.nodeType === PROCESSING_INSTRUCTION_NODE) {
103
+ return node.nodeName.toLowerCase() === XML_STYLESHEET_TARGET;
104
+ }
105
+ if (node.nodeType !== ELEMENT_NODE) return false;
106
+ return BANNED_ELEMENTS.has(localNameOf(node)) || isHostileAnimation(node as Element);
107
+ }
108
+
109
+ /**
110
+ * Scrub `doc` in place: remove `script`/`foreignObject`, `href`-targeting
111
+ * animations, every `on*` attribute, `javascript:`/`vbscript:` hrefs, `data:`
112
+ * hrefs other than `data:image/*` on `<image>`, and `xml-stylesheet`
113
+ * processing instructions. Element, attribute and PI matching ignores case and
114
+ * namespace prefix.
115
+ *
116
+ * @param doc - A document parsed from SVG; mutated.
117
+ */
118
+ export function scrubSvgDocument(doc: Document): void {
119
+ const pending: Node[] = [doc];
120
+ while (pending.length > 0) {
121
+ const parent = pending.pop() as Node;
122
+ const children = Array.from(parent.childNodes);
123
+ for (const child of children) {
124
+ if (isDoomedNode(child)) {
125
+ parent.removeChild(child);
126
+ } else if (child.nodeType === ELEMENT_NODE) {
127
+ scrubAttributes(child as Element);
128
+ pending.push(child);
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Parse `svg`, scrub it with {@link scrubSvgDocument} and serialize it back.
136
+ *
137
+ * @param svg - SVG source text.
138
+ * @param parser - A `DOMParser`-like object.
139
+ * @param serializer - An `XMLSerializer`-like object.
140
+ * @returns The scrubbed SVG text.
141
+ */
142
+ export function scrubSvgString(
143
+ svg: string,
144
+ parser: SvgParserLike,
145
+ serializer: SvgSerializerLike,
146
+ ): string {
147
+ const doc = parser.parseFromString(svg, 'image/svg+xml');
148
+ scrubSvgDocument(doc);
149
+ return serializer.serializeToString(doc);
150
+ }
@@ -0,0 +1,109 @@
1
+ // SPDX-License-Identifier: EPL-2.0
2
+ //
3
+ // CSS `font` shorthand for canvas text measurement. C graphviz has no canvas
4
+ // measurer; its text-layout plugin measures through the PostScript alias
5
+ // (plugin/pango/gvtextlayout_pango.c:112), and the SVG emitter renders through
6
+ // the same alias (svg_textspan). This builder yields the face the SVG renders
7
+ // with, so browser measurement and rendering agree. Selection follows the
8
+ // NATIVEFONTS branch of svg_textspan (plans/canvas-font-mapping ADR-1).
9
+
10
+ import { translatePostscriptFontname, type PostscriptAlias } from './ps-fontalias.js';
11
+ import type { TextVariantFlags } from './textmeasure.js';
12
+
13
+ /** svg_textspan's family when a span has no font name. */
14
+ const DEFAULT_FAMILIES: readonly string[] = ['Times', 'serif'];
15
+
16
+ const CSS_GENERIC_FAMILIES: ReadonlySet<string> = new Set([
17
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui',
18
+ ]);
19
+
20
+ const CSS_WEIGHT_KEYWORDS: ReadonlySet<string> = new Set([
21
+ 'normal', 'bold', 'bolder', 'lighter',
22
+ ]);
23
+
24
+ const DQ = '\u0022';
25
+ const SQ = '\u0027';
26
+ const BACKSLASH = '\\';
27
+
28
+ /**
29
+ * True when `w` is a value CSS accepts for font-weight (ADR-2). Alias weights
30
+ * are a fixed keyword set (ps_font_equiv.h), never numeric, so only the
31
+ * keywords are checked.
32
+ */
33
+ function isCssWeight(w: string): boolean {
34
+ return CSS_WEIGHT_KEYWORDS.has(w.toLowerCase());
35
+ }
36
+
37
+ /** Remove one pair of matching surrounding quotes. */
38
+ function stripQuotes(f: string): string {
39
+ const q = f.charAt(0);
40
+ if (f.length >= 2 && (q === DQ || q === SQ) && f.endsWith(q)) return f.slice(1, -1);
41
+ return f;
42
+ }
43
+
44
+ /** Quote a family name unless it is a CSS generic family (ADR-4). */
45
+ function quoteFamily(f: string): string {
46
+ if (CSS_GENERIC_FAMILIES.has(f.toLowerCase())) return f;
47
+ const escaped = f.split(BACKSLASH).join(BACKSLASH + BACKSLASH)
48
+ .split(DQ).join(BACKSLASH + DQ);
49
+ return DQ + escaped + DQ;
50
+ }
51
+
52
+ /** Split a non-alias fontname into its family entries (ADR-4). */
53
+ function splitFamilies(fontname: string | null): readonly string[] {
54
+ const entries = (fontname ?? '').split(',')
55
+ .map((f) => stripQuotes(f.trim()).trim())
56
+ .filter((f) => f !== '');
57
+ return entries.length > 0 ? entries : DEFAULT_FAMILIES;
58
+ }
59
+
60
+ /**
61
+ * NATIVEFONTS family list: family, plus svg_font_family when it differs.
62
+ * @see plugin/core/gvrender_core_svg.c:487-489
63
+ */
64
+ function aliasFamilies(a: PostscriptAlias): readonly string[] {
65
+ return a.svgFontFamily !== a.family ? [a.family, a.svgFontFamily] : [a.family];
66
+ }
67
+
68
+ /** Style token: the alias style, else italic from the HTML_IF flag. */
69
+ function styleToken(a: PostscriptAlias | null, flags?: TextVariantFlags): string | null {
70
+ if (a?.style != null) return a.style;
71
+ return flags?.italic === true ? 'italic' : null;
72
+ }
73
+
74
+ /**
75
+ * Weight token. The bold flag applies only when the alias set no weight —
76
+ * tested on the alias value BEFORE the ADR-2 drop, as C's HTML_BF guard tests
77
+ * the raw alias weight (so `demi` + bold renders, and measures, normal).
78
+ */
79
+ function weightToken(a: PostscriptAlias | null, flags?: TextVariantFlags): string | null {
80
+ if (a?.weight != null) return isCssWeight(a.weight) ? a.weight : null;
81
+ return flags?.bold === true ? 'bold' : null;
82
+ }
83
+
84
+ /**
85
+ * style/weight/stretch tokens in shorthand order.
86
+ * @see plugin/core/gvrender_core_svg.c:490-500
87
+ */
88
+ function variantTokens(a: PostscriptAlias | null, flags?: TextVariantFlags): string[] {
89
+ const tokens = [styleToken(a, flags), weightToken(a, flags), a?.stretch ?? null];
90
+ return tokens.filter((t): t is string => t !== null);
91
+ }
92
+
93
+ /**
94
+ * CSS `font` shorthand (`[style] [weight] [stretch] <size>px <families>`)
95
+ * naming the face svg_textspan renders `fontname` with. Pure; always
96
+ * syntactically valid CSS.
97
+ * @see plugin/core/gvrender_core_svg.c:462-500 svg_textspan (family/weight/style selection)
98
+ */
99
+ export function canvasFont(
100
+ fontname: string | null,
101
+ fontsize: number,
102
+ flags?: TextVariantFlags,
103
+ ): string {
104
+ // C translate_postscript_fontname matches the whole name only.
105
+ const a = fontname !== null ? translatePostscriptFontname(fontname) : null;
106
+ const families = a !== null ? aliasFamilies(a) : splitFamilies(fontname);
107
+ const tokens = [...variantTokens(a, flags), `${fontsize}px`];
108
+ return `${tokens.join(' ')} ${families.map(quoteFamily).join(', ')}`;
109
+ }
@@ -9,8 +9,7 @@
9
9
  // Error
10
10
  // ---------------------------------------------------------------------------
11
11
 
12
- import type { GvError } from '../errors.js';
13
- import { friendlyMessageFor } from '../errors.js';
12
+ import { DotEngineError, friendlyMessageFor } from '../errors.js';
14
13
 
15
14
  /**
16
15
  * Thrown when an unrecognized HTML tag is encountered during parsing.
@@ -21,14 +20,14 @@ import { friendlyMessageFor } from '../errors.js';
21
20
  *
22
21
  * @see lib/common/htmllex.c:lexerror
23
22
  */
24
- export class HtmlParseError extends Error implements GvError {
23
+ export class HtmlParseError extends DotEngineError {
25
24
  readonly type = 'semantic';
26
25
  readonly code = 'HTML_PARSE_ERROR';
27
26
  readonly friendlyMessage = friendlyMessageFor('HTML_PARSE_ERROR');
28
27
  readonly tag: string;
29
28
 
30
- constructor(tag: string) {
31
- super(`Unknown HTML element <${tag}>`);
29
+ constructor(tag: string, options?: ErrorOptions) {
30
+ super(`Unknown HTML element <${tag}>`, options);
32
31
  this.tag = tag;
33
32
  this.name = 'HtmlParseError';
34
33
  }
@@ -14,6 +14,8 @@ import type { TextMeasurer, TextSize } from './textmeasure.js';
14
14
  import type { TextSpan } from './emit-types.js';
15
15
  import { makeHtmlLabel } from './htmltable-pos.js';
16
16
  import { substObj, type GraphObj } from './subst.js';
17
+ import { Edge } from '../model/edge.js';
18
+ import type { Graph } from '../model/graph.js';
17
19
  import { htmlEntityUTF8 } from './html-entities.js';
18
20
 
19
21
  export const DEFAULT_FONTSIZE = 14.0;
@@ -39,6 +41,11 @@ function getPenColor(obj?: GraphObj): string | undefined {
39
41
  return undefined;
40
42
  }
41
43
 
44
+ /** The root graph owning `obj` (edges resolve through their tail node). */
45
+ function rootOf(obj: GraphObj): Graph {
46
+ return obj instanceof Edge ? obj.tail.root : obj.root;
47
+ }
48
+
42
49
  /** Font attributes bundle — mirrors C fontinfo_t fields used in label init. */
43
50
  export interface FontInfo {
44
51
  fontname: string;
@@ -180,7 +187,9 @@ export function makeAnyLabel(
180
187
  // plain text (html=false) per htmltable.c:1892. Thread the owning object's
181
188
  // pen color so an HTML table/cell border with no explicit COLOR inherits
182
189
  // it (htmltable.c:1911 getPenColor). @see getPenColor
183
- return makeHtmlLabel(content, { ...font, pencolor: getPenColor(obj) }, measurer);
190
+ // ADR-2: a per-context sizer (when set) wins over the global one.
191
+ const imageSizer = obj !== undefined ? rootOf(obj).info.gvc?.imageSizer : undefined;
192
+ return makeHtmlLabel(content, { ...font, pencolor: getPenColor(obj), imageSizer }, measurer);
184
193
  }
185
194
  // Plain path: resolve \G \N \E \T \H \L against the owning object
186
195
  // BEFORE measuring, as C does (labels.c:169, escBackslash=0; the
@@ -9,6 +9,7 @@
9
9
  // poly-shapes-util.ts and the per-shape vertex generators in
10
10
  // poly-shapes-cases.ts.
11
11
 
12
+ import { RenderError } from '../errors.js';
12
13
  import type { Point } from '../model/geom.js';
13
14
  import {
14
15
  type ShapeCtx, interpolationPoints, renderShapeBezier,
@@ -73,7 +74,9 @@ export function drawSpecialShape(
73
74
  }
74
75
  const draw = CASES.get(shape);
75
76
  if (draw === undefined) {
76
- throw new Error('special shape ' + String(shape) + ' not yet ported');
77
+ throw new RenderError(
78
+ 'special shape ' + String(shape) + ' not yet ported', 'UNSUPPORTED_FEATURE',
79
+ );
77
80
  }
78
81
  const b = interpolationPoints(ring, ring.length, shape);
79
82
  draw(ring, b, coord, filled, ctx);
@@ -23,6 +23,8 @@ import {
23
23
  LutTextMeasurer, CanvasTextMeasurer, EstimateTextMeasurer, type TextMeasurer,
24
24
  } from './textmeasure.js';
25
25
 
26
+ import { invalidArgType } from '../errors.js';
27
+
26
28
  let override: TextMeasurer | undefined;
27
29
 
28
30
  /**
@@ -31,8 +33,17 @@ let override: TextMeasurer | undefined;
31
33
  * render (renderSvg resolves the measurer per call). Use for deterministic
32
34
  * tests or to wire a host-faithful Node measurer (e.g. node-canvas) — see the
33
35
  * resolution note above.
36
+ * @throws TypeError `ERR_INVALID_ARG_TYPE` if `m` is neither undefined nor an
37
+ * object with a `measure` function
34
38
  */
35
39
  export function setTextMeasurer(m: TextMeasurer | undefined): void {
40
+ if (
41
+ m !== undefined
42
+ && (typeof m !== 'object' || m === null
43
+ || typeof (m as { measure?: unknown }).measure !== 'function')
44
+ ) {
45
+ throw invalidArgType('measurer', 'TextMeasurer or undefined', m);
46
+ }
36
47
  override = m;
37
48
  }
38
49