@knowvah/dot-engine 1.1.1 → 1.2.1
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 +32 -17
- package/dist/api.js +24 -0
- package/dist/api.js.map +2 -2
- package/dist/common/arrows.d.ts.map +1 -1
- package/dist/common/edge-label-init.d.ts.map +1 -1
- package/dist/common/htmltable-emit.d.ts +0 -8
- package/dist/common/htmltable-emit.d.ts.map +1 -1
- package/dist/common/ps-fontalias.d.ts +22 -9
- package/dist/common/ps-fontalias.d.ts.map +1 -1
- package/dist/common/splines-clip.d.ts.map +1 -1
- package/dist/gvc/device.d.ts.map +1 -1
- package/dist/gvc/job.d.ts +4 -0
- package/dist/gvc/job.d.ts.map +1 -1
- package/dist/index.js +1981 -1533
- package/dist/index.js.map +4 -4
- package/dist/layout/dot/index.d.ts +1 -1
- package/dist/layout/dot/index.d.ts.map +1 -1
- package/dist/layout/sfdp/init.d.ts.map +1 -1
- package/dist/model/graph.d.ts +24 -0
- package/dist/model/graph.d.ts.map +1 -1
- package/dist/parser/builder.d.ts +32 -0
- package/dist/parser/builder.d.ts.map +1 -1
- package/dist/render/dot/agwrite.d.ts +186 -0
- package/dist/render/dot/agwrite.d.ts.map +1 -0
- package/dist/render/dot/attrs.d.ts +220 -0
- package/dist/render/dot/attrs.d.ts.map +1 -0
- package/dist/render/dot/edge-draw.d.ts +60 -0
- package/dist/render/dot/edge-draw.d.ts.map +1 -0
- package/dist/render/dot/types.d.ts +28 -0
- package/dist/render/dot/types.d.ts.map +1 -0
- package/dist/render/dot/xdot-ops.d.ts +206 -0
- package/dist/render/dot/xdot-ops.d.ts.map +1 -0
- package/dist/render/dot.d.ts +58 -277
- package/dist/render/dot.d.ts.map +1 -1
- package/dist/render/map.d.ts +14 -1
- package/dist/render/map.d.ts.map +1 -1
- package/dist/render/svg-graph.d.ts.map +1 -1
- package/dist/render.js +1942 -1494
- package/dist/render.js.map +4 -4
- package/package.json +1 -1
- package/src/common/arrows.ts +17 -4
- package/src/common/edge-label-init.ts +9 -2
- package/src/common/htmltable-emit.ts +36 -8
- package/src/common/poly-gencode.ts +8 -8
- package/src/common/ps-fontalias.ts +80 -49
- package/src/common/splines-clip.ts +36 -10
- package/src/gvc/device.ts +18 -6
- package/src/gvc/job.ts +4 -0
- package/src/layout/dot/index.ts +37 -5
- package/src/layout/sfdp/init.ts +11 -0
- package/src/model/graph.ts +26 -0
- package/src/parser/builder.ts +90 -5
- package/src/render/dot/agwrite.ts +506 -0
- package/src/render/dot/attrs.ts +437 -0
- package/src/render/dot/edge-draw.ts +203 -0
- package/src/render/dot/types.ts +32 -0
- package/src/render/dot/xdot-ops.ts +432 -0
- package/src/render/dot.ts +114 -1131
- package/src/render/map.ts +18 -1
- package/src/render/svg-graph.ts +4 -0
- package/src/render/svg-helpers.ts +1 -1
package/src/parser/builder.ts
CHANGED
|
@@ -164,8 +164,51 @@ export class StmtProcessor {
|
|
|
164
164
|
*/
|
|
165
165
|
private anonCounter = 0;
|
|
166
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Strict-graph edge index: canonical endpoint-pair key → the edge already
|
|
169
|
+
* created for it. `agedge` on a strict graph probes for a pre-existing edge
|
|
170
|
+
* and returns it instead of creating a second one, so `strict digraph
|
|
171
|
+
* { a->b; a->b }` holds ONE edge. A Map (rather than scanning root.edges)
|
|
172
|
+
* keeps that probe O(1); it stays empty for non-strict graphs.
|
|
173
|
+
* @see lib/cgraph/edge.c:agedge (agfindedge_by_key probe, :262-287)
|
|
174
|
+
*/
|
|
175
|
+
private readonly strictEdges = new Map<string, Edge>();
|
|
176
|
+
|
|
167
177
|
constructor(private readonly registry: NodeRegistry) {}
|
|
168
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Canonical key for a strict-graph endpoint pair. C probes (t,h) and then,
|
|
181
|
+
* for an UNDIRECTED graph, (h,t) — so an undirected pair matches either way
|
|
182
|
+
* round and is canonicalized here by node id; a directed pair is not.
|
|
183
|
+
* @see lib/cgraph/edge.c:agedge:274-275 (`if (e == NULL && agisundirected(g))`)
|
|
184
|
+
*/
|
|
185
|
+
private static strictKey(tail: Node, head: Node, undirected: boolean): string {
|
|
186
|
+
const swap = undirected && head.id < tail.id;
|
|
187
|
+
return swap ? `${head.id}\u0000${tail.id}` : `${tail.id}\u0000${head.id}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Apply the DOT-syntax `:port:compass` endpoints to an edge's attrs.
|
|
192
|
+
*
|
|
193
|
+
* C's precedence is explicit attr > `:port` syntax > edge default (verified
|
|
194
|
+
* against the oracle: with `edge [headport=n]` declared, `a:e -> b:sw` yields
|
|
195
|
+
* headport `sw`, while `c:e -> d:sw [headport=w]` yields `w`). The guard is
|
|
196
|
+
* therefore against THIS statement's own attribute list — not the edge's
|
|
197
|
+
* accumulated map, which also holds inherited defaults and, for a strict
|
|
198
|
+
* duplicate, the earlier statement's values.
|
|
199
|
+
* @see lib/cgraph/grammar.y:396 (mkport)
|
|
200
|
+
*/
|
|
201
|
+
private static applySyntaxPorts(
|
|
202
|
+
target: Map<string, string>,
|
|
203
|
+
stmtAttrs: AttrPair[],
|
|
204
|
+
tailPort: string,
|
|
205
|
+
headPort: string,
|
|
206
|
+
): void {
|
|
207
|
+
const setByStmt = (k: string): boolean => stmtAttrs.some((a) => a.key === k);
|
|
208
|
+
if (tailPort && !setByStmt('tailport')) target.set('tailport', tailPort);
|
|
209
|
+
if (headPort && !setByStmt('headport')) target.set('headport', headPort);
|
|
210
|
+
}
|
|
211
|
+
|
|
169
212
|
/** Consume one anonymous id, returning cgraph's `2*counter+1`. */
|
|
170
213
|
private nextAnonId(): number {
|
|
171
214
|
return this.anonCounter++ * 2 + 1;
|
|
@@ -185,13 +228,23 @@ export class StmtProcessor {
|
|
|
185
228
|
graph.attrs.set(stmt.key, normaliseAttrValue(stmt.value));
|
|
186
229
|
// cgraph: assigning a graph attr on any (sub)graph declares it graph-wide
|
|
187
230
|
// with an empty default → the root sees "" for it. @see agattr declaration.
|
|
188
|
-
|
|
231
|
+
this.declareGraphAttr(graph, stmt.key);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Record a graph-attribute declaration, noting whether THIS scope was the
|
|
235
|
+
* first to declare the key anywhere — the branch selector in cgraph's
|
|
236
|
+
* setattr. @see lib/cgraph/attr.c:257 · model/graph.ts firstGraphDecl */
|
|
237
|
+
private declareGraphAttr(graph: Graph, key: string): void {
|
|
238
|
+
if (!graph.root.declaredGraphAttrs.has(key)) {
|
|
239
|
+
(graph.firstGraphDecl ??= new Set()).add(key);
|
|
240
|
+
graph.root.declaredGraphAttrs.add(key);
|
|
241
|
+
}
|
|
189
242
|
}
|
|
190
243
|
|
|
191
244
|
processAttr(stmt: AttrStmt, graph: Graph): void {
|
|
192
245
|
if (stmt.target === 'graph') {
|
|
193
246
|
applyAttrs(stmt.attrs, graph.attrs);
|
|
194
|
-
for (const a of stmt.attrs)
|
|
247
|
+
for (const a of stmt.attrs) this.declareGraphAttr(graph, a.key);
|
|
195
248
|
} else if (stmt.target === 'node') {
|
|
196
249
|
applyAttrs(stmt.attrs, graph.nodeDefaults);
|
|
197
250
|
} else {
|
|
@@ -261,9 +314,16 @@ export class StmtProcessor {
|
|
|
261
314
|
// copy attrs but not the snapshot. Mirrors cgraph's agsubg defval copy; kept
|
|
262
315
|
// to the keys do_graph_label reads so the blast radius stays in label render.
|
|
263
316
|
// @see lib/common/input.c:do_graph_label (agget label/font*)
|
|
317
|
+
// The seeded keys are recorded so the -Tdot serializer can tell them from a
|
|
318
|
+
// genuine local declaration: cgraph gives a local declaration its own dict
|
|
319
|
+
// symbol (printed by write_dict) even when the value equals the inherited
|
|
320
|
+
// one, whereas a seeded value must stay invisible. @see src/render/dot.ts
|
|
264
321
|
for (const key of GRAPH_LABEL_INHERIT_KEYS) {
|
|
265
322
|
const v = sg.graphDefaultsSnapshot.get(key);
|
|
266
|
-
if (v !== undefined && !sg.attrs.has(key))
|
|
323
|
+
if (v !== undefined && !sg.attrs.has(key)) {
|
|
324
|
+
sg.attrs.set(key, v);
|
|
325
|
+
(sg.seededAttrs ??= new Set()).add(key);
|
|
326
|
+
}
|
|
267
327
|
}
|
|
268
328
|
graph.subgraphs.set(sgName, sg);
|
|
269
329
|
return sg;
|
|
@@ -342,14 +402,39 @@ export class StmtProcessor {
|
|
|
342
402
|
// DOT-syntax ports land in tailport/headport attrs; explicit attrs win.
|
|
343
403
|
const tailPort = tailEnd.port;
|
|
344
404
|
const headPort = headEnd.port;
|
|
405
|
+
const strict = root.kind === 'strict-directed' || root.kind === 'strict-undirected';
|
|
406
|
+
const undirected = root.kind === 'strict-undirected';
|
|
345
407
|
for (const tail of tailEnd.nodes) {
|
|
346
408
|
for (const head of headEnd.nodes) {
|
|
409
|
+
const key = strict ? StmtProcessor.strictKey(tail, head, undirected) : '';
|
|
410
|
+
const existing = strict ? this.strictEdges.get(key) : undefined;
|
|
411
|
+
if (existing !== undefined) {
|
|
412
|
+
// A strict duplicate is NOT a new edge: agedge returns the existing
|
|
413
|
+
// one, so this statement's attributes land on it. It returns before
|
|
414
|
+
// agmapnametoid reserves an id, so the anon counter must NOT advance
|
|
415
|
+
// here — that counter drives sibling subgraphs' `%N` names.
|
|
416
|
+
// @see lib/cgraph/edge.c:agedge:276-277
|
|
417
|
+
applyAttrs(attrs, existing.attrs);
|
|
418
|
+
StmtProcessor.applySyntaxPorts(existing.attrs, attrs, tailPort, headPort);
|
|
419
|
+
// subedge: the pre-existing edge joins any enclosing subgraph that
|
|
420
|
+
// does not already hold it. @see lib/cgraph/edge.c:agedge:283
|
|
421
|
+
for (let g: Graph | null = graph; g !== null && g !== root; g = g.parent) {
|
|
422
|
+
g.nodes.set(tail.name, tail);
|
|
423
|
+
g.nodes.set(head.name, head);
|
|
424
|
+
if (!g.edges.includes(existing)) g.edges.push(existing);
|
|
425
|
+
}
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
347
428
|
const edge = new Edge(tail, head, '');
|
|
348
429
|
this.advanceAnonId(); // keyless edge = anonymous cgraph id (see method)
|
|
349
430
|
applyAttrs(attrs, edge.attrs);
|
|
431
|
+
// Syntax ports BEFORE the defaults snapshot: C's precedence is
|
|
432
|
+
// explicit attr > `:port` syntax > edge default. Seeding the defaults
|
|
433
|
+
// first made the `!has` guard below true for a declared
|
|
434
|
+
// `edge [headport=…]`, silently dropping every `a:w -> b:e` port.
|
|
435
|
+
StmtProcessor.applySyntaxPorts(edge.attrs, attrs, tailPort, headPort);
|
|
350
436
|
this.snapshotEdgeDefaults(edge, graph);
|
|
351
|
-
if (
|
|
352
|
-
if (headPort && !edge.attrs.has('headport')) edge.attrs.set('headport', headPort);
|
|
437
|
+
if (strict) this.strictEdges.set(key, edge);
|
|
353
438
|
root.edges.push(edge);
|
|
354
439
|
edge.graphSeq = root.edges.length;
|
|
355
440
|
// cgraph: nodes and edges belong to every enclosing graph.
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The agwrite serializer — write.c's graph/subgraph/node/edge emission, shared
|
|
5
|
+
* verbatim by `-Tdot` and `-Txdot`.
|
|
6
|
+
*
|
|
7
|
+
* Split out of dot.ts as an abstract base rather than a collaborator so every
|
|
8
|
+
* method body moves UNCHANGED: the serializer reads only three things from the
|
|
9
|
+
* renderer (`emitDraws`, `clusters`, `drawsOf`), which are declared abstract
|
|
10
|
+
* here and supplied by XdotRenderer. Keeping the bodies byte-identical matters
|
|
11
|
+
* more than the inheritance being fashionable — this is ported C, and a
|
|
12
|
+
* mechanical move is auditable against write.c in a way a rewrite is not.
|
|
13
|
+
*
|
|
14
|
+
* @see lib/cgraph/write.c
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Graph } from '../../model/graph.js';
|
|
18
|
+
import type { Node } from '../../model/node.js';
|
|
19
|
+
import type { Edge } from '../../model/edge.js';
|
|
20
|
+
import type { TextlabelT, FieldT, ShapeDesc } from '../../common/types.js';
|
|
21
|
+
import { agstrcanon, agstrcanonText } from '../map.js';
|
|
22
|
+
import { isHtmlValue } from '../../common/html-string.js';
|
|
23
|
+
import { XDOT_VERSION, agcanonEscape, gfmt5, lpStr, xdotId } from './xdot-ops.js';
|
|
24
|
+
import {
|
|
25
|
+
COMPUTED_EDGE_ATTRS, COMPUTED_NODE_ATTRS, appendRecordRects, computedPart, dictParts,
|
|
26
|
+
echoAttr, echoGraphAttr, edgeAttrsAttached, edgeConnector, edgePosRaw,
|
|
27
|
+
effectiveEdgeDefaults, effectiveNodeDefaults, graphInputParts, graphLabelAttrs,
|
|
28
|
+
isDirected, nodeDictParts, nodeRecord, objInputParts,
|
|
29
|
+
} from './attrs.js';
|
|
30
|
+
import { nodesInSeq } from '../../layout/dot/decomp.js';
|
|
31
|
+
import type { XdotDraws, SerCtx } from './types.js';
|
|
32
|
+
|
|
33
|
+
/** write.c's serializer half of the dot/xdot renderer. @see lib/cgraph/write.c */
|
|
34
|
+
export abstract class DotWriterBase {
|
|
35
|
+
/** @see dot.ts XdotRenderer.emitDraws */
|
|
36
|
+
protected abstract readonly emitDraws: boolean;
|
|
37
|
+
/** Clusters in render order (GD_clust); filled by the renderer's endCluster. */
|
|
38
|
+
protected clusters: Graph[] = [];
|
|
39
|
+
/** Draw strings for `obj`, or undefined in a format that emits none. */
|
|
40
|
+
protected abstract drawsOf(obj: Node | Edge | Graph): XdotDraws | undefined;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Emit `key="value"`, escaping the value the way agwrite's agcanonStr does: a
|
|
44
|
+
* `"` becomes `\"` unless it is already part of an escape sequence (see
|
|
45
|
+
* agcanonEscape). A draw string carries label text that may contain a bare `"`
|
|
46
|
+
* (would close the attribute early) or a source `\"`/`\\` (must not be
|
|
47
|
+
* double-escaped). The byte-length prefix stays on the UNescaped text (the
|
|
48
|
+
* parser un-escapes before parseXDot re-reads it), matching native exactly.
|
|
49
|
+
* @see lib/cgraph/write.c:_agstrcanon (135-167)
|
|
50
|
+
*/
|
|
51
|
+
private drawAttr(key: string, value: string): string {
|
|
52
|
+
return key + '="' + agcanonEscape(value) + '"';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** `llx,lly,urx,ury` from a graph's layout bb. */
|
|
56
|
+
private bbStr(g: Graph): string {
|
|
57
|
+
const bb = g.info.bb;
|
|
58
|
+
return gfmt5(bb.ll.x) + ',' + gfmt5(bb.ll.y) + ',' +
|
|
59
|
+
gfmt5(bb.ur.x) + ',' + gfmt5(bb.ur.y);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Serialize the whole laid-out graph to xdot DOT text — a faithful port of
|
|
64
|
+
* cgraph's agwrite (lib/cgraph/write.c). Recurses the subgraph tree
|
|
65
|
+
* (write_subgs/write_body), scoping each node/edge to the subgraph(s) it
|
|
66
|
+
* belongs to via preorder numbers (write_node_test/write_edge_test), and
|
|
67
|
+
* re-emits an object bare (no attrs) on any scope after the first
|
|
68
|
+
* (attrs_written). This reproduces native's per-subgraph edge re-declarations
|
|
69
|
+
* — e.g. an edge in a rank=same subgraph is drawn once, then re-declared bare.
|
|
70
|
+
* @see lib/cgraph/write.c:agwrite
|
|
71
|
+
*/
|
|
72
|
+
protected serialize(g: Graph): string {
|
|
73
|
+
const ctx: SerCtx = {
|
|
74
|
+
out: [],
|
|
75
|
+
preorder: new Map<Graph, number>(),
|
|
76
|
+
nodeLW: new Map<Node, number>(),
|
|
77
|
+
edgeLW: new Map<Edge, number>(),
|
|
78
|
+
attrsWritten: new Set<Node | Edge>(),
|
|
79
|
+
level: 0,
|
|
80
|
+
};
|
|
81
|
+
this.subgdfs(g, 1, ctx.preorder);
|
|
82
|
+
this.writeHdr(g, true, ctx);
|
|
83
|
+
this.writeBody(g, ctx);
|
|
84
|
+
this.writeTrl(ctx);
|
|
85
|
+
return ctx.out.join('');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Preorder-number the subgraph tree. @see write.c:subgdfs */
|
|
89
|
+
private subgdfs(g: Graph, ix: number, preorder: Map<Graph, number>): number {
|
|
90
|
+
let ix0 = ix;
|
|
91
|
+
preorder.set(g, ix0);
|
|
92
|
+
for (const sub of g.subgraphs.values()) ix0 = this.subgdfs(sub, ix0, preorder);
|
|
93
|
+
return ix0 + 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A subgraph is anonymous when its name is empty or a `%N` local name. */
|
|
97
|
+
private isAnonymous(g: Graph): boolean {
|
|
98
|
+
return g.name.length === 0 || g.name.charCodeAt(0) === 0x25 /* % */;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A graph carries a `bb` attribute when it is the root or has a layout bb
|
|
102
|
+
* (clusters, and any subgraph output.c computed a box for). Native seeds `bb`
|
|
103
|
+
* on every graph via safe_dcl, so the value differs (set vs empty "") between
|
|
104
|
+
* a boxed graph and an unboxed child — the driver that makes an anon subgraph
|
|
105
|
+
* under the root or a cluster "relevant". @see lib/common/output.c:safe_dcl */
|
|
106
|
+
private hasBb(g: Graph): boolean {
|
|
107
|
+
return g === g.root || this.clusters.includes(g);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Anonymous subgraph with no own node/edge defaults and no graph attrs
|
|
111
|
+
* differing from its parent → inlined into the parent. Native compares every
|
|
112
|
+
* graph attr over the root attr dict; the load-bearing ones are `bb` (set on
|
|
113
|
+
* the parent, empty on the child) and `rank`. @see write.c:irrelevant_subgraph */
|
|
114
|
+
private irrelevantSubgraph(g: Graph): boolean {
|
|
115
|
+
if (!this.isAnonymous(g)) return false;
|
|
116
|
+
if (this.clusters.includes(g)) return false;
|
|
117
|
+
if (g.nodeDefaults.size > 0 || g.edgeDefaults.size > 0) return false;
|
|
118
|
+
if (g.parent) {
|
|
119
|
+
if (this.hasBb(g) !== this.hasBb(g.parent)) return false;
|
|
120
|
+
for (const [k, v] of g.attrs) {
|
|
121
|
+
if (g.parent.attrs.get(k) !== v) return false;
|
|
122
|
+
}
|
|
123
|
+
} else if (g.attrs.size > 0) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Non-draw graph attrs a subgraph emits (rank for rank=same; clusters use
|
|
130
|
+
* clusterAttrs). Only comparator-relevant fields need be exact.
|
|
131
|
+
*
|
|
132
|
+
* `rec_attach_bb` walks the ROOT and then `GD_clust` recursively, so a
|
|
133
|
+
* subgraph the layout did not box is attached NEITHER `bb` NOR the label
|
|
134
|
+
* triple — both keep the INPUT's values, which agwrite echoes. circo and
|
|
135
|
+
* twopi lay out no clusters at all (`GD_clust` empty — mirrored here by
|
|
136
|
+
* `this.clusters`, filled from endCluster), so on a re-fed dot output their
|
|
137
|
+
* `cluster0` comes back carrying the *previous* run's `bb` and `lp`.
|
|
138
|
+
* @see lib/common/output.c:249 rec_attach_bb */
|
|
139
|
+
private subgGraphAttrs(sg: Graph): string[] {
|
|
140
|
+
// `rank` is not special-cased here: it is an ordinary input attribute and
|
|
141
|
+
// graphInputParts echoes it, on clusters as well as plain subgraphs.
|
|
142
|
+
const parts: string[] = [];
|
|
143
|
+
parts.push(...echoGraphAttr(sg, 'bb'));
|
|
144
|
+
parts.push(...graphLabelAttrs(sg));
|
|
145
|
+
parts.push(...graphInputParts(sg, false, this.emitDraws));
|
|
146
|
+
return parts;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private indent(ctx: SerCtx): string {
|
|
150
|
+
return '\t'.repeat(ctx.level);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** @see write.c:write_hdr */
|
|
154
|
+
private writeHdr(g: Graph, top: boolean, ctx: SerCtx): void {
|
|
155
|
+
if (top) {
|
|
156
|
+
const strict = g.kind === 'strict-directed' || g.kind === 'strict-undirected' ? 'strict ' : '';
|
|
157
|
+
const kw = isDirected(g) ? 'digraph' : 'graph';
|
|
158
|
+
const nm = g.name.length > 0 && !this.isAnonymous(g) ? xdotId(g.name) + ' ' : '';
|
|
159
|
+
ctx.out.push(strict + kw + ' ' + nm + '{\n');
|
|
160
|
+
ctx.level++;
|
|
161
|
+
} else {
|
|
162
|
+
const nm = this.isAnonymous(g) ? '' : 'subgraph ' + xdotId(g.name) + ' ';
|
|
163
|
+
ctx.out.push(this.indent(ctx) + nm + '{\n');
|
|
164
|
+
ctx.level++;
|
|
165
|
+
}
|
|
166
|
+
this.writeDicts(g, top, ctx);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Emit this scope's `graph` / `node` / `edge` default statements, in that
|
|
171
|
+
* order. @see lib/cgraph/write.c:307 write_dicts
|
|
172
|
+
*/
|
|
173
|
+
private writeDicts(g: Graph, top: boolean, ctx: SerCtx): void {
|
|
174
|
+
const graphParts = top
|
|
175
|
+
? this.graphAttrs(g)
|
|
176
|
+
: this.clusters.includes(g)
|
|
177
|
+
? this.clusterAttrs(g)
|
|
178
|
+
: this.subgGraphAttrs(g);
|
|
179
|
+
this.writeDict('graph', graphParts, ctx);
|
|
180
|
+
this.writeDict('node', nodeDictParts(g, top), ctx);
|
|
181
|
+
this.writeDict('edge', dictParts(g.edgeDefaults), ctx);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* One `<name> [...]` default statement. Entries are emitted in **strcmp order
|
|
186
|
+
* of attribute name**: cgraph's attribute dicts are `Dttree`s keyed on
|
|
187
|
+
* `Agsym_t.name` with a NULL comparf (attr.c:34), so `dtfirst`/`dtnext` walk
|
|
188
|
+
* them sorted — the oracle's `graph [bb, rankdir]` and `[height, pos, width]`.
|
|
189
|
+
*
|
|
190
|
+
* Layout mirrors write_dict exactly: a single entry stays on one line, and two
|
|
191
|
+
* or more break after each `,` with the body indented one level deeper and the
|
|
192
|
+
* closing `];` back at the statement's own level.
|
|
193
|
+
*
|
|
194
|
+
* Not ported: the `EMPTY(defval) && !sym->print` skip (write.c:271-280). Every
|
|
195
|
+
* entry here was explicitly declared or computed, i.e. `print` is set, so that
|
|
196
|
+
* branch is unreachable until the eager-propagation artifact lands — an
|
|
197
|
+
* `agapply`-installed empty default is the only `print == false` producer.
|
|
198
|
+
*
|
|
199
|
+
* @see lib/cgraph/write.c:262 write_dict
|
|
200
|
+
*/
|
|
201
|
+
private writeDict(name: string, parts: string[], ctx: SerCtx): void {
|
|
202
|
+
if (parts.length === 0) return;
|
|
203
|
+
const sorted = [...parts].sort((a, b) => {
|
|
204
|
+
const ka = a.slice(0, a.indexOf('='));
|
|
205
|
+
const kb = b.slice(0, b.indexOf('='));
|
|
206
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
207
|
+
});
|
|
208
|
+
ctx.out.push(this.indent(ctx) + name + ' [');
|
|
209
|
+
ctx.level++;
|
|
210
|
+
ctx.out.push(sorted.join(',\n' + this.indent(ctx)));
|
|
211
|
+
ctx.level--;
|
|
212
|
+
if (sorted.length > 1) ctx.out.push('\n' + this.indent(ctx));
|
|
213
|
+
ctx.out.push('];\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* One object's `[...]` attribute block, or '' when it has no attributes.
|
|
218
|
+
* Entries sort strcmp like every dict (attr.c:34). Layout is
|
|
219
|
+
* write_nondefault_attrs, which differs from write_dict in two ways: the block
|
|
220
|
+
* opens with a literal TAB before `[`, and the closing `]` follows the last
|
|
221
|
+
* value directly — there is no newline+indent before it.
|
|
222
|
+
* @see lib/cgraph/write.c:471 write_nondefault_attrs
|
|
223
|
+
*/
|
|
224
|
+
private objAttrBlock(parts: string[], ctx: SerCtx): string {
|
|
225
|
+
if (parts.length === 0) return '';
|
|
226
|
+
const sorted = [...parts].sort((a, b) => {
|
|
227
|
+
const ka = a.slice(0, a.indexOf('='));
|
|
228
|
+
const kb = b.slice(0, b.indexOf('='));
|
|
229
|
+
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
|
230
|
+
});
|
|
231
|
+
ctx.level++;
|
|
232
|
+
const body = sorted.join(',\n' + this.indent(ctx));
|
|
233
|
+
ctx.level--;
|
|
234
|
+
return '\t[' + body + ']';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** @see write.c:write_trl */
|
|
238
|
+
private writeTrl(ctx: SerCtx): void {
|
|
239
|
+
ctx.level--;
|
|
240
|
+
ctx.out.push(this.indent(ctx) + '}\n');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** @see write.c:write_subgs */
|
|
244
|
+
private writeSubgs(g: Graph, ctx: SerCtx): void {
|
|
245
|
+
for (const sub of g.subgraphs.values()) {
|
|
246
|
+
if (this.irrelevantSubgraph(sub)) {
|
|
247
|
+
this.writeSubgs(sub, ctx);
|
|
248
|
+
} else {
|
|
249
|
+
this.writeHdr(sub, false, ctx);
|
|
250
|
+
this.writeBody(sub, ctx);
|
|
251
|
+
this.writeTrl(ctx);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** @see write.c:write_body — subgraphs, then this scope's nodes and edges. */
|
|
257
|
+
private writeBody(g: Graph, ctx: SerCtx): void {
|
|
258
|
+
this.writeSubgs(g, ctx);
|
|
259
|
+
// agfstnode/agnxtnode walk a subgraph's node set in AGSEQ (root creation)
|
|
260
|
+
// order, NOT the order nodes were added to this subgraph, so a node first
|
|
261
|
+
// referenced by an earlier edge is emitted before one declared above it in
|
|
262
|
+
// the subgraph's own text. @see lib/cgraph/node.c:43 · decomp.ts nodesInSeq
|
|
263
|
+
for (const n of nodesInSeq(g)) {
|
|
264
|
+
if (this.writeNodeTest(g, n, ctx)) this.writeNode(g, n, ctx);
|
|
265
|
+
let prev: Node = n;
|
|
266
|
+
for (const e of n.outEdges(g)) {
|
|
267
|
+
if (prev !== e.head && this.writeNodeTest(g, e.head, ctx)) {
|
|
268
|
+
this.writeNode(g, e.head, ctx);
|
|
269
|
+
prev = e.head;
|
|
270
|
+
}
|
|
271
|
+
if (this.writeEdgeTest(g, e, ctx)) this.writeEdge(g, e, ctx);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** @see write.c:write_node_test — every xdot node carries pos/size, so it is
|
|
277
|
+
* never "default"; write it in the first scope that has not yet emitted it.
|
|
278
|
+
* Cluster nodes (fdp compound proxies, ND_clustnode) are never declared —
|
|
279
|
+
* C's write_plain/writenodeandport suppress them. @see lib/common/output.c:146 */
|
|
280
|
+
private writeNodeTest(g: Graph, n: Node, ctx: SerCtx): boolean {
|
|
281
|
+
if (n.info.clustnode) return false;
|
|
282
|
+
return (ctx.nodeLW.get(n) ?? 0) < ctx.preorder.get(g)!;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Emitted node name: a cluster node's synthetic `__i:<cluster>` id is written
|
|
286
|
+
* as the cluster name it stands for. @see lib/common/output.c:114 */
|
|
287
|
+
private emitNodeName(n: Node): string {
|
|
288
|
+
if (n.info.clustnode) {
|
|
289
|
+
const i = n.name.indexOf(':');
|
|
290
|
+
if (i >= 0) return n.name.slice(i + 1);
|
|
291
|
+
}
|
|
292
|
+
return n.name;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** @see write.c:write_edge_test */
|
|
296
|
+
private writeEdgeTest(g: Graph, e: Edge, ctx: SerCtx): boolean {
|
|
297
|
+
return (ctx.edgeLW.get(e) ?? 0) < ctx.preorder.get(g)!;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** @see write.c:write_node */
|
|
301
|
+
private writeNode(g: Graph, n: Node, ctx: SerCtx): void {
|
|
302
|
+
let s = this.indent(ctx) + xdotId(this.emitNodeName(n));
|
|
303
|
+
if (!ctx.attrsWritten.has(n)) {
|
|
304
|
+
s += this.objAttrBlock(this.nodeAttrs(n, g), ctx);
|
|
305
|
+
ctx.attrsWritten.add(n);
|
|
306
|
+
}
|
|
307
|
+
ctx.out.push(s + ';\n');
|
|
308
|
+
ctx.nodeLW.set(n, ctx.preorder.get(g)!);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** @see write.c:write_edge — attrs only on first emission, bare thereafter. */
|
|
312
|
+
/**
|
|
313
|
+
* The `:port` / `:port:compass` suffix C writes after an endpoint name.
|
|
314
|
+
*
|
|
315
|
+
* These are NOT ordinary attributes on the way out: write_nondefault_attrs
|
|
316
|
+
* skips the tailport/headport symbols (write.c:487-492) precisely because
|
|
317
|
+
* this function re-emits them as endpoint syntax, so an edge parsed from
|
|
318
|
+
* `A:f0:ne -> B` round-trips through the port syntax rather than through a
|
|
319
|
+
* `tailport="f0:ne"` attribute.
|
|
320
|
+
*
|
|
321
|
+
* C reads the value with agxget, which falls back to the edge dict default,
|
|
322
|
+
* and gates on the SYMBOL existing at all — both are subsumed here by the
|
|
323
|
+
* attr-then-default lookup coming back undefined. An html-like value is
|
|
324
|
+
* canonicalized whole; a plain one splits on its FIRST `:` (strchr) so the
|
|
325
|
+
* port and the compass point are canonicalized separately.
|
|
326
|
+
* @see lib/cgraph/write.c:565 write_port
|
|
327
|
+
*/
|
|
328
|
+
private portSuffix(e: Edge, g: Graph, key: 'tailport' | 'headport'): string {
|
|
329
|
+
const val = e.attrs.get(key) ?? effectiveEdgeDefaults(g).get(key);
|
|
330
|
+
if (val === undefined || val.length === 0) return '';
|
|
331
|
+
if (isHtmlValue(val)) return ':' + agstrcanon(val);
|
|
332
|
+
const i = val.indexOf(':');
|
|
333
|
+
if (i < 0) return ':' + agstrcanonText(val);
|
|
334
|
+
return ':' + agstrcanonText(val.slice(0, i)) + ':' + agstrcanonText(val.slice(i + 1));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private writeEdge(g: Graph, e: Edge, ctx: SerCtx): void {
|
|
338
|
+
const conn = edgeConnector(isDirected(g));
|
|
339
|
+
let s = this.indent(ctx) + xdotId(this.emitNodeName(e.tail)) +
|
|
340
|
+
this.portSuffix(e, g, 'tailport') + ' ' + conn + ' ' +
|
|
341
|
+
xdotId(this.emitNodeName(e.head)) + this.portSuffix(e, g, 'headport');
|
|
342
|
+
if (!ctx.attrsWritten.has(e)) {
|
|
343
|
+
s += this.objAttrBlock(this.edgeAttrStr(e, g), ctx);
|
|
344
|
+
ctx.attrsWritten.add(e);
|
|
345
|
+
}
|
|
346
|
+
ctx.out.push(s + ';\n');
|
|
347
|
+
ctx.edgeLW.set(e, ctx.preorder.get(g)!);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Root-graph attribute block: `_draw_`, `_ldraw_`, `bb`, `xdotversion`. */
|
|
351
|
+
private graphAttrs(g: Graph): string[] {
|
|
352
|
+
const d = this.drawsOf(g);
|
|
353
|
+
const parts: string[] = [];
|
|
354
|
+
if (d?.draw) parts.push(this.drawAttr('_draw_', d.draw));
|
|
355
|
+
if (d?.ldraw) parts.push(this.drawAttr('_ldraw_', d.ldraw));
|
|
356
|
+
parts.push('bb="' + this.bbStr(g) + '"');
|
|
357
|
+
parts.push(...graphLabelAttrs(g));
|
|
358
|
+
// xdot_begin_graph agsets xdotversion; FORMAT_DOT never does.
|
|
359
|
+
// @see plugin/core/gvrender_core_dot.c:341 xdot_begin_graph
|
|
360
|
+
// Canonicalized like any other dict value: `1.7` needs no quotes, and
|
|
361
|
+
// native emits it bare. @see lib/cgraph/write.c:write_canonstr
|
|
362
|
+
if (this.emitDraws) parts.push('xdotversion=' + agstrcanon(XDOT_VERSION));
|
|
363
|
+
parts.push(...graphInputParts(g, true, this.emitDraws));
|
|
364
|
+
return parts;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Cluster attribute block. C's xdot_end_cluster ALWAYS agsets `_draw_` when
|
|
368
|
+
* the graph has clusters (even empty, e.g. peripheries=0), so emit it
|
|
369
|
+
* unconditionally; `_ldraw_` only when the cluster has a label.
|
|
370
|
+
* @see plugin/core/gvrender_core_dot.c:284 xdot_end_cluster */
|
|
371
|
+
private clusterAttrs(sg: Graph): string[] {
|
|
372
|
+
const d = this.drawsOf(sg);
|
|
373
|
+
const parts: string[] = this.emitDraws ? [this.drawAttr('_draw_', d?.draw ?? '')] : [];
|
|
374
|
+
if (d?.ldraw) parts.push(this.drawAttr('_ldraw_', d.ldraw));
|
|
375
|
+
if (sg.info.bb) parts.push('bb="' + this.bbStr(sg) + '"');
|
|
376
|
+
// rec_attach_bb recurses into GD_clust, so a labelled cluster carries the
|
|
377
|
+
// same lp/lwidth/lheight triple as the root. @see lib/common/output.c:249
|
|
378
|
+
parts.push(...graphLabelAttrs(sg));
|
|
379
|
+
parts.push(...graphInputParts(sg, false, this.emitDraws));
|
|
380
|
+
return parts;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Node attribute block: pos/width/height plus `_draw_`/`_ldraw_`. `scope` is
|
|
384
|
+
* the subgraph the node is being WRITTEN in, whose node dict every value is
|
|
385
|
+
* compared against. @see lib/cgraph/write.c:537-545 write_node */
|
|
386
|
+
private nodeAttrs(n: Node, scope: Graph): string[] {
|
|
387
|
+
const info = n.info;
|
|
388
|
+
// attach_attrs derives the emitted size from ND_lw+ND_rw / ND_ht, NOT
|
|
389
|
+
// ND_width/ND_height (output.c:307-308). They coincide except where an
|
|
390
|
+
// engine leaves them divergent — patchwork's finishNode lets poly_init
|
|
391
|
+
// clobber ND_width/height while the tile survives in lw/rw/ht.
|
|
392
|
+
const defs = effectiveNodeDefaults(scope);
|
|
393
|
+
const posRaw = gfmt5(info.coord.x) + ',' + gfmt5(info.coord.y);
|
|
394
|
+
const widthRaw = gfmt5((info.lw + info.rw) / 72);
|
|
395
|
+
const heightRaw = gfmt5(info.ht / 72);
|
|
396
|
+
const parts: string[] = [
|
|
397
|
+
...computedPart('pos', posRaw, defs),
|
|
398
|
+
...computedPart('width', widthRaw, defs),
|
|
399
|
+
...computedPart('height', heightRaw, defs),
|
|
400
|
+
];
|
|
401
|
+
parts.push(...this.nodeXlpPart(n, defs));
|
|
402
|
+
parts.push(...this.nodeRectsPart(n, defs));
|
|
403
|
+
const d = this.drawsOf(n);
|
|
404
|
+
if (d?.draw) parts.push(this.drawAttr('_draw_', d.draw));
|
|
405
|
+
if (d?.ldraw) parts.push(this.drawAttr('_ldraw_', d.ldraw));
|
|
406
|
+
parts.push(...objInputParts(nodeRecord(n), defs, COMPUTED_NODE_ATTRS));
|
|
407
|
+
return parts;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The `[...]` attribute body for an edge (draw ops + spline `pos`), or ''. */
|
|
411
|
+
private edgeAttrStr(e: Edge, g: Graph): string[] {
|
|
412
|
+
const defs = effectiveEdgeDefaults(g);
|
|
413
|
+
const parts: string[] = this.edgeDrawParts(e);
|
|
414
|
+
const posRaw = edgePosRaw(e);
|
|
415
|
+
if (posRaw !== null) {
|
|
416
|
+
parts.push(...computedPart('pos', posRaw, defs));
|
|
417
|
+
} else {
|
|
418
|
+
// C's attach_attrs only agsets `pos` when the edge HAS a spline
|
|
419
|
+
// (output.c:348); an engine that never routes (patchwork) leaves the
|
|
420
|
+
// INPUT's own pos attribute intact and write.c emits it verbatim.
|
|
421
|
+
parts.push(...echoAttr(e.attrs, 'pos'));
|
|
422
|
+
}
|
|
423
|
+
// The label-position attributes live inside the SAME loop that writes `pos`
|
|
424
|
+
// (output.c:377-396), so they are attached only for a routed, non-IGNORED
|
|
425
|
+
// edge. Note the asymmetry C encodes: `lp`/`head_lp`/`tail_lp` are emitted
|
|
426
|
+
// whenever the label EXISTS, but `xlp` additionally requires `->set` — an
|
|
427
|
+
// unplaced xlabel is omitted. Order mirrors C: lp, xlp, head_lp, tail_lp.
|
|
428
|
+
// Each gate is independent, and each FAILED gate leaves the input's own
|
|
429
|
+
// value in the slot for write.c to echo: an unrouted edge (patchwork routes
|
|
430
|
+
// none) returns the `lp` a previous dot run wrote.
|
|
431
|
+
parts.push(...this.edgeLabelPosParts(e, defs));
|
|
432
|
+
parts.push(...objInputParts(e.attrs, defs, COMPUTED_EDGE_ATTRS));
|
|
433
|
+
return parts;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** `xlp` — only when the node HAS an xlabel AND the xlabel placer actually set
|
|
437
|
+
* its position (`ND_xlabel(n)->set`). An xlabel that was never placed is
|
|
438
|
+
* omitted entirely, so an input `xlp` survives and is echoed.
|
|
439
|
+
* @see lib/common/output.c:309-313 */
|
|
440
|
+
private nodeXlpPart(n: Node, defs: Map<string, string>): string[] {
|
|
441
|
+
const xlabel = n.info.xlabel as TextlabelT | undefined;
|
|
442
|
+
if (!xlabel || !xlabel.set) return echoAttr(n.attrs, 'xlp');
|
|
443
|
+
const raw = lpStr(xlabel.pos);
|
|
444
|
+
return computedPart('xlp', raw, defs);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** `rects` — record field boxes. C gates on the SHAPE NAME being exactly
|
|
448
|
+
* "record" (`strcmp(ND_shape(n)->name, "record") == 0`), so `Mrecord` and
|
|
449
|
+
* HTML-table nodes get NO rects; confirmed against the native oracle. When
|
|
450
|
+
* the shape is not a record the input's `rects` is never overwritten and is
|
|
451
|
+
* echoed — patchwork forces every node to `box`, so a re-fed dot output
|
|
452
|
+
* returns its old record rects untouched. @see lib/common/output.c:314-317 */
|
|
453
|
+
private nodeRectsPart(n: Node, defs: Map<string, string>): string[] {
|
|
454
|
+
const shape = n.info.shape as ShapeDesc | undefined;
|
|
455
|
+
if (shape?.name !== 'record') return echoAttr(n.attrs, 'rects');
|
|
456
|
+
const rects: string[] = [];
|
|
457
|
+
appendRecordRects(n, n.info.shape_info as FieldT, rects);
|
|
458
|
+
const raw = rects.join(' ');
|
|
459
|
+
return computedPart('rects', raw, defs);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** The six `_draw_`-family attributes of an edge, in C's order. */
|
|
463
|
+
private edgeDrawParts(e: Edge): string[] {
|
|
464
|
+
const d = this.drawsOf(e);
|
|
465
|
+
const parts: string[] = [];
|
|
466
|
+
if (d?.draw) parts.push(this.drawAttr('_draw_', d.draw));
|
|
467
|
+
if (d?.ldraw) parts.push(this.drawAttr('_ldraw_', d.ldraw));
|
|
468
|
+
if (d?.hdraw) parts.push(this.drawAttr('_hdraw_', d.hdraw));
|
|
469
|
+
if (d?.tdraw) parts.push(this.drawAttr('_tdraw_', d.tdraw));
|
|
470
|
+
if (d?.hldraw) parts.push(this.drawAttr('_hldraw_', d.hldraw));
|
|
471
|
+
if (d?.tldraw) parts.push(this.drawAttr('_tldraw_', d.tldraw));
|
|
472
|
+
return parts;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* `lp`/`xlp`/`head_lp`/`tail_lp` for one edge, in C's order.
|
|
477
|
+
*
|
|
478
|
+
* Note the asymmetry C encodes: `lp`/`head_lp`/`tail_lp` are emitted whenever
|
|
479
|
+
* the label EXISTS, but `xlp` additionally requires `->set` — an unplaced
|
|
480
|
+
* xlabel is omitted. Each gate is independent, and each FAILED gate leaves the
|
|
481
|
+
* input's own value in the slot for write.c to echo: an unrouted edge
|
|
482
|
+
* (patchwork routes none) returns the `lp` a previous dot run wrote.
|
|
483
|
+
* @see lib/common/output.c:377-396
|
|
484
|
+
*/
|
|
485
|
+
private edgeLabelPosParts(e: Edge, defs: Map<string, string>): string[] {
|
|
486
|
+
const attached = edgeAttrsAttached(e);
|
|
487
|
+
const info = e.info;
|
|
488
|
+
const parts: string[] = [];
|
|
489
|
+
const lpPart = (key: string, label: TextlabelT): string[] => {
|
|
490
|
+
const raw = lpStr(label.pos);
|
|
491
|
+
return computedPart(key, raw, defs);
|
|
492
|
+
};
|
|
493
|
+
if (attached && info.label) parts.push(...lpPart('lp', info.label));
|
|
494
|
+
else parts.push(...echoAttr(e.attrs, 'lp'));
|
|
495
|
+
if (attached && info.xlabel && info.xlabel.set) {
|
|
496
|
+
parts.push(...lpPart('xlp', info.xlabel));
|
|
497
|
+
} else parts.push(...echoAttr(e.attrs, 'xlp'));
|
|
498
|
+
if (attached && info.head_label) {
|
|
499
|
+
parts.push(...lpPart('head_lp', info.head_label));
|
|
500
|
+
} else parts.push(...echoAttr(e.attrs, 'head_lp'));
|
|
501
|
+
if (attached && info.tail_label) {
|
|
502
|
+
parts.push(...lpPart('tail_lp', info.tail_label));
|
|
503
|
+
} else parts.push(...echoAttr(e.attrs, 'tail_lp'));
|
|
504
|
+
return parts;
|
|
505
|
+
}
|
|
506
|
+
}
|