@knowvah/dot-engine 1.9.0 → 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.
- package/README.md +232 -30
- package/dist/api/builder.d.ts +3 -0
- package/dist/api/builder.d.ts.map +1 -1
- package/dist/api/edge-ops.d.ts +7 -0
- package/dist/api/edge-ops.d.ts.map +1 -1
- package/dist/api/geometry.d.ts +5 -2
- package/dist/api/geometry.d.ts.map +1 -1
- package/dist/api.js +159 -31
- package/dist/api.js.map +3 -3
- package/dist/async/collect.d.ts +49 -0
- package/dist/async/collect.d.ts.map +1 -0
- package/dist/async/fonts.d.ts +20 -0
- package/dist/async/fonts.d.ts.map +1 -0
- package/dist/async/render-async.d.ts +91 -0
- package/dist/async/render-async.d.ts.map +1 -0
- package/dist/async/render-into.d.ts +38 -0
- package/dist/async/render-into.d.ts.map +1 -0
- package/dist/async/sanitize.d.ts +28 -0
- package/dist/async/sanitize.d.ts.map +1 -0
- package/dist/common/htmltable-types.d.ts +3 -3
- package/dist/common/htmltable-types.d.ts.map +1 -1
- package/dist/common/make-label.d.ts.map +1 -1
- package/dist/common/poly-shapes.d.ts.map +1 -1
- package/dist/common/textmeasure-factory.d.ts +2 -0
- package/dist/common/textmeasure-factory.d.ts.map +1 -1
- package/dist/common/utils-inputscale.d.ts +19 -0
- package/dist/common/utils-inputscale.d.ts.map +1 -0
- package/dist/errors.d.ts +61 -5
- package/dist/errors.d.ts.map +1 -1
- package/dist/gvc/context.d.ts +36 -4
- package/dist/gvc/context.d.ts.map +1 -1
- package/dist/gvc/device.d.ts +5 -2
- package/dist/gvc/device.d.ts.map +1 -1
- package/dist/gvc/image-resolver.d.ts +16 -16
- package/dist/gvc/image-resolver.d.ts.map +1 -1
- package/dist/gvc/job.d.ts +1 -9
- package/dist/gvc/job.d.ts.map +1 -1
- package/dist/gvc/usershape.d.ts +2 -12
- package/dist/gvc/usershape.d.ts.map +1 -1
- package/dist/index.d.ts +27 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7560 -5446
- package/dist/index.js.map +4 -4
- package/dist/label/index.d.ts.map +1 -1
- package/dist/label/node.d.ts +0 -6
- package/dist/label/node.d.ts.map +1 -1
- package/dist/label/rectangle.d.ts +1 -7
- package/dist/label/rectangle.d.ts.map +1 -1
- package/dist/layout/circo/circular.d.ts +8 -5
- package/dist/layout/circo/circular.d.ts.map +1 -1
- package/dist/layout/dot/pack-components.d.ts +0 -19
- package/dist/layout/dot/pack-components.d.ts.map +1 -1
- package/dist/layout/dot/position.d.ts +7 -2
- package/dist/layout/dot/position.d.ts.map +1 -1
- package/dist/layout/fdp/derive.d.ts.map +1 -1
- package/dist/layout/fdp/index.d.ts.map +1 -1
- package/dist/layout/fdp/init.d.ts.map +1 -1
- package/dist/layout/fdp/layout.d.ts.map +1 -1
- package/dist/layout/fdp/ports.d.ts +0 -10
- package/dist/layout/fdp/ports.d.ts.map +1 -1
- package/dist/layout/fdp/xlayout.d.ts +0 -16
- package/dist/layout/fdp/xlayout.d.ts.map +1 -1
- package/dist/layout/neato/adjust-info.d.ts +117 -0
- package/dist/layout/neato/adjust-info.d.ts.map +1 -0
- package/dist/layout/neato/cdt-surface.d.ts.map +1 -1
- package/dist/layout/neato/constraint-adjust.d.ts +40 -0
- package/dist/layout/neato/constraint-adjust.d.ts.map +1 -0
- package/dist/layout/neato/edge-len.d.ts +20 -0
- package/dist/layout/neato/edge-len.d.ts.map +1 -0
- package/dist/layout/neato/fdp-adjust.d.ts +32 -4
- package/dist/layout/neato/fdp-adjust.d.ts.map +1 -1
- package/dist/layout/neato/index.d.ts +9 -6
- package/dist/layout/neato/index.d.ts.map +1 -1
- package/dist/layout/neato/init.d.ts +4 -11
- package/dist/layout/neato/init.d.ts.map +1 -1
- package/dist/layout/neato/kk-paths.d.ts +50 -0
- package/dist/layout/neato/kk-paths.d.ts.map +1 -0
- package/dist/layout/neato/kk-solve.d.ts +14 -0
- package/dist/layout/neato/kk-solve.d.ts.map +1 -0
- package/dist/layout/neato/kk.d.ts +44 -0
- package/dist/layout/neato/kk.d.ts.map +1 -0
- package/dist/layout/neato/multispline-router.d.ts.map +1 -1
- package/dist/layout/neato/poly.d.ts +50 -0
- package/dist/layout/neato/poly.d.ts.map +1 -0
- package/dist/layout/neato/sgd-dijkstra.d.ts +20 -0
- package/dist/layout/neato/sgd-dijkstra.d.ts.map +1 -0
- package/dist/layout/neato/sgd.d.ts +14 -9
- package/dist/layout/neato/sgd.d.ts.map +1 -1
- package/dist/layout/neato/start.d.ts +51 -0
- package/dist/layout/neato/start.d.ts.map +1 -0
- package/dist/layout/neato/vpsc-adjust.d.ts +21 -0
- package/dist/layout/neato/vpsc-adjust.d.ts.map +1 -0
- package/dist/layout/sfdp/index.d.ts.map +1 -1
- package/dist/layout/sfdp/init.d.ts +0 -9
- package/dist/layout/sfdp/init.d.ts.map +1 -1
- package/dist/layout/sfdp/spring-driver.d.ts.map +1 -1
- package/dist/layout/twopi/circle.d.ts +0 -7
- package/dist/layout/twopi/circle.d.ts.map +1 -1
- package/dist/ortho/ortho-parallel.d.ts.map +1 -1
- package/dist/ortho/trap-query.d.ts.map +1 -1
- package/dist/parser/index.d.ts +9 -5
- package/dist/parser/index.d.ts.map +1 -1
- package/dist/render/index.d.ts +2 -0
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/public.d.ts +9 -3
- package/dist/render/public.d.ts.map +1 -1
- package/dist/render/xdot-public.d.ts +7 -2
- package/dist/render/xdot-public.d.ts.map +1 -1
- package/dist/render.js +7355 -5476
- package/dist/render.js.map +4 -4
- package/dist/util/xml.d.ts.map +1 -1
- package/dist/vpsc/Solver.d.ts +1 -0
- package/dist/vpsc/Solver.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api/builder.ts +73 -5
- package/src/api/edge-ops.ts +19 -0
- package/src/api/geometry.ts +22 -5
- package/src/async/collect.ts +204 -0
- package/src/async/fonts.ts +61 -0
- package/src/async/render-async.ts +205 -0
- package/src/async/render-into.ts +115 -0
- package/src/async/sanitize.ts +150 -0
- package/src/common/htmltable-types.ts +4 -5
- package/src/common/make-label.ts +10 -1
- package/src/common/poly-shapes.ts +4 -1
- package/src/common/textmeasure-factory.ts +11 -0
- package/src/common/utils-inputscale.ts +31 -0
- package/src/errors.ts +188 -5
- package/src/gvc/context.ts +96 -17
- package/src/gvc/device.ts +11 -6
- package/src/gvc/image-resolver.ts +25 -2
- package/src/gvc/job.ts +3 -1
- package/src/gvc/usershape.ts +6 -0
- package/src/index.ts +59 -43
- package/src/label/index.ts +6 -2
- package/src/label/node.ts +2 -1
- package/src/label/rectangle.ts +4 -2
- package/src/layout/circo/circular.ts +9 -6
- package/src/layout/dot/pack-components.ts +6 -2
- package/src/layout/dot/position.ts +21 -3
- package/src/layout/fdp/derive.ts +7 -6
- package/src/layout/fdp/index.ts +45 -5
- package/src/layout/fdp/init.ts +7 -6
- package/src/layout/fdp/layout.ts +2 -1
- package/src/layout/fdp/ports.ts +3 -2
- package/src/layout/fdp/xlayout.ts +3 -1
- package/src/layout/neato/adjust-info.ts +332 -0
- package/src/layout/neato/cdt-surface.ts +50 -30
- package/src/layout/neato/constraint-adjust.ts +466 -0
- package/src/layout/neato/edge-len.ts +36 -0
- package/src/layout/neato/fdp-adjust.ts +117 -12
- package/src/layout/neato/index.ts +36 -54
- package/src/layout/neato/init.ts +21 -36
- package/src/layout/neato/kk-paths.ts +146 -0
- package/src/layout/neato/kk-solve.ts +64 -0
- package/src/layout/neato/kk.ts +322 -0
- package/src/layout/neato/multispline-router.ts +4 -3
- package/src/layout/neato/poly.ts +493 -0
- package/src/layout/neato/sgd-dijkstra.ts +125 -0
- package/src/layout/neato/sgd.ts +63 -40
- package/src/layout/neato/start.ts +222 -0
- package/src/layout/neato/vpsc-adjust.ts +93 -0
- package/src/layout/sfdp/index.ts +4 -5
- package/src/layout/sfdp/init.ts +40 -4
- package/src/layout/sfdp/spring-driver.ts +30 -1
- package/src/layout/twopi/circle.ts +2 -1
- package/src/ortho/ortho-parallel.ts +2 -1
- package/src/ortho/trap-query.ts +2 -1
- package/src/parser/index.ts +11 -11
- package/src/render/index.ts +5 -0
- package/src/render/public.ts +24 -24
- package/src/render/svg.ts +1 -1
- package/src/render/xdot-public.ts +19 -18
- package/src/util/xml.ts +24 -30
- 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
|
+
}
|
|
@@ -9,8 +9,7 @@
|
|
|
9
9
|
// Error
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
|
-
import
|
|
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
|
|
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
|
}
|
package/src/common/make-label.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
+
}
|