@knowvah/dot-engine 0.1.0 → 1.1.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 +7 -4
- package/dist/api/index.d.ts +2 -2
- package/dist/api.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/gvc/anchor.d.ts.map +1 -1
- package/dist/gvc/device.d.ts +1 -1
- package/dist/gvc/device.d.ts.map +1 -1
- package/dist/index.js +226 -40
- package/dist/index.js.map +3 -3
- package/dist/layout/neato/init.d.ts.map +1 -1
- package/dist/layout/twopi/init.d.ts +0 -7
- package/dist/layout/twopi/init.d.ts.map +1 -1
- package/dist/render/index.d.ts +2 -2
- package/dist/render/map.d.ts +29 -7
- package/dist/render/map.d.ts.map +1 -1
- package/dist/render/public.d.ts +1 -1
- package/dist/render/xdot-public.d.ts +1 -1
- package/dist/render.js +226 -40
- package/dist/render.js.map +3 -3
- package/package.json +10 -9
- package/src/api/index.ts +2 -2
- package/src/common/textmeasure-factory.ts +1 -1
- package/src/errors.ts +1 -1
- package/src/gvc/anchor.ts +10 -2
- package/src/gvc/device.ts +10 -3
- package/src/index.ts +3 -3
- package/src/layout/neato/init.ts +13 -2
- package/src/layout/twopi/init.ts +7 -2
- package/src/render/index.ts +2 -2
- package/src/render/map.ts +295 -32
- package/src/render/public.ts +1 -1
- package/src/render/svg-graph.ts +1 -1
- package/src/render/xdot-public.ts +1 -1
- package/dist/bundle.js +0 -101
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@knowvah/dot-engine",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Faithful TypeScript port of Graphviz (dot, neato, fdp, sfdp, circo, twopi, osage, patchwork) — no C: no native binary, no WASM. Oracle-verified against the C implementation, runs in the browser.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"graphviz",
|
|
@@ -66,13 +66,12 @@
|
|
|
66
66
|
"survey:dashboard": "tsx test/corpus/dashboard.ts",
|
|
67
67
|
"survey:baseline": "GV_TEXT_MEASURER=lut tsx test/corpus/survey.ts",
|
|
68
68
|
"docs:copy-reports": "node docs-site/copy-reports.mjs",
|
|
69
|
+
"docs:copy-goldens": "node docs-site/copy-goldens.mjs",
|
|
69
70
|
"docs:api": "typedoc",
|
|
70
|
-
"docs:dev": "npm run docs:copy-reports && npm run docs:api && vitepress dev docs-site",
|
|
71
|
-
"docs:build": "npm run docs:copy-reports && npm run docs:api && vitepress build docs-site",
|
|
71
|
+
"docs:dev": "npm run docs:copy-reports && npm run docs:copy-goldens && npm run docs:api && vitepress dev docs-site",
|
|
72
|
+
"docs:build": "npm run docs:copy-reports && npm run docs:copy-goldens && npm run docs:api && vitepress build docs-site",
|
|
72
73
|
"docs:preview": "vitepress preview docs-site",
|
|
73
|
-
"
|
|
74
|
-
"version-packages": "changeset version",
|
|
75
|
-
"release": "npm run build && changeset publish",
|
|
74
|
+
"release": "semantic-release",
|
|
76
75
|
"publish:check": "npm run typecheck && npm run build && npm pack --dry-run",
|
|
77
76
|
"prepublishOnly": "npm run typecheck && npm run build"
|
|
78
77
|
},
|
|
@@ -88,7 +87,10 @@
|
|
|
88
87
|
}
|
|
89
88
|
},
|
|
90
89
|
"devDependencies": {
|
|
91
|
-
"@
|
|
90
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
91
|
+
"@semantic-release/github": "^12.0.9",
|
|
92
|
+
"@semantic-release/npm": "^13.1.5",
|
|
93
|
+
"@semantic-release/release-notes-generator": "^14.1.1",
|
|
92
94
|
"@types/fontkit": "^2.0.9",
|
|
93
95
|
"@types/node": "^26.0.0",
|
|
94
96
|
"@typescript/native-preview": "^7.0.0-dev.20260622.1",
|
|
@@ -97,14 +99,13 @@
|
|
|
97
99
|
"canvas": "^3.2.3",
|
|
98
100
|
"esbuild": "^0.28.1",
|
|
99
101
|
"fontkit": "^2.0.4",
|
|
100
|
-
"mermaid": "^11.16.0",
|
|
101
102
|
"peggy": "^5.0.0",
|
|
103
|
+
"semantic-release": "^25.0.8",
|
|
102
104
|
"typedoc": "^0.28.20",
|
|
103
105
|
"typedoc-plugin-markdown": "^4.12.0",
|
|
104
106
|
"typedoc-vitepress-theme": "^1.1.3",
|
|
105
107
|
"typescript": "^6.0.0",
|
|
106
108
|
"vitepress": "^1.6.0",
|
|
107
|
-
"vitepress-plugin-mermaid": "^2.0.17",
|
|
108
109
|
"vitest": "^4.1.9"
|
|
109
110
|
},
|
|
110
111
|
"volta": {
|
package/src/api/index.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* `@knowvah/dot-engine/api` — the build + inspect + geometry entry point (ADR-2).
|
|
5
5
|
*
|
|
6
6
|
* Pure re-export barrel for the api layer: programmatic graph construction
|
|
7
7
|
* (the builder), the safe edge helper, and the computed-geometry snapshot.
|
|
8
8
|
*
|
|
9
9
|
* Typical flow: call {@link createGraph} to build nodes/edges/subgraphs
|
|
10
10
|
* without hand-writing DOT text, hand the builder's `.graph` (or a
|
|
11
|
-
* `parse()` result) to `render()` from
|
|
11
|
+
* `parse()` result) to `render()` from `@knowvah/dot-engine/render` (or the root
|
|
12
12
|
* package) to lay out and emit an output format, then optionally call
|
|
13
13
|
* {@link getLayout} here to read back computed node/edge/cluster geometry
|
|
14
14
|
* from the laid-out graph.
|
|
@@ -59,7 +59,7 @@ function adviseHostFaithful(): void {
|
|
|
59
59
|
if (process.env?.GV_FONT_QUIET || !process.stderr?.isTTY) return;
|
|
60
60
|
adviceShown = true;
|
|
61
61
|
process.stderr.write(
|
|
62
|
-
'
|
|
62
|
+
'@knowvah/dot-engine: measuring text with built-in metrics (kerning/shaping not applied). '
|
|
63
63
|
+ 'For host-faithful sizing matching the rendering font, install `canvas` and call '
|
|
64
64
|
+ "setTextMeasurer(new CanvasTextMeasurer(createCanvas(0,0).getContext('2d'))). "
|
|
65
65
|
+ 'Silence with GV_FONT_QUIET=1.\n',
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Public structured-error contract for
|
|
4
|
+
* Public structured-error contract for @knowvah/dot-engine.
|
|
5
5
|
*
|
|
6
6
|
* Consumers branch on the stable `code` / `type` fields; the
|
|
7
7
|
* `code -> friendlyMessage` map is the single seam a future i18n library
|
package/src/gvc/anchor.ts
CHANGED
|
@@ -392,8 +392,16 @@ export function computeNodeUrlMap(n: Node, obj: ObjState, ctx: MapCtx): void {
|
|
|
392
392
|
* @see lib/common/emit.c:emit_page (3623-3642)
|
|
393
393
|
*/
|
|
394
394
|
export function computeGraphUrlMap(obj: ObjState, ctx: MapCtx): void {
|
|
395
|
-
|
|
396
|
-
|
|
395
|
+
// C uses job->clip — the 0-BASED page window ([-pad, (UR-LL)+pad] in graph
|
|
396
|
+
// coords), not bb±pad. Identical when bb.LL is the origin, but engines that
|
|
397
|
+
// keep a negative bb.LL (fdp graph labels: bb "0,-24.8,...") shift the root
|
|
398
|
+
// rect: native url.gv fdp emits 0,-33,539,374 where bb±pad would give
|
|
399
|
+
// 0,0,539,407. @see lib/common/emit.c:3652 emit_map_rect(job, job->clip)
|
|
400
|
+
const ll = { x: -ctx.pad.x, y: -ctx.pad.y };
|
|
401
|
+
const ur = {
|
|
402
|
+
x: ctx.bb.ur.x - ctx.bb.ll.x + ctx.pad.x,
|
|
403
|
+
y: ctx.bb.ur.y - ctx.bb.ll.y + ctx.pad.y,
|
|
404
|
+
};
|
|
397
405
|
obj.urlMapShape = MapShape.Rectangle;
|
|
398
406
|
obj.urlMapPts = [mapTransform(ll, ctx), mapTransform(ur, ctx)];
|
|
399
407
|
}
|
package/src/gvc/device.ts
CHANGED
|
@@ -50,7 +50,7 @@ import { emitRoundedBezier } from '../common/poly-shapes.js';
|
|
|
50
50
|
import { applyClusterObjState, clusterStyle, clusterPeripheries } from './device-cluster.js';
|
|
51
51
|
/** Cluster labels go through the single emit_label port. @see labels.c:emit_label */
|
|
52
52
|
export function renderClusterLabel(sg: Graph, renderer: RendererPlugin, job: RenderJob): void {
|
|
53
|
-
renderOneLabel(sg.info.label as TextlabelT | undefined, renderer, job);
|
|
53
|
+
renderOneLabel(sg.info.label as TextlabelT | undefined, renderer, job, false);
|
|
54
54
|
}
|
|
55
55
|
import { svgNodeId, svgEdgeId, svgClusterId, svgGraphId } from '../render/svg-id.js';
|
|
56
56
|
|
|
@@ -302,8 +302,15 @@ export function renderOneLabel(
|
|
|
302
302
|
lp: TextlabelT | undefined,
|
|
303
303
|
renderer: RendererPlugin,
|
|
304
304
|
job: RenderJob,
|
|
305
|
+
requireSet = true,
|
|
305
306
|
): void {
|
|
306
|
-
|
|
307
|
+
// C gates `->set` at the xlabel/edge-label CALL SITES (emit.c:1829,
|
|
308
|
+
// emit_edge_label:2891), but draws root-graph and cluster labels on
|
|
309
|
+
// existence alone (emit.c:3656, 3920) — an unplaced label (e.g. fdp's
|
|
310
|
+
// non-comparable-clusters abort skips gv_postprocess) still renders at its
|
|
311
|
+
// default pos. Callers mirroring the existence-only sites pass false.
|
|
312
|
+
if (!lp) return;
|
|
313
|
+
if (requireSet && !lp.set) return;
|
|
307
314
|
// HTML branch: @see lib/common/labels.c:emit_label (226-230)
|
|
308
315
|
// C routes to emit_html_label(job, lp->u.html, lp) using lp->pos as anchor.
|
|
309
316
|
if (lp.html) {
|
|
@@ -346,7 +353,7 @@ export function renderNodeXLabel(n: Node, renderer: RendererPlugin, job: RenderJ
|
|
|
346
353
|
export function renderGraphLabel(g: Graph, renderer: RendererPlugin, job: RenderJob): void {
|
|
347
354
|
// @see lib/common/emit.c:emit_begin_graph / getObjId (root graph → graph0)
|
|
348
355
|
setHtmlAnchorObj(svgGraphId(g, job), labelTextOf(g.info.label), job.obj ?? undefined);
|
|
349
|
-
renderOneLabel(g.info.label as TextlabelT | undefined, renderer, job);
|
|
356
|
+
renderOneLabel(g.info.label as TextlabelT | undefined, renderer, job, false);
|
|
350
357
|
}
|
|
351
358
|
|
|
352
359
|
// ---------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Public entry point for the
|
|
4
|
+
* Public entry point for the @knowvah/dot-engine library.
|
|
5
5
|
*
|
|
6
6
|
* Wires together the parser, layout engines, SVG renderer, and GVC
|
|
7
7
|
* orchestration layer into a single renderSvg function.
|
|
@@ -154,8 +154,8 @@ export type { BuiltinEngine, EngineName } from './gvc/context.js';
|
|
|
154
154
|
export { render as renderWithContext } from './gvc/device.js';
|
|
155
155
|
|
|
156
156
|
// Discoverable root re-exports of the api + render surfaces (ADR-2): root
|
|
157
|
-
//
|
|
158
|
-
//
|
|
157
|
+
// `@knowvah/dot-engine` exposes everything from `@knowvah/dot-engine/api` and
|
|
158
|
+
// `@knowvah/dot-engine/render` for one-import discoverability.
|
|
159
159
|
//
|
|
160
160
|
// Collision resolution: the root `render` is the new public
|
|
161
161
|
// `render(g, format, opts?)` from `./render`. The low-level
|
package/src/layout/neato/init.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* @see lib/neatogen/neatoprocs.h
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import type { ShapeDesc } from '../../common/types.js';
|
|
12
13
|
import type { Graph } from '../../model/graph.js';
|
|
13
14
|
import type { Node } from '../../model/node.js';
|
|
14
15
|
import type { Edge } from '../../model/edge.js';
|
|
@@ -85,8 +86,18 @@ export function neatoInitNode(n: Node, dim = DFLT_DIM): void {
|
|
|
85
86
|
n.info.pos = pos;
|
|
86
87
|
}
|
|
87
88
|
if (n.info.UF_size === undefined) n.info.UF_size = 1;
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
// C neato_init_node has NO size defaulting (neatoinit.c:60-66) — after
|
|
90
|
+
// common_init_node, ND_width is always written (late_double clamps a 0
|
|
91
|
+
// attr to the 0.75 default; every initfn assigns). The port's NodeInfo
|
|
92
|
+
// zero-initializes width/height (calloc mirror) and two port-only paths
|
|
93
|
+
// leave them at that unset-0 (no-measurer initNodeDefaults; poly-null
|
|
94
|
+
// custom/epsf shapes — C gives customs box geometry, a modeling gap
|
|
95
|
+
// flagged in the journal), so a fallback is still needed. The ONE case
|
|
96
|
+
// where 0 is a legitimate post-init size is shape=plain (poly_init
|
|
97
|
+
// IS_PLAIN, shapes.c:1962) — never clobber it.
|
|
98
|
+
const plainShaped = (n.info.shape as ShapeDesc | undefined)?.name === 'plain';
|
|
99
|
+
if (!n.info.width && !plainShaped) n.info.width = 0.75;
|
|
100
|
+
if (!n.info.height && !plainShaped) n.info.height = 0.5;
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
// ---------------------------------------------------------------------------
|
package/src/layout/twopi/init.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* @see lib/twopigen/twopiinit.c
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import type { ShapeDesc } from '../../common/types.js';
|
|
11
12
|
import type { Graph } from '../../model/graph.js';
|
|
12
13
|
import type { Node } from '../../model/node.js';
|
|
13
14
|
import type { Edge } from '../../model/edge.js';
|
|
@@ -47,8 +48,12 @@ function makeRdata(INF: number): TwopiAlgData {
|
|
|
47
48
|
*/
|
|
48
49
|
export function twopiInitNode(n: Node): void {
|
|
49
50
|
if (!n.info.pos || n.info.pos.length < 2) n.info.pos = [0, 0];
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
// shape=plain legitimately sizes 0×0 (poly_init IS_PLAIN) — never clobber
|
|
52
|
+
// it; all other zero widths are the port's unset-0. See neatoInitNode's
|
|
53
|
+
// note for the full rationale. @see lib/neatogen/neatoinit.c:60-66
|
|
54
|
+
const plainShaped = (n.info.shape as ShapeDesc | undefined)?.name === 'plain';
|
|
55
|
+
if (!n.info.width && !plainShaped) n.info.width = 0.75;
|
|
56
|
+
if (!n.info.height && !plainShaped) n.info.height = 0.5;
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
/**
|
package/src/render/index.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// SPDX-License-Identifier: EPL-2.0
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* `@knowvah/dot-engine/render` — the output-formats + draw-ops entry point (ADR-2).
|
|
5
5
|
*
|
|
6
6
|
* Pure re-export barrel for the public render surface: the multi-format
|
|
7
7
|
* `render()` entry and the structured xdot draw-op access.
|
|
8
8
|
*
|
|
9
|
-
* Typical flow: build or `parse()` a graph (see
|
|
9
|
+
* Typical flow: build or `parse()` a graph (see `@knowvah/dot-engine/api`), then
|
|
10
10
|
* either call `render(g, format, opts?)` for a serialized output string —
|
|
11
11
|
* `format` is one of {@link OutputFormat} (`'svg'`, `'dot'`, `'xdot'`,
|
|
12
12
|
* `'json'`, `'plain'`, ...) — or call {@link getDrawOps} to skip string
|
package/src/render/map.ts
CHANGED
|
@@ -16,8 +16,11 @@ import type { Edge } from '../model/edge.js';
|
|
|
16
16
|
import type { Point, Box } from '../model/geom.js';
|
|
17
17
|
import { POINTS_PER_INCH } from '../model/geom.js';
|
|
18
18
|
import type { TextSpan } from '../common/emit-types.js';
|
|
19
|
-
import type
|
|
19
|
+
import { ShapeKind, type ShapeDesc, type TextlabelT } from '../common/types.js';
|
|
20
20
|
import { lateDouble } from '../common/nodeinit.js';
|
|
21
|
+
import { nodeAttr } from '../common/poly-init.js';
|
|
22
|
+
import { htmlValueContent, isHtmlValue } from '../common/html-string.js';
|
|
23
|
+
import { nodesInSeq } from '../layout/dot/decomp.js';
|
|
21
24
|
import { substObjAnchor } from '../common/subst.js';
|
|
22
25
|
import type { RendererPlugin } from '../gvc/context.js';
|
|
23
26
|
import type { ObjState, RenderJob } from '../gvc/job.js';
|
|
@@ -58,13 +61,74 @@ export interface AnchorCtx {
|
|
|
58
61
|
// printG5 — @see lib/common/output.c:printdouble (%.5g)
|
|
59
62
|
// ---------------------------------------------------------------------------
|
|
60
63
|
|
|
61
|
-
/**
|
|
64
|
+
/** Exact decimal digits of a finite double: doubles are binary rationals, so
|
|
65
|
+
* the expansion terminates. Returns significant digits and the base-10
|
|
66
|
+
* exponent of the first digit (value = 0.d₁d₂… × 10^(exp10+1)). */
|
|
67
|
+
function exactDecimalDigits(v: number): { digits: string; exp10: number } {
|
|
68
|
+
const dv = new DataView(new ArrayBuffer(8));
|
|
69
|
+
dv.setFloat64(0, Math.abs(v));
|
|
70
|
+
const bits = dv.getBigUint64(0);
|
|
71
|
+
const expBits = Number((bits >> 52n) & 0x7ffn);
|
|
72
|
+
const mant = bits & 0xfffffffffffffn;
|
|
73
|
+
const m = expBits === 0 ? mant : mant | (1n << 52n);
|
|
74
|
+
const e = (expBits === 0 ? 1 : expBits) - 1075;
|
|
75
|
+
let intDigits: string;
|
|
76
|
+
let fracLen = 0;
|
|
77
|
+
if (e >= 0) {
|
|
78
|
+
intDigits = (m << BigInt(e)).toString();
|
|
79
|
+
} else {
|
|
80
|
+
intDigits = (m * 5n ** BigInt(-e)).toString();
|
|
81
|
+
fracLen = -e;
|
|
82
|
+
}
|
|
83
|
+
if (intDigits.length <= fracLen) {
|
|
84
|
+
intDigits = '0'.repeat(fracLen - intDigits.length + 1) + intDigits;
|
|
85
|
+
}
|
|
86
|
+
const pointPos = intDigits.length - fracLen; // digits before the decimal point
|
|
87
|
+
const firstSig = intDigits.search(/[1-9]/);
|
|
88
|
+
return { digits: intDigits.slice(firstSig), exp10: pointPos - firstSig - 1 };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Format with 5 significant figures, trailing zeros stripped — C snprintf
|
|
92
|
+
* `%.5g`. JS toPrecision rounds exact decimal ties away from zero, but C
|
|
93
|
+
* (round-to-nearest-even FP mode) rounds them to even, and integer point
|
|
94
|
+
* coordinates land on exact .x5 inch ties (e.g. 78498pt/72 = 1090.25 →
|
|
95
|
+
* "1090.2", not "1090.3"). Round from the exact expansion instead.
|
|
96
|
+
* @see lib/common/output.c:printdouble */
|
|
62
97
|
export function printG5(v: number): string {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
98
|
+
if (v === 0 || !Number.isFinite(v)) return String(v);
|
|
99
|
+
const P = 5;
|
|
100
|
+
const { digits, exp10 } = exactDecimalDigits(v);
|
|
101
|
+
let sig = digits.slice(0, P);
|
|
102
|
+
if (sig.length < P) sig += '0'.repeat(P - sig.length);
|
|
103
|
+
const rest = digits.slice(P);
|
|
104
|
+
const restNonzeroAfterFirst = /[1-9]/.test(rest.slice(1));
|
|
105
|
+
const roundUp = rest.length > 0 && (
|
|
106
|
+
rest[0]! > '5'
|
|
107
|
+
|| (rest[0] === '5' && restNonzeroAfterFirst)
|
|
108
|
+
|| (rest[0] === '5' && !restNonzeroAfterFirst
|
|
109
|
+
&& (sig.charCodeAt(P - 1) - 0x30) % 2 === 1));
|
|
110
|
+
let exp = exp10;
|
|
111
|
+
if (roundUp) {
|
|
112
|
+
const n = String(BigInt(sig) + 1n);
|
|
113
|
+
sig = n.length > P ? (exp++, n.slice(0, P)) : n;
|
|
114
|
+
}
|
|
115
|
+
const sign = v < 0 ? '-' : '';
|
|
116
|
+
// %g style selection: exponential when exp < -4 or exp >= precision.
|
|
117
|
+
if (exp < -4 || exp >= P) {
|
|
118
|
+
const mant = (sig[0]! + '.' + sig.slice(1)).replace(/\.?0+$/, '');
|
|
119
|
+
const as = Math.abs(exp);
|
|
120
|
+
return sign + mant + 'e' + (exp < 0 ? '-' : '+') + (as < 10 ? '0' : '') + String(as);
|
|
121
|
+
}
|
|
122
|
+
let s: string;
|
|
123
|
+
if (exp >= sig.length - 1) {
|
|
124
|
+
s = sig + '0'.repeat(exp - sig.length + 1);
|
|
125
|
+
} else if (exp >= 0) {
|
|
126
|
+
s = sig.slice(0, exp + 1) + '.' + sig.slice(exp + 1);
|
|
127
|
+
} else {
|
|
128
|
+
s = '0.' + '0'.repeat(-exp - 1) + sig;
|
|
66
129
|
}
|
|
67
|
-
|
|
130
|
+
if (s.includes('.')) s = s.replace(/\.?0+$/, '');
|
|
131
|
+
return sign + s;
|
|
68
132
|
}
|
|
69
133
|
|
|
70
134
|
/** Convert points → inches (PS2INCH = 1/72) and format as %.5g. */
|
|
@@ -72,44 +136,207 @@ export function plainCoord(v: number): string {
|
|
|
72
136
|
return printG5(v / 72);
|
|
73
137
|
}
|
|
74
138
|
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// agstrcanon — DOT-canonical string form for plain names/labels/ports.
|
|
141
|
+
// @see lib/cgraph/write.c:_agstrcanon / agstrcanon / agcanonhtmlstr
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** must agree with scan.l @see lib/cgraph/write.c:120 tokenlist */
|
|
145
|
+
const CANON_KEYWORDS = ['node', 'edge', 'strict', 'graph', 'digraph', 'subgraph'];
|
|
146
|
+
|
|
147
|
+
/** Line-break threshold. agwrite may override via `linelength` but always
|
|
148
|
+
* restores this value, so the plain path (direct agstrcanon, no agwrite)
|
|
149
|
+
* observes the default. @see lib/cgraph/write.c:44,676,692 */
|
|
150
|
+
const MAX_OUTPUTLINE = 128;
|
|
151
|
+
|
|
152
|
+
function isDigitByte(b: number): boolean { return b >= 0x30 && b <= 0x39; }
|
|
153
|
+
|
|
154
|
+
function isAlnumByte(b: number): boolean {
|
|
155
|
+
return isDigitByte(b) || (b >= 0x41 && b <= 0x5a) || (b >= 0x61 && b <= 0x7a);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** alphanumeric, '.', '-', or non-ascii byte. @see lib/cgraph/write.c:is_id_char */
|
|
159
|
+
function isIdCharByte(b: number): boolean {
|
|
160
|
+
return isAlnumByte(b) || b === 0x2e || b === 0x2d || b >= 0x80;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Recognized escString escape starting at bytes[i]. @see lib/cgraph/write.c:is_escape */
|
|
164
|
+
function isEscapeAt(bytes: Uint8Array, i: number): boolean {
|
|
165
|
+
if (bytes[i] !== 0x5c) return false;
|
|
166
|
+
const c = bytes[i + 1];
|
|
167
|
+
return c !== undefined && 'EGHLNTlnr\\"'.includes(String.fromCharCode(c));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The needs-quotes / numeral / escape state scan of `_agstrcanon`, over UTF-8
|
|
171
|
+
* BYTES (C iterates bytes: cnt counts bytes for line breaking, and non-ascii
|
|
172
|
+
* bytes are id chars). Returns the quoted buffer or the untouched input.
|
|
173
|
+
* @see lib/cgraph/write.c:_agstrcanon */
|
|
174
|
+
export function agstrcanonText(arg: string): string {
|
|
175
|
+
if (arg.length === 0) return '""';
|
|
176
|
+
const bytes = new TextEncoder().encode(arg);
|
|
177
|
+
const out: number[] = [0x22];
|
|
178
|
+
let needsQuotes = false;
|
|
179
|
+
let partOfEscape = false;
|
|
180
|
+
let backslashPending = false;
|
|
181
|
+
let cnt = 0;
|
|
182
|
+
let dotcnt = 0;
|
|
183
|
+
let maybeNum = isDigitByte(bytes[0]!) || bytes[0] === 0x2e || bytes[0] === 0x2d;
|
|
184
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
185
|
+
const uc = bytes[i]!;
|
|
186
|
+
if (uc === 0x22 && !partOfEscape) { // '"' not already part of an escape
|
|
187
|
+
out.push(0x5c);
|
|
188
|
+
needsQuotes = true;
|
|
189
|
+
} else if (!partOfEscape && isEscapeAt(bytes, i)) {
|
|
190
|
+
needsQuotes = true;
|
|
191
|
+
partOfEscape = true;
|
|
192
|
+
} else if (maybeNum) {
|
|
193
|
+
if (uc === 0x2d) { // '-' legal only as the first char of a numeral
|
|
194
|
+
if (cnt) { maybeNum = false; needsQuotes = true; }
|
|
195
|
+
} else if (uc === 0x2e) { // one '.' allowed
|
|
196
|
+
if (dotcnt++) { maybeNum = false; needsQuotes = true; }
|
|
197
|
+
} else if (!isDigitByte(uc)) {
|
|
198
|
+
maybeNum = false;
|
|
199
|
+
needsQuotes = true;
|
|
200
|
+
}
|
|
201
|
+
partOfEscape = false;
|
|
202
|
+
} else if (!(isAlnumByte(uc) || uc === 0x5f || uc >= 0x80)) {
|
|
203
|
+
needsQuotes = true;
|
|
204
|
+
partOfEscape = false;
|
|
205
|
+
} else {
|
|
206
|
+
partOfEscape = false;
|
|
207
|
+
}
|
|
208
|
+
out.push(uc);
|
|
209
|
+
cnt++;
|
|
210
|
+
const next = bytes[i + 1];
|
|
211
|
+
// Long-string line breaking: only after a non-id, non-backslash output
|
|
212
|
+
// char where the next input char is an id char. @see write.c:170-190
|
|
213
|
+
if (next !== undefined) {
|
|
214
|
+
const last = out[out.length - 1]!;
|
|
215
|
+
const canBreak = !(isIdCharByte(last) || last === 0x5c) && isIdCharByte(next);
|
|
216
|
+
if (backslashPending && canBreak) {
|
|
217
|
+
out.push(0x5c, 0x0a);
|
|
218
|
+
needsQuotes = true;
|
|
219
|
+
backslashPending = false;
|
|
220
|
+
cnt = 0;
|
|
221
|
+
} else if (cnt >= MAX_OUTPUTLINE) {
|
|
222
|
+
if (canBreak) {
|
|
223
|
+
out.push(0x5c, 0x0a);
|
|
224
|
+
needsQuotes = true;
|
|
225
|
+
cnt = 0;
|
|
226
|
+
} else {
|
|
227
|
+
backslashPending = true;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
out.push(0x22);
|
|
233
|
+
const first = bytes[0]!;
|
|
234
|
+
if (needsQuotes || (cnt === 1 && (first === 0x2e || first === 0x2d))) {
|
|
235
|
+
return new TextDecoder().decode(new Uint8Array(out));
|
|
236
|
+
}
|
|
237
|
+
// Quotes protect DOT keywords (e.g. a node named "node"). @see write.c:199-203
|
|
238
|
+
const lower = arg.toLowerCase();
|
|
239
|
+
for (const tok of CANON_KEYWORDS) {
|
|
240
|
+
if (tok === lower) return new TextDecoder().decode(new Uint8Array(out));
|
|
241
|
+
}
|
|
242
|
+
return arg;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** late_nnstring: default when the attr is missing OR empty.
|
|
246
|
+
* @see lib/common/utils.c:late_nnstring */
|
|
247
|
+
function lateNN(v: string | undefined, def: string): string {
|
|
248
|
+
return v !== undefined && v !== '' ? v : def;
|
|
249
|
+
}
|
|
250
|
+
|
|
75
251
|
/** Resolve fill color: fillcolor attr, then color attr, then lightgrey. */
|
|
76
|
-
export function plainNodeFill(n: Node): string {
|
|
77
|
-
|
|
78
|
-
|
|
252
|
+
export function plainNodeFill(n: Node, g: Graph): string {
|
|
253
|
+
// @see lib/common/output.c:write_plain (167-169): fillcolor attr if non-empty,
|
|
254
|
+
// else the color attr, else DEFAULT_FILL ("lightgrey"). Note the fallback is
|
|
255
|
+
// DEFAULT_FILL, not DEFAULT_COLOR — an unfilled node's plain fill field is
|
|
256
|
+
// "lightgrey", not "black".
|
|
257
|
+
const fillcolor = lateNN(nodeAttr(n, g, 'fillcolor'), '');
|
|
258
|
+
if (fillcolor !== '') return fillcolor;
|
|
259
|
+
return lateNN(nodeAttr(n, g, 'color'), 'lightgrey');
|
|
79
260
|
}
|
|
80
261
|
|
|
81
262
|
// ---------------------------------------------------------------------------
|
|
82
263
|
// Plain format helpers — @see lib/common/output.c:write_plain
|
|
83
264
|
// ---------------------------------------------------------------------------
|
|
84
265
|
|
|
266
|
+
/** The label field of a plain node line: HTML labels re-wrap the ORIGINAL
|
|
267
|
+
* label attr in `<...>` (agstrcanon's aghtmlstr branch on agxget(n, N_label));
|
|
268
|
+
* everything else — including record labels, whose textlabel keeps the raw
|
|
269
|
+
* unsubstituted source — canonicalizes ND_label(n)->text.
|
|
270
|
+
* @see lib/common/output.c:write_plain (152-158) */
|
|
271
|
+
function plainNodeLabel(n: Node, g: Graph): string {
|
|
272
|
+
const lbl = n.info.label as TextlabelT | undefined;
|
|
273
|
+
const attr = nodeAttr(n, g, 'label');
|
|
274
|
+
if (lbl !== undefined && lbl.u.kind === 'html') {
|
|
275
|
+
const a = attr ?? '';
|
|
276
|
+
return '<' + (isHtmlValue(a) ? htmlValueContent(a) : a) + '>';
|
|
277
|
+
}
|
|
278
|
+
// An html-valued attr with a NON-html label object = the HTML parse failed:
|
|
279
|
+
// the port's fallback label is empty (matching C's drawn spans), but C's
|
|
280
|
+
// ND_label->text still holds the raw markup, which plain prints quoted.
|
|
281
|
+
// @see lib/common/labels.c:make_label (html branch gv_strdup before parse)
|
|
282
|
+
if (attr !== undefined && isHtmlValue(attr) && lbl !== undefined && lbl.u.kind === 'txt') {
|
|
283
|
+
return agstrcanonText(htmlValueContent(attr));
|
|
284
|
+
}
|
|
285
|
+
// C record textlabels keep the raw UNSUBSTITUTED label source (make_label's
|
|
286
|
+
// is_record branch gv_strdup's it; substitution happens per-field at record
|
|
287
|
+
// parse). The port's record label resolves the default to the node name, so
|
|
288
|
+
// reconstruct C's text: the label attr, or cgraph's always-present N_label
|
|
289
|
+
// default "\N". @see lib/common/labels.c:make_label ; lib/common/input.c:468
|
|
290
|
+
const shape = n.info.shape as ShapeDesc | undefined;
|
|
291
|
+
if (shape !== undefined && shape.kind === ShapeKind.SH_RECORD) {
|
|
292
|
+
return agstrcanonText(nodeAttr(n, g, 'label') ?? '\\N');
|
|
293
|
+
}
|
|
294
|
+
const text = lbl !== undefined ? lbl.text : (n.attrs.get('label') ?? n.name);
|
|
295
|
+
return agstrcanonText(text);
|
|
296
|
+
}
|
|
297
|
+
|
|
85
298
|
/** Read the five style attrs needed for a plain node line. */
|
|
86
|
-
export function plainNodeAttrs(n: Node): PlainNodeAttrs {
|
|
299
|
+
export function plainNodeAttrs(n: Node, g: Graph): PlainNodeAttrs {
|
|
300
|
+
// style/shape/color resolve through the node-defaults chain (C agxget sees
|
|
301
|
+
// `node [...]` defaults). C prints ND_shape(n)->name, which bind_shape
|
|
302
|
+
// derives purely from attrs: a non-epsf shapefile forces "custom", unknown
|
|
303
|
+
// names keep the user's name (user_shape), default is "ellipse" — so the
|
|
304
|
+
// port's resolved-fallback ShapeDesc name must NOT be used here.
|
|
305
|
+
// @see lib/common/output.c:write_plain (163-166)
|
|
306
|
+
// @see lib/common/shapes.c:bind_shape / user_shape
|
|
307
|
+
const shapeAttr = lateNN(nodeAttr(n, g, 'shape'), 'ellipse');
|
|
308
|
+
const shapefile = nodeAttr(n, g, 'shapefile');
|
|
309
|
+
const shapeName =
|
|
310
|
+
shapefile !== undefined && shapefile !== '' && shapeAttr !== 'epsf' ? 'custom' : shapeAttr;
|
|
87
311
|
return {
|
|
88
|
-
label:
|
|
89
|
-
style: n
|
|
90
|
-
shape:
|
|
91
|
-
color: n
|
|
92
|
-
fill: plainNodeFill(n),
|
|
312
|
+
label: plainNodeLabel(n, g),
|
|
313
|
+
style: lateNN(nodeAttr(n, g, 'style'), 'solid'),
|
|
314
|
+
shape: shapeName,
|
|
315
|
+
color: lateNN(nodeAttr(n, g, 'color'), 'black'),
|
|
316
|
+
fill: plainNodeFill(n, g),
|
|
93
317
|
};
|
|
94
318
|
}
|
|
95
319
|
|
|
96
320
|
/** Write one node line: `node name x y w h label style shape color fill\n` */
|
|
97
|
-
export function writePlainNode(n: Node, out: string[]): void {
|
|
321
|
+
export function writePlainNode(n: Node, g: Graph, out: string[]): void {
|
|
98
322
|
const x = plainCoord(n.info.coord.x);
|
|
99
323
|
const y = plainCoord(n.info.coord.y);
|
|
100
324
|
const w = printG5(n.info.width);
|
|
101
325
|
const h = printG5(n.info.height);
|
|
102
|
-
const a = plainNodeAttrs(n);
|
|
103
|
-
out.push('node ' + n.name + ' ' + x + ' ' + y + ' ' + w + ' ' + h
|
|
326
|
+
const a = plainNodeAttrs(n, g);
|
|
327
|
+
out.push('node ' + agstrcanonText(n.name) + ' ' + x + ' ' + y + ' ' + w + ' ' + h
|
|
104
328
|
+ ' ' + a.label + ' ' + a.style + ' ' + a.shape + ' ' + a.color + ' ' + a.fill + '\n');
|
|
105
329
|
}
|
|
106
330
|
|
|
107
|
-
/** Flatten all Bezier curves in an edge spline into a point array.
|
|
331
|
+
/** Flatten all Bezier curves in an edge spline into a point array. Reads
|
|
332
|
+
* exactly bz.size points — routing can leave over-allocated scratch entries
|
|
333
|
+
* past size in bz.list (C sums ED_spl sizes, never the allocation).
|
|
334
|
+
* @see lib/common/output.c:write_plain (183-195) */
|
|
108
335
|
export function collectSplinePts(e: Edge): Point[] {
|
|
109
336
|
if (!e.info.spl) return [];
|
|
110
337
|
const pts: Point[] = [];
|
|
111
338
|
for (const bz of e.info.spl.list) {
|
|
112
|
-
for (
|
|
339
|
+
for (let i = 0; i < bz.size; i++) pts.push(bz.list[i]!);
|
|
113
340
|
}
|
|
114
341
|
return pts;
|
|
115
342
|
}
|
|
@@ -119,39 +346,75 @@ export function portSuffix(name: string | null): string {
|
|
|
119
346
|
return name ? ':' + name : '';
|
|
120
347
|
}
|
|
121
348
|
|
|
349
|
+
/** Write ` name[:port]`, both parts DOT-canonicalized. A cluster proxy node's
|
|
350
|
+
* synthetic `__i:<cluster>` id is written as the cluster name it stands for
|
|
351
|
+
* (C: strchr(agnameof(node), ':') + 1).
|
|
352
|
+
* @see lib/common/output.c:writenodeandport */
|
|
353
|
+
function writeNodeAndPort(n: Node, portname: string, out: string[]): void {
|
|
354
|
+
let name = n.name;
|
|
355
|
+
if (n.info.clustnode) {
|
|
356
|
+
const i = name.indexOf(':');
|
|
357
|
+
if (i >= 0) name = name.slice(i + 1);
|
|
358
|
+
}
|
|
359
|
+
out.push(' ' + agstrcanonText(name));
|
|
360
|
+
if (portname !== '') out.push(':' + agstrcanonText(portname));
|
|
361
|
+
}
|
|
362
|
+
|
|
122
363
|
/** Write the `edge tail head n pt...` prefix when spline data exists. */
|
|
123
364
|
export function writePlainEdgeHead(
|
|
124
365
|
e: Edge, tport: string, hport: string, pts: Point[], out: string[],
|
|
125
366
|
): void {
|
|
126
|
-
out.push('edge
|
|
127
|
-
|
|
367
|
+
out.push('edge');
|
|
368
|
+
writeNodeAndPort(e.tail, tport, out);
|
|
369
|
+
writeNodeAndPort(e.head, hport, out);
|
|
370
|
+
out.push(' ' + String(pts.length));
|
|
128
371
|
for (const pt of pts) {
|
|
129
372
|
out.push(' ' + plainCoord(pt.x) + ' ' + plainCoord(pt.y));
|
|
130
373
|
}
|
|
131
374
|
}
|
|
132
375
|
|
|
133
|
-
/** Write one edge — spline prefix if available,
|
|
376
|
+
/** Write one edge — spline prefix if available, then the edge label (when
|
|
377
|
+
* present), always appends `style color\n`. plain-ext ports come from the
|
|
378
|
+
* tailport/headport ATTRS (C agget), which keep any `:compass` suffix the
|
|
379
|
+
* resolved port objects have already split off.
|
|
380
|
+
* @see lib/common/output.c:write_plain (200-208) */
|
|
134
381
|
export function writePlainEdge(e: Edge, extend: boolean, out: string[]): void {
|
|
135
|
-
const tport = extend ?
|
|
136
|
-
const hport = extend ?
|
|
382
|
+
const tport = extend ? (e.attrs.get('tailport') ?? '') : '';
|
|
383
|
+
const hport = extend ? (e.attrs.get('headport') ?? '') : '';
|
|
137
384
|
const pts = collectSplinePts(e);
|
|
138
385
|
if (pts.length > 0) writePlainEdgeHead(e, tport, hport, pts, out);
|
|
139
|
-
|
|
140
|
-
|
|
386
|
+
// Edge label: canon(text) then position, mirroring `if (ED_label(e)) {
|
|
387
|
+
// printstring(canon(...)); printpoint(pos) }`.
|
|
388
|
+
const lbl = e.info.label;
|
|
389
|
+
if (lbl !== undefined) {
|
|
390
|
+
out.push(' ' + agstrcanonText(lbl.text)
|
|
391
|
+
+ ' ' + plainCoord(lbl.pos.x) + ' ' + plainCoord(lbl.pos.y));
|
|
392
|
+
}
|
|
393
|
+
const style = lateNN(e.attrs.get('style'), 'solid');
|
|
394
|
+
const color = lateNN(e.attrs.get('color'), 'black');
|
|
141
395
|
out.push(' ' + style + ' ' + color + '\n');
|
|
142
396
|
}
|
|
143
397
|
|
|
144
|
-
/** Write the full plain output: graph header, nodes, edges, stop.
|
|
398
|
+
/** Write the full plain output: graph header, nodes, edges, stop.
|
|
399
|
+
* Node iteration is agfstnode/agnxtnode (AGSEQ) order, not insertion-Map
|
|
400
|
+
* order; the graph line's scale is job->zoom — the size= fit factor, computed
|
|
401
|
+
* with the dot renderer's pad of 0 (render_features_dot).
|
|
402
|
+
* @see lib/common/output.c:write_plain
|
|
403
|
+
* @see plugin/core/gvrender_core_dot.c:render_features_dot */
|
|
145
404
|
export function writePlain(g: Graph, job: RenderJob, extend: boolean): void {
|
|
146
405
|
const w = plainCoord(g.info.bb.ur.x);
|
|
147
406
|
const h = plainCoord(g.info.bb.ur.y);
|
|
148
|
-
|
|
149
|
-
|
|
407
|
+
const zoom = initJobViewportZoom(
|
|
408
|
+
job.bb, parseDrawingSize(g.attrs.get('size')), { x: 0, y: 0 });
|
|
409
|
+
job.write('graph ' + printG5(zoom) + ' ' + w + ' ' + h + '\n');
|
|
410
|
+
const nodes = nodesInSeq(g);
|
|
411
|
+
for (const n of nodes) {
|
|
412
|
+
if (n.info.clustnode) continue; // IS_CLUST_NODE — cluster proxies get no node line
|
|
150
413
|
const buf: string[] = [];
|
|
151
|
-
writePlainNode(n, buf);
|
|
414
|
+
writePlainNode(n, g, buf);
|
|
152
415
|
job.write(buf.join(''));
|
|
153
416
|
}
|
|
154
|
-
for (const
|
|
417
|
+
for (const n of nodes) {
|
|
155
418
|
for (const e of n.outEdges(g)) {
|
|
156
419
|
const buf: string[] = [];
|
|
157
420
|
writePlainEdge(e, extend, buf);
|
package/src/render/public.ts
CHANGED
|
@@ -53,7 +53,7 @@ export interface RenderOptions {
|
|
|
53
53
|
* `false` — unset reproduces the pre-AD-1 raw `xlink:href="src"`
|
|
54
54
|
* passthrough byte-for-byte. When `true`, the SVG emitter consults the
|
|
55
55
|
* process-global resolver registered via `setImageResolver` (from
|
|
56
|
-
*
|
|
56
|
+
* `@knowvah/dot-engine`) for each `image=`/HTML `<IMG>` source; a hit is inlined
|
|
57
57
|
* as `data:<mime>;base64,<...>`, a miss (or no resolver registered) falls
|
|
58
58
|
* back to the raw src passthrough. Has no effect on non-SVG formats.
|
|
59
59
|
*/
|