figdown 0.1.0-rc.1 → 0.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 +253 -171
- package/dist/figdown.js +2601 -580
- package/dist/figdown.mjs +2601 -580
- package/examples/evpn-fabric.svg +16 -11
- package/examples/showcase/arp-resolution.svg +18 -16
- package/examples/showcase/ethernet-frame.svg +12 -10
- package/examples/showcase/l2-forwarding-logic.svg +20 -16
- package/examples/showcase/tcp-handshake.svg +14 -13
- package/examples/showcase/tcp-header.svg +26 -19
- package/examples/showcase/tcp-state-machine.svg +57 -44
- package/guide/expressing.md +160 -0
- package/guide/layout.md +635 -0
- package/{SHOWCASE.md → guide/showcase.md} +130 -108
- package/package.json +30 -4
- package/skill/figdown/SKILL.md +173 -0
- package/skill/figdown/build-svg.js +51 -16
- package/skill/figdown/figdown.html +3005 -783
- package/skill/figdown/reference/bitfield.md +130 -0
- package/skill/figdown/reference/experimental/constructs.md +90 -0
- package/skill/figdown/reference/experimental/flowchart.md +56 -0
- package/skill/figdown/reference/experimental/timing.md +33 -0
- package/skill/figdown/reference/experimental/topology.md +38 -0
- package/skill/figdown/reference/layout.md +77 -0
- package/skill/figdown/reference/reading.md +119 -0
- package/skill/figdown/reference/scene.md +118 -0
- package/skill/figdown/reference/table.md +71 -0
- package/skill/figdown/reference/transcribe.md +46 -0
- package/EXPRESSING.md +0 -119
- package/LAYOUT-GUIDE.md +0 -367
- package/README.zh-tw.md +0 -185
package/dist/figdown.mjs
CHANGED
|
@@ -1,145 +1,898 @@
|
|
|
1
|
-
// figdown.mjs — FigDown embeddable library (0.1
|
|
1
|
+
// figdown.mjs — FigDown embeddable library (0.1.0)
|
|
2
2
|
// GENERATED FILE, DO NOT EDIT. Built from editor/figdown.html.
|
|
3
3
|
// Regenerate with: node tools/make-lib.js
|
|
4
4
|
'use strict';
|
|
5
|
-
var VERSION = "0.1
|
|
5
|
+
var VERSION = "0.1.0";
|
|
6
6
|
|
|
7
7
|
// ---- engine (extracted verbatim from editor/figdown.html) ----
|
|
8
8
|
var __engine = (function () {
|
|
9
|
-
const SHAPES = ['box','rounded','circle','ellipse','
|
|
9
|
+
const SHAPES = ['box','rounded','circle','ellipse','diamond','cylinder'];
|
|
10
|
+
// The engine's own version string, and the SINGLE source of it: it sits
|
|
11
|
+
// INSIDE the extracted engine region, so tools/make-lib.js (dist/) and
|
|
12
|
+
// tools/build-svg.js (the CLI) both read this line rather than each keeping
|
|
13
|
+
// a copy — one more four-copy drift point removed. Every artifact records it
|
|
14
|
+
// (core §7, `data-engine-version=`): `RENDERING-DETERMINISM` promises byte-identical output for
|
|
15
|
+
// the same source AND THE SAME RENDERER VERSION, so the version is half the
|
|
16
|
+
// input to that promise, and under core §13 a 0.x renderer may differ from
|
|
17
|
+
// the next — which makes the recorded version the only thing that can
|
|
18
|
+
// explain a diff between two renderings of one source.
|
|
19
|
+
const FIGDOWN_VERSION = '0.1.0';
|
|
20
|
+
// Retired shape VALUES keep a named diagnostic (PROCESS §5(d)), the same way
|
|
21
|
+
// retired option keys do: `cloud` was the one value that named a domain
|
|
22
|
+
// (the internet cloud) in an enum the language keeps purely geometric
|
|
23
|
+
// (`SHAPE-ENUM-VOCABULARY`/`EXTERNAL-EDGE-ENDPOINTS`), so it was removed rather than demoted.
|
|
24
|
+
const RETIRED_SHAPES = {
|
|
25
|
+
cloud: 'shape=cloud has been retired: use shape=ellipse and put the meaning in the label or a class= (shapes are pure geometry, `SHAPE-ENUM-VOCABULARY`) (MIGRATIONS)'
|
|
26
|
+
};
|
|
10
27
|
// Colors are CSS hex (#rgb / #rrggbb) or CSS named colors (spec §1) — the
|
|
11
28
|
// 147 CSS/SVG color keywords (lowercase) plus `transparent`. Anything else
|
|
12
|
-
// is a line error (closed grammar
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
// is a line error (closed grammar).
|
|
30
|
+
// 0.1: the same 147 keywords, now as name -> sRGB. The VALUES are new
|
|
31
|
+
// and load-bearing: `color=` is retired (`COLOUR-KEY-STATUS`) and the default label colour is
|
|
32
|
+
// DERIVED from the resolved fill's WCAG relative luminance (`LABEL-COLOUR-SOURCE`), so a fill
|
|
33
|
+
// written as a CSS name has to reach the same arithmetic a `#rrggbb` fill does.
|
|
34
|
+
// Values are CSS Color Module Level 4 §6.1 "Named Colors".
|
|
35
|
+
const CSS_COLOR_HEX=(function(){
|
|
36
|
+
const m={};
|
|
37
|
+
('aliceblue:f0f8ff antiquewhite:faebd7 aqua:00ffff aquamarine:7fffd4 azure:f0ffff beige:f5f5dc '+
|
|
38
|
+
'bisque:ffe4c4 black:000000 blanchedalmond:ffebcd blue:0000ff blueviolet:8a2be2 brown:a52a2a '+
|
|
39
|
+
'burlywood:deb887 cadetblue:5f9ea0 chartreuse:7fff00 chocolate:d2691e coral:ff7f50 cornflowerblue:6495ed '+
|
|
40
|
+
'cornsilk:fff8dc crimson:dc143c cyan:00ffff darkblue:00008b darkcyan:008b8b darkgoldenrod:b8860b '+
|
|
41
|
+
'darkgray:a9a9a9 darkgreen:006400 darkgrey:a9a9a9 darkkhaki:bdb76b darkmagenta:8b008b '+
|
|
42
|
+
'darkolivegreen:556b2f darkorange:ff8c00 darkorchid:9932cc darkred:8b0000 darksalmon:e9967a '+
|
|
43
|
+
'darkseagreen:8fbc8f darkslateblue:483d8b darkslategray:2f4f4f darkslategrey:2f4f4f darkturquoise:00ced1 '+
|
|
44
|
+
'darkviolet:9400d3 deeppink:ff1493 deepskyblue:00bfff dimgray:696969 dimgrey:696969 dodgerblue:1e90ff '+
|
|
45
|
+
'firebrick:b22222 floralwhite:fffaf0 forestgreen:228b22 fuchsia:ff00ff gainsboro:dcdcdc ghostwhite:f8f8ff '+
|
|
46
|
+
'gold:ffd700 goldenrod:daa520 gray:808080 green:008000 greenyellow:adff2f grey:808080 honeydew:f0fff0 '+
|
|
47
|
+
'hotpink:ff69b4 indianred:cd5c5c indigo:4b0082 ivory:fffff0 khaki:f0e68c lavender:e6e6fa '+
|
|
48
|
+
'lavenderblush:fff0f5 lawngreen:7cfc00 lemonchiffon:fffacd lightblue:add8e6 lightcoral:f08080 '+
|
|
49
|
+
'lightcyan:e0ffff lightgoldenrodyellow:fafad2 lightgray:d3d3d3 lightgreen:90ee90 lightgrey:d3d3d3 '+
|
|
50
|
+
'lightpink:ffb6c1 lightsalmon:ffa07a lightseagreen:20b2aa lightskyblue:87cefa lightslategray:778899 '+
|
|
51
|
+
'lightslategrey:778899 lightsteelblue:b0c4de lightyellow:ffffe0 lime:00ff00 limegreen:32cd32 linen:faf0e6 '+
|
|
52
|
+
'magenta:ff00ff maroon:800000 mediumaquamarine:66cdaa mediumblue:0000cd mediumorchid:ba55d3 '+
|
|
53
|
+
'mediumpurple:9370db mediumseagreen:3cb371 mediumslateblue:7b68ee mediumspringgreen:00fa9a '+
|
|
54
|
+
'mediumturquoise:48d1cc mediumvioletred:c71585 midnightblue:191970 mintcream:f5fffa mistyrose:ffe4e1 '+
|
|
55
|
+
'moccasin:ffe4b5 navajowhite:ffdead navy:000080 oldlace:fdf5e6 olive:808000 olivedrab:6b8e23 '+
|
|
56
|
+
'orange:ffa500 orangered:ff4500 orchid:da70d6 palegoldenrod:eee8aa palegreen:98fb98 paleturquoise:afeeee '+
|
|
57
|
+
'palevioletred:db7093 papayawhip:ffefd5 peachpuff:ffdab9 peru:cd853f pink:ffc0cb plum:dda0dd '+
|
|
58
|
+
'powderblue:b0e0e6 purple:800080 red:ff0000 rosybrown:bc8f8f royalblue:4169e1 saddlebrown:8b4513 '+
|
|
59
|
+
'salmon:fa8072 sandybrown:f4a460 seagreen:2e8b57 seashell:fff5ee sienna:a0522d silver:c0c0c0 '+
|
|
60
|
+
'skyblue:87ceeb slateblue:6a5acd slategray:708090 slategrey:708090 snow:fffafa springgreen:00ff7f '+
|
|
61
|
+
'steelblue:4682b4 tan:d2b48c teal:008080 thistle:d8bfd8 tomato:ff6347 turquoise:40e0d0 violet:ee82ee '+
|
|
62
|
+
'wheat:f5deb3 white:ffffff whitesmoke:f5f5f5 yellow:ffff00 yellowgreen:9acd32')
|
|
63
|
+
.split(' ').forEach(p=>{const i=p.indexOf(':'); m[p.slice(0,i)]='#'+p.slice(i+1);});
|
|
64
|
+
return m;
|
|
65
|
+
})();
|
|
66
|
+
const CSS_COLORS=new Set(Object.keys(CSS_COLOR_HEX).concat(['transparent']));
|
|
28
67
|
const isColor=v=>/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)||CSS_COLORS.has(v);
|
|
29
68
|
|
|
69
|
+
// ---- derived label colour (§5, `LABEL-COLOUR-SOURCE`) -------------------------------------
|
|
70
|
+
// v0.1 has NO author-facing label-colour key: `color=` is retired language-wide
|
|
71
|
+
// (`COLOUR-KEY-STATUS`) and nothing replaced it. The DEFAULT is derived from the background
|
|
72
|
+
// the label is drawn on, so it cannot assert a falsehood (`UNSAFE-DEFAULT-ELIMINATION`).
|
|
73
|
+
//
|
|
74
|
+
// The arithmetic is normative and is WCAG 2.1's, unaltered:
|
|
75
|
+
// 1. relative luminance (WCAG 2.1 "relative luminance"):
|
|
76
|
+
// c = channel / 255
|
|
77
|
+
// clin = c/12.92 when c <= 0.03928
|
|
78
|
+
// = ((c + 0.055)/1.055) ** 2.4 otherwise
|
|
79
|
+
// L = 0.2126*Rlin + 0.7152*Glin + 0.0722*Blin
|
|
80
|
+
// 2. contrast ratio (WCAG 2.1 "contrast ratio"): (L1 + 0.05)/(L2 + 0.05).
|
|
81
|
+
// The threshold is not a taste value: it is the L at which white and black
|
|
82
|
+
// contrast EQUALLY against the background, i.e. the solution of
|
|
83
|
+
// 1.05/(L + 0.05) = (L + 0.05)/0.05 => L = sqrt(0.0525) - 0.05
|
|
84
|
+
// = 0.179128784747792
|
|
85
|
+
// Above it the dark ink wins, at or below it the light ink wins. Choosing any
|
|
86
|
+
// other number would knowingly pick the LESS readable of the two.
|
|
87
|
+
const CONTRAST_L = Math.sqrt(0.0525) - 0.05;
|
|
88
|
+
function relLuminance(hex){
|
|
89
|
+
const h=hex.length===4
|
|
90
|
+
? '#'+hex[1]+hex[1]+hex[2]+hex[2]+hex[3]+hex[3]
|
|
91
|
+
: hex;
|
|
92
|
+
const ch=i=>{
|
|
93
|
+
const c=parseInt(h.slice(1+2*i,3+2*i),16)/255;
|
|
94
|
+
return c<=0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055,2.4);
|
|
95
|
+
};
|
|
96
|
+
return 0.2126*ch(0)+0.7152*ch(1)+0.0722*ch(2);
|
|
97
|
+
}
|
|
98
|
+
// `bg` is the RESOLVED fill the label sits on (never undefined at a call site:
|
|
99
|
+
// every caller passes the same value it painted). `transparent` and an unknown
|
|
100
|
+
// token mean "no fill of its own" — the label is then on the canvas, which is
|
|
101
|
+
// the light case. `dark` is the renderer's ink for that context; the light ink
|
|
102
|
+
// is white everywhere.
|
|
103
|
+
const INK_LIGHT='#ffffff';
|
|
104
|
+
function labelInk(bg, dark){
|
|
105
|
+
if(bg===undefined||bg===null||bg==='transparent') return dark;
|
|
106
|
+
const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(bg) ? bg : CSS_COLOR_HEX[bg];
|
|
107
|
+
if(!hex) return dark;
|
|
108
|
+
return relLuminance(hex) > CONTRAST_L ? dark : INK_LIGHT;
|
|
109
|
+
}
|
|
110
|
+
// Inside the typed blocks the dark ink is "the document's own text colour",
|
|
111
|
+
// which is spelled by writing NO fill attribute at all. There the derivation
|
|
112
|
+
// only ever ADDS the light ink, so a figure whose fills are light is byte-for-
|
|
113
|
+
// byte what it was before this release.
|
|
114
|
+
const inkAttr=(bg)=>labelInk(bg,null)===INK_LIGHT?' fill="'+INK_LIGHT+'"':'';
|
|
115
|
+
|
|
116
|
+
// 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`): the waypoint-list and dock parsers were deleted with the
|
|
117
|
+
// `path` directive they served. `points=(x,y),…` was the only value form in
|
|
118
|
+
// the language that needed a point-LIST scanner, and `tailport=`/`headport=`
|
|
119
|
+
// the only one that needed a compass-or-fraction dock. `pin at=` keeps its own
|
|
120
|
+
// single-point grammar inline where it is used, so nothing else in the
|
|
121
|
+
// language lost a parser. See spec/migrations.md 0.1 and core §9
|
|
122
|
+
// `EDGE-IDENTITY-AND-GEOMETRY` for why the constructs went and what a replacement would need.
|
|
123
|
+
|
|
124
|
+
// `LINK-OPERATOR-IN-IDS`: ONE scanner for both the "whole token is a string"
|
|
125
|
+
// case and the inline `key="value with spaces"` case. A token is a maximal
|
|
126
|
+
// run of non-whitespace in which quoted REGIONS may appear anywhere; a
|
|
127
|
+
// quoted region may itself contain whitespace. Two consequences the old
|
|
128
|
+
// two-branch scanner got wrong:
|
|
129
|
+
// - `rank "a",b` used to lex as TWO tokens (`a`, `,b`) because the
|
|
130
|
+
// leading-quote branch stopped at the closing quote, which is why the
|
|
131
|
+
// line reported "do not mix the comma form with the space form" when
|
|
132
|
+
// nothing was mixed;
|
|
133
|
+
// - `field "Long Name":16` used to need a re-gluing hack downstream.
|
|
134
|
+
// QUOTEDNESS IS NO LONGER DISCARDED (`OPTION-POSITION-PARSING`'s principle, extended): every
|
|
135
|
+
// token carries `m`, a mark string index-aligned with `v` ('q' = this
|
|
136
|
+
// character came from inside quotes, '.' = it did not), and `qz`, the
|
|
137
|
+
// offsets in `v` at which a quoted region began (so an EMPTY quoted
|
|
138
|
+
// region — `present=""` — is still observable). Everything that has to know
|
|
139
|
+
// whether a value was quoted (id positions, string-typed option values,
|
|
140
|
+
// comma splitting) reads those two, never the raw line.
|
|
30
141
|
function tokenize(line){
|
|
31
|
-
// split by whitespace, honoring double quotes
|
|
32
142
|
const toks=[]; let i=0;
|
|
33
143
|
while(i<line.length){
|
|
34
144
|
while(i<line.length && /\s/.test(line[i])) i++;
|
|
35
145
|
if(i>=line.length) break;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
if(j>=line.length) return {error:'unterminated string'};
|
|
49
|
-
toks.push({v:s,q:true}); i=j+1;
|
|
50
|
-
} else {
|
|
51
|
-
let j=i, s='';
|
|
52
|
-
while(j<line.length && !/\s/.test(line[j])){
|
|
53
|
-
if(line[j]==='"'){ // key="value with spaces"
|
|
54
|
-
j++;
|
|
55
|
-
while(j<line.length && line[j]!=='"'){
|
|
56
|
-
if(line[j]==='\\'){
|
|
57
|
-
const e=line[j+1];
|
|
58
|
-
if(e==='n'){ s+='\n'; j+=2; continue; }
|
|
59
|
-
if(e==='"'){ s+='"'; j+=2; continue; }
|
|
60
|
-
if(e==='\\'){ s+='\\'; j+=2; continue; }
|
|
61
|
-
return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'};
|
|
62
|
-
}
|
|
63
|
-
s+=line[j]; j++;
|
|
146
|
+
let s='', m='', qz=[];
|
|
147
|
+
while(i<line.length && !/\s/.test(line[i])){
|
|
148
|
+
if(line[i]==='"'){
|
|
149
|
+
qz.push(s.length); i++;
|
|
150
|
+
while(i<line.length && line[i]!=='"'){
|
|
151
|
+
if(line[i]==='\\'){
|
|
152
|
+
const e=line[i+1];
|
|
153
|
+
if(e==='n'){ s+='\n'; m+='q'; i+=2; continue; }
|
|
154
|
+
if(e==='"'){ s+='"'; m+='q'; i+=2; continue; }
|
|
155
|
+
if(e==='\\'){ s+='\\'; m+='q'; i+=2; continue; }
|
|
156
|
+
return {error:'unknown escape "\\'+(e||'')+'" (allowed: \\n \\" \\\\)'};
|
|
64
157
|
}
|
|
65
|
-
|
|
66
|
-
j++; continue;
|
|
158
|
+
s+=line[i]; m+='q'; i++;
|
|
67
159
|
}
|
|
68
|
-
|
|
160
|
+
if(i>=line.length) return {error:'unterminated string'};
|
|
161
|
+
i++; continue;
|
|
69
162
|
}
|
|
70
|
-
|
|
163
|
+
s+=line[i]; m+='.'; i++;
|
|
71
164
|
}
|
|
165
|
+
// `q` keeps its old meaning: the token as a whole is a string literal.
|
|
166
|
+
toks.push({v:s,m,qz,q:qz.length>0 && !m.includes('.')});
|
|
72
167
|
}
|
|
73
168
|
return {toks};
|
|
74
169
|
}
|
|
75
|
-
//
|
|
170
|
+
// Quotedness of the slice [a,b) of a token. An empty slice is "quoted" iff
|
|
171
|
+
// a quoted region opened exactly there (`present=""`, `data="",x`).
|
|
172
|
+
function sliceQ(t,a,b){
|
|
173
|
+
if(b>a) return !t.m.slice(a,b).includes('.');
|
|
174
|
+
return t.qz.some(z=>z>=a&&z<=b);
|
|
175
|
+
}
|
|
176
|
+
// Did the slice [a,b) contain ANY quoting? Id positions reject on this, not
|
|
177
|
+
// on sliceQ: `node "a"b` is as much a quoted id as `node "a"` is.
|
|
178
|
+
function sliceHasQ(t,a,b){
|
|
179
|
+
if(t.m.slice(a,b).includes('q')) return true;
|
|
180
|
+
return t.qz.some(z=>z>=a&&z<=b);
|
|
181
|
+
}
|
|
182
|
+
// Split the slice [off,end) of a token into list elements on UNQUOTED
|
|
183
|
+
// commas, so a quoted element protects its own comma (`LINK-OPERATOR-IN-IDS`). Returns
|
|
184
|
+
// {v, q, h} per element: value, fully-quoted, contains-any-quote.
|
|
185
|
+
function splitList(t,off){
|
|
186
|
+
const v=t.v, out=[]; let a=off==null?0:off;
|
|
187
|
+
const start=a;
|
|
188
|
+
for(let i=start;i<=v.length;i++){
|
|
189
|
+
if(i===v.length || (v[i]===','&&t.m[i]==='.')){
|
|
190
|
+
out.push({v:v.slice(a,i), q:sliceQ(t,a,i), h:sliceHasQ(t,a,i)});
|
|
191
|
+
a=i+1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
// Option keys are a CLOSED set (two levels):
|
|
76
197
|
// - a key listed in OPT_KEYS is parsed as an option; whether it is
|
|
77
198
|
// *applicable* is checked per directive against DIRECTIVE_OPTS
|
|
78
|
-
// (a registered-but-inapplicable key is a line error,
|
|
199
|
+
// (a registered-but-inapplicable key is a line error, `INAPPLICABLE-OPTION-KEYS`);
|
|
79
200
|
// - a key=value token with an unregistered key is an "unknown option"
|
|
80
|
-
// line error (
|
|
201
|
+
// line error (`UNKNOWN-OPTION-DEGRADATION`) — except inside timing `signal` lanes, where bare
|
|
81
202
|
// tokens may contain '=' and stay positional (laneMode).
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
203
|
+
// `fill` was registered here until this release solely to power a retired
|
|
204
|
+
// migration diagnostic on the old `line` directive; it left the registry with
|
|
205
|
+
// the `fill` → `band` KEYWORD rename, and 0.1 gave the word back to the
|
|
206
|
+
// option-key namespace as the primary presentation key (`color=` → `fill=`).
|
|
207
|
+
// `color` stays registered as a RETIRED key so the rename gets a named
|
|
208
|
+
// diagnostic instead of the generic `unknown option "color="`.
|
|
209
|
+
// `w`/`h` stay registered as RETIRED keys (same device as
|
|
210
|
+
// `color`): the rename to `width=`/`height=` gets a named diagnostic instead
|
|
211
|
+
// of the generic `unknown option "w="`. `dir` is retired the same way in
|
|
212
|
+
// favour of `extend=` on `band`.
|
|
213
|
+
// 0.1: `color` was LIVE again and meant the TEXT colour.
|
|
214
|
+
// 0.1 (`COLOUR-KEY-STATUS`): `color` is RETIRED for the third and last time, and this
|
|
215
|
+
// time the language gains no replacement — v0.1 has no author-facing label
|
|
216
|
+
// colour at all (the default is derived, `LABEL-COLOUR-SOURCE`; the owner-level key that could
|
|
217
|
+
// be added today is the wrong shape, core §9 `ANNOTATION-LOCATOR-SPLIT`). It stays registered so
|
|
218
|
+
// the message can name BOTH eras: a `color=` written before this release meant
|
|
219
|
+
// the FILL, one written meant the LABEL, and only a human
|
|
220
|
+
// knows which document this is. `text` and `z` stay registered
|
|
221
|
+
// as RETIRED keys so each rename gets a named diagnostic. `offset` replaces
|
|
222
|
+
// `threshold at=` (the directive was spelled `guide` until this release);
|
|
223
|
+
// `at` stays live on `pin`.
|
|
224
|
+
// 0.1: `level` stays registered as a RETIRED key — the construct was
|
|
225
|
+
// DELETED (`CHART-LEVEL-KEY`), and a registered-but-retired key is the only way the
|
|
226
|
+
// deletion gets a named message instead of `unknown option "level="`.
|
|
227
|
+
// 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`): `points`, `tailport`, `headport` and `routing` join them,
|
|
228
|
+
// for the same reason and with a message of a NEW shape — the constructs were
|
|
229
|
+
// WITHDRAWN, so there is no replacement spelling to name. Their acceptor
|
|
230
|
+
// directive (`path`) is gone too, so they have no acceptor row at all; they
|
|
231
|
+
// are registered here only so the diagnostic can fire language-wide.
|
|
232
|
+
// 0.1 (`BITFIELD-REPETITION-CONSTRUCT`): `index` joins the registry as `field`'s repetition key.
|
|
233
|
+
// Its value is a RANGE — `index=0..7` — and it is the only option key whose
|
|
234
|
+
// value grammar is a range, so it is also the key that fixes the language's
|
|
235
|
+
// ONE range spelling at `..` (`RANGE-SPELLING` moves `band` off the hyphen in the same
|
|
236
|
+
// release).
|
|
237
|
+
const OPT_KEYS=new Set(['kind','type','shape','fill','color','stroke','text','in','plane','layer','label',
|
|
238
|
+
'style','z','z-index','at','offset','w','h','width','height','unit','word','note','description','present','index','labels','data','numbering','from','to','gap',
|
|
239
|
+
'dir','extend','level','taillabel','headlabel','class','via','points','routing','src','dst','tailport','headport']);
|
|
85
240
|
// Applicable option keys per directive. Keys with dedicated diagnostics
|
|
86
|
-
// (node kind/w/h, edge label/taillabel/headlabel,
|
|
87
|
-
// are listed so their specific error messages
|
|
88
|
-
//
|
|
241
|
+
// (node kind/w/h, edge label/taillabel/headlabel, band from/to, and `color`
|
|
242
|
+
// wherever `fill` replaced it) are listed so their specific error messages
|
|
243
|
+
// fire. `edge` is consumed by
|
|
244
|
+
// parseEdgeLine; `title` takes exactly one quoted string (no options).
|
|
245
|
+
// §5 presentation attributes (`fill`, `stroke`, `text` label colour,
|
|
246
|
+
// `style` solid|dashed|dotted, `plane`) are optional on ANY element that can
|
|
247
|
+
// carry them with a rendering effect — the `GROUP-PRESENTATION-ATTRIBUTES` `group` decision applied to the
|
|
248
|
+
// whole registry. Three families carry a carve-out, because the
|
|
249
|
+
// attribute has nothing to act on there:
|
|
250
|
+
// - `external` is NEVER drawn (`EXTERNAL-EDGE-ENDPOINTS`) — no fill, no border, no dash; only its
|
|
251
|
+
// label exists, so it takes `text=` (plus `plane=`, organizational exactly
|
|
252
|
+
// as on a node);
|
|
253
|
+
// - `band` carried NO label channel at all until this release (`BAND-LABEL-STATUS`); it now
|
|
254
|
+
// takes a mandatory quoted label, so `color=` applies to it like any
|
|
255
|
+
// other labelled element;
|
|
256
|
+
// - typed blocks (`bitfield`/`table`/`timing`) stack in document order OUTSIDE
|
|
257
|
+
// the scene, so nothing can be layered against them (no `plane=`, on the
|
|
258
|
+
// block or its children), and a block-wide `style=` would collide with the
|
|
259
|
+
// per-item dash conventions (an `optional` field is dashed) — per-item
|
|
260
|
+
// `style=` on `field`/`cell`/`signal` covers the real need.
|
|
261
|
+
// 0.1 registry. `text` is listed wherever it was accepted so the
|
|
262
|
+
// RENAME fires with a named diagnostic instead of `unknown option`; `fill`
|
|
263
|
+
// stays listed on `edge`/`threshold`/`bundle` for the same reason (§8.4: those
|
|
264
|
+
// three have no interior, so `fill=` and `stroke=` named one channel with
|
|
265
|
+
// silent `stroke=` precedence).
|
|
266
|
+
// `at` stays listed on `threshold` so its rename to `offset=` is diagnosed.
|
|
267
|
+
// 0.1 (`BAND-LABEL-STATUS`): `band` gained a mandatory quoted label, so it gained the
|
|
268
|
+
// text channel with it and now accepts `color=` — the carve-out that refused
|
|
269
|
+
// the key existed only because there was no label to colour.
|
|
89
270
|
const DIRECTIVE_OPTS={
|
|
90
271
|
figdown:[],
|
|
91
|
-
node:['shape','
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
272
|
+
node:['shape','fill','stroke','style','class','in','plane','width','height'],
|
|
273
|
+
// `FLOWCHART-ROLE-KEYWORDS`: the three flowchart role keywords take EXACTLY the
|
|
274
|
+
// option keys `node` takes — they ARE nodes, with a role recorded. Listing
|
|
275
|
+
// `width`/`height` mirrors `node` so the same "use a pin line" diagnostic
|
|
276
|
+
// fires rather than a bare `unknown option`.
|
|
277
|
+
process:['shape','fill','stroke','style','class','in','plane','width','height'],
|
|
278
|
+
decision:['shape','fill','stroke','style','class','in','plane','width','height'],
|
|
279
|
+
terminator:['shape','fill','stroke','style','class','in','plane','width','height'],
|
|
280
|
+
group:['fill','stroke','style','gap','class','plane'],
|
|
281
|
+
external:['plane'],
|
|
282
|
+
edge:['style','class','fill','stroke','plane','label','taillabel','headlabel'],
|
|
283
|
+
plane:['z','z-index'], flow:[], rank:[],
|
|
284
|
+
bundle:['fill','stroke','style','plane'],
|
|
285
|
+
threshold:['in','at','offset','fill','stroke','style','plane'],
|
|
286
|
+
band:['in','extend','fill','stroke','style','plane','from','to'],
|
|
287
|
+
// `ELEMENT-GEOMETRY-DIRECTIVE`: `size` merged into `pin`. ONE directive carries an
|
|
288
|
+
// element's whole DECLARED geometry — `at=` places it, `width=`/`height=`
|
|
289
|
+
// extend it — and one model object records it. All three keys are optional
|
|
290
|
+
// and a `pin` with none of them is a line error, because it declares
|
|
291
|
+
// nothing. The domains differ and the parser enforces the difference:
|
|
292
|
+
// `at=` takes nodes, groups and externals; `width=`/`height=` take nodes
|
|
293
|
+
// only (a group sizes to its members, and an external or a typed block
|
|
294
|
+
// derives its geometry from its content).
|
|
295
|
+
pin:['at','width','height'],
|
|
296
|
+
layout:[],
|
|
297
|
+
'class':['fill','stroke','style','plane'],
|
|
298
|
+
// 0.1: `class=` is NOT accepted on the typed-block OPENERS. The
|
|
299
|
+
// normative registry (core §10) lists its acceptors as node/group/edge/
|
|
300
|
+
// field/cell — the block openers were an engine-only extra with 0 uses in
|
|
301
|
+
// either corpus, and a closed registry the engine widens is not closed.
|
|
302
|
+
bitfield:['word','numbering','fill','stroke'],
|
|
303
|
+
table:['fill','stroke'], timing:['fill','stroke'],
|
|
304
|
+
chart:['type'],
|
|
305
|
+
// `STYLE-KEY-SCOPE`: `style=` LEAVES `field`, `cell` and `signal`. On a
|
|
306
|
+
// `field` it silently erased the dash that is `optional`'s only visual
|
|
307
|
+
// carrier — `field "B" 8 optional style=solid` kept `optional:true` in the
|
|
308
|
+
// model and drew an ordinary field, so a reading agent and a human got
|
|
309
|
+
// different figures from one line. That is `PRESENTATION-AS-MEANING-CARRIER`'s exact prohibition
|
|
310
|
+
// (presentation may RENDER meaning, never be its only carrier). The three
|
|
311
|
+
// move together because they are one minimum set: 11 in-repo uses, 0
|
|
312
|
+
// downstream, and none of the three has a dash convention worth a key.
|
|
313
|
+
// `PRESENCE-CONDITION-EXPRESSION`/`DESCRIPTION-KEY-SPELLING`: `note=` became `description=` (IEEE 1685's own
|
|
314
|
+
// spelling for authored documentation prose) and the positional flag
|
|
315
|
+
// `optional` became the option key `present=`, whose VALUE is the presence
|
|
316
|
+
// condition. Both old spellings fire a named diagnostic.
|
|
317
|
+
// `BITFIELD-REPETITION-CONSTRUCT`: `index=` — the repetition construct. It states that
|
|
318
|
+
// the field is ONE ELEMENT of a repeated run and gives the run's index
|
|
319
|
+
// range; the engine derives the elision row and the index labels from it,
|
|
320
|
+
// exactly as it derives the dash and the caption from `present=`.
|
|
321
|
+
field:['fill','stroke','class','description','present','index'], 'break':[],
|
|
322
|
+
cell:['fill','stroke','class'], width:[],
|
|
323
|
+
signal:['data','fill','stroke'], gap:[]
|
|
324
|
+
};
|
|
325
|
+
const STYLES=['solid','dashed','dotted'];
|
|
326
|
+
// `RULE-POSITION-ENUMERATION`: every LIVE option key whose value grammar is an enum,
|
|
327
|
+
// read off spec/vocabulary-sources.tsv (`shape` column = `enum`, `status`
|
|
328
|
+
// live). These are the option half of RULE 2.4's enum positions; the
|
|
329
|
+
// positional half (`figdown` version and genre, `flow` direction, `cell`
|
|
330
|
+
// `highlight`) is checked at each directive, because each has its own
|
|
331
|
+
// positional layout. `type` is `chart`'s and is experimental; it is listed
|
|
332
|
+
// because the rule is language-wide, not surface-wide.
|
|
333
|
+
const ENUM_OPT_KEYS=['shape','style','numbering','extend','type'];
|
|
334
|
+
// Retired option key. `color=` set the FILL, but CSS and the
|
|
335
|
+
// diagram languages that borrowed from it use `color` for the TEXT colour and
|
|
336
|
+
// `fill` for the fill — so the old spelling read as its own opposite and
|
|
337
|
+
// produced a legal, wrong figure. One message, one lookup (PROCESS §5).
|
|
338
|
+
// 0.1: `color=` is a LIVE key again (it now sets the TEXT colour), so
|
|
339
|
+
// the 0.1 diagnostic is gone. Nothing can replace it: an engine
|
|
340
|
+
// reading `color=#dc2626` cannot tell a pre-0.1 source file —
|
|
341
|
+
// where it meant FILL — from a post-0.1 one, where it means TEXT. The whole burden of that
|
|
342
|
+
// distinction therefore sits in tools/migrate-figdown.js, which refuses to
|
|
343
|
+
// guess: its only auto-rewriting branch is gated on the presence of `text=`,
|
|
344
|
+
// a token the rewrite removes, so it cannot fire twice.
|
|
345
|
+
// Retired option keys. Unlike `color=` — which stayed on the
|
|
346
|
+
// directives that accepted it so the message could be per-directive — these
|
|
347
|
+
// are reported WHEREVER they appear, the `colw` → `width` precedent: the
|
|
348
|
+
// spelling is gone from the language, so no directive can accept it and the
|
|
349
|
+
// generic "<directive> does not take w=" would bury the rename.
|
|
350
|
+
// - `w=`/`h=`: SVG, CSS, Graphviz DOT, mxGraph and D2 all spell the size
|
|
351
|
+
// attributes in full. `fill=` and `stroke=` were borrowed whole from SVG;
|
|
352
|
+
// taking the abbreviation for the size pair while taking the full spelling
|
|
353
|
+
// for the paint pair breaks the single-source vocabulary rule (`UNSAFE-DEFAULT-ELIMINATION`).
|
|
354
|
+
// - `dir=`: HTML's `dir` attribute means TEXT WRITING DIRECTION. FigDown's
|
|
355
|
+
// meant "which way the band extends from its anchor" — the same spelling
|
|
356
|
+
// for a different meaning, which `UNSAFE-DEFAULT-ELIMINATION` treats as a defect in itself.
|
|
357
|
+
// 0.1 retirements. `text=` and `z=` left the LANGUAGE (the channel is
|
|
358
|
+
// now `color=`, the stacking key is now `z-index=`), so under RULE 6.2's
|
|
359
|
+
// placement test they fire wherever they appear, the `w=`/`h=`/`colw`
|
|
360
|
+
// precedent. `text=`'s message must carry the 0.1 warning too: this
|
|
361
|
+
// is the one migration that is NOT a rename — old `color=` set the FILL, new
|
|
362
|
+
// `color=` sets the TEXT — and once `color=` is live again the engine can no
|
|
363
|
+
// longer diagnose a pre-0.1 document at all.
|
|
364
|
+
const RETIRED_OPT_KEYS={
|
|
365
|
+
w:'w= has been renamed: use width= (SVG, CSS, DOT, mxGraph and D2 all spell it in full — no standard abbreviates it) (MIGRATIONS)',
|
|
366
|
+
h:'h= has been renamed: use height= (SVG, CSS, DOT, mxGraph and D2 all spell it in full — no standard abbreviates it) (MIGRATIONS)',
|
|
367
|
+
dir:'dir= has been renamed: use extend= (HTML\'s dir= is text writing direction; this one says which way the band extends from its anchor) (MIGRATIONS)',
|
|
368
|
+
text:'text= has been retired: v0.1 has NO label-colour key — the label colour is DERIVED from the fill it sits on (core §5), and the owner-level key that could replace it would colour an edge\'s [tail]/[mid]/[head] labels identically, which is the wrong shape (core §9 `ANNOTATION-LOCATOR-SPLIT`). Delete the key; if the distinction was knowledge, write it in the label or a class= meaning (§5, `PRESENTATION-AS-MEANING-CARRIER`) (MIGRATIONS)',
|
|
369
|
+
// `COLOUR-KEY-STATUS`. This is the ONLY key in the language whose diagnostic
|
|
370
|
+
// must name two eras and refuse to choose between them: the same six
|
|
371
|
+
// characters meant the FILL before this release and the LABEL after
|
|
372
|
+
// 0.1, and no engine can tell the two source files apart. Retiring
|
|
373
|
+
// the key is what makes the difference DIAGNOSABLE at all — while it was
|
|
374
|
+
// live, a pre-0.1 document parsed and drew a legal, wrong figure in
|
|
375
|
+
// silence.
|
|
376
|
+
//
|
|
377
|
+
// 0.1: the MESSAGE may not name those release numbers. A `.fd` does
|
|
378
|
+
// not record which pre-release wrote it — downstream every document says
|
|
379
|
+
// `figdown 0.1` — so "which era is this document from?" is a question the
|
|
380
|
+
// reader cannot answer by looking at anything. What they CAN look at is the
|
|
381
|
+
// rest of the file, and the two eras differ in what else it may contain:
|
|
382
|
+
// `fill=` did not exist while `color=` meant the FILL, and the older
|
|
383
|
+
// spellings were gone by the time it meant the LABEL. The message names
|
|
384
|
+
// those observables, says plainly when a file has neither, and cites the
|
|
385
|
+
// release only as a MIGRATIONS lookup. Same rule as the migration tool's
|
|
386
|
+
// `color=` family, which reads the same evidence to decide its refusals.
|
|
387
|
+
color:'color= has been retired: the same six characters set the box FILL in one era of this language and the LABEL colour in another, and this line does not say which — which is why the key is gone rather than renamed. READ IT OFF THE REST OF THE DOCUMENT. A file that also writes fill= cannot be from the FILL era (the two keys never coexisted), so its color= was a LABEL colour: delete it and let the derived default apply (core §5). A file still writing the spellings that were retired before the LABEL era (w= h= unit= via= dir= kind= layer= boundary wrap optional) cannot be from that era, so its color= was a FILL: write fill= instead. A file with NEITHER carries no evidence at all, and the two readings then differ only in what was DRAWN — as a FILL the value painted the box interior, as a LABEL colour it painted only the text. If the colour carried meaning, put that meaning in the label or a class= (§5, `PRESENTATION-AS-MEANING-CARRIER`). tools/migrate-figdown.js reads this evidence for you and REFUSES the wrong --color-means=fill|text (MIGRATIONS)',
|
|
388
|
+
kind:'kind= has been renamed: on a node use shape= (geometric; the label text carries the device semantics — MIGRATIONS 0.1), on a chart use type= (Vega, Chart.js and ECharts all spell the chart-type key "type" — MIGRATIONS 0.1). One spelling was retired on node and live on plot at the same time, inside one namespace; 0.1 closed that.',
|
|
389
|
+
layer:'layer= has been renamed: use plane= (mxGraph makes a layer a containment parent that establishes coordinates; Inkscape layers can carry a transform; OGC WMS layers carry an SRS; CSS @layer is cascade priority. None of those is what this key does, and SVG has no layer at all) (MIGRATIONS)',
|
|
390
|
+
labels:'labels= has been renamed: use data= (WaveDrom\'s own key for exactly this is `data`, "an array of signal labels" — one per value cell of the lane) (MIGRATIONS)',
|
|
391
|
+
// 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`). These six keys end in a WITHDRAWAL, not a rename, so
|
|
392
|
+
// their messages have a shape no earlier retirement in this table has: they
|
|
393
|
+
// name no replacement spelling, because there is none. `via=`/`src=`/`dst=`
|
|
394
|
+
// were renamed to keys that have since been withdrawn, so each
|
|
395
|
+
// states the whole two-hop chain in one lookup (the `line` → `guide` →
|
|
396
|
+
// `threshold` precedent) and ends at the same place.
|
|
397
|
+
via:'via= has been WITHDRAWN: it was renamed points=, and points= was withdrawn with the whole `path` directive. There is no replacement spelling — author waypoints are outside the stable prior-art intersection (only 2 of 4 surveyed systems model them, and those 2 disagree on what happens when an endpoint moves). Delete the line; the edge draws under auto layout. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
|
|
398
|
+
src:'src= has been WITHDRAWN: it was renamed tailport=, and tailport= was withdrawn with the whole `path` directive. There is no replacement spelling — role-addressed attachment IS inside the stable prior-art intersection, but per-edge addressing needs an edge-identity construct FigDown does not have. Delete the line; the edge draws under auto layout. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
|
|
399
|
+
dst:'dst= has been WITHDRAWN: it was renamed headport=, and headport= was withdrawn with the whole `path` directive. There is no replacement spelling — role-addressed attachment IS inside the stable prior-art intersection, but per-edge addressing needs an edge-identity construct FigDown does not have. Delete the line; the edge draws under auto layout. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
|
|
400
|
+
points:'points= has been WITHDRAWN with the `path` directive (`EDGE-GEOMETRY-CONSTRUCTS`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. Author waypoints are outside the stable prior-art intersection — only 2 of 4 surveyed systems model them, and those 2 disagree on what happens when an endpoint moves (mxGraph leaves them behind, ELK carries them, Visio has no waypoint concept at all). Delete the line; the edge draws under auto layout, and `pin`, `rank`, `flow` and declaration order are the content-zone means of shaping it. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
|
|
401
|
+
tailport:'tailport= has been WITHDRAWN with the `path` directive (`EDGE-GEOMETRY-CONSTRUCTS`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. Attachment to a named site addressed by semantic role IS inside the stable prior-art intersection; FigDown\'s realisation was not (a fraction on the EDGE is mxGraph-only, and written-order attachment has zero prior art in any surveyed system). Restoring it needs an edge-identity construct first. Delete the line; the edge draws under auto layout. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
|
|
402
|
+
headport:'headport= has been WITHDRAWN with the `path` directive (`EDGE-GEOMETRY-CONSTRUCTS`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. Attachment to a named site addressed by semantic role IS inside the stable prior-art intersection; FigDown\'s realisation was not (a fraction on the EDGE is mxGraph-only, and written-order attachment has zero prior art in any surveyed system). Restoring it needs an edge-identity construct first. Delete the line; the edge draws under auto layout. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
|
|
403
|
+
routing:'routing= has been WITHDRAWN with the `path` directive (`EDGE-GEOMETRY-CONSTRUCTS`): the construct is removed from the language, not renamed, so there is no spelling to migrate to. The per-edge routing SCOPE was inside the stable prior-art intersection and is deliberately lost with its host line — an override needs an edge to address, and FigDown has no edge-identity construct. Delete the line. The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md',
|
|
404
|
+
unit:'unit= has been renamed: use word= (RFC 2360 §3.1: "a sequence of long words in network byte order, with each word horizontal on the page"; RFC 791 §3.1 measures the header in "32 bit words". Mermaid names the identical setting bitsPerRow — semantically right, camelCase barred. `unit=32` also inverts count-vs-unit, reading as "the unit is 32", and C\'s "unit" is the addressable storage unit, not the row width) (MIGRATIONS)',
|
|
405
|
+
z:'z= has been renamed: use z-index= (CSS spells the stacking concept z-index, and RULE 4.2 takes the standard spelling in full; a single-letter key is structurally risky next to the closed timing lane alphabet, `LANE-ALPHABET-KEY-RESERVATION`) (MIGRATIONS)',
|
|
406
|
+
// `DESCRIPTION-KEY-SPELLING`. The spelling leaves the LANGUAGE, so the message fires
|
|
407
|
+
// wherever it appears (the `w=`/`h=`/`unit=` placement test, RULE 6.2).
|
|
408
|
+
note:'note= has been renamed: use description= (IEEE 1685-2022 spells this channel `description`; SystemRDL\'s `desc` is barred by RULE 4.2 as an abbreviation. The rename is defensive: `ANNOTATION-LOCATOR-SPLIT` files `note` as the highest-demand v0.2 annotation construct — ~66 figure-identities, 20 independent reinventions — and that one will be a DRAWN callout, so a never-drawing `note=` beside an always-drawing `note` would be one spelling with two opposite behaviours) (MIGRATIONS)',
|
|
409
|
+
level:'level= has been DELETED, not renamed: it drew a reference plane through a 3-D bar chart, has zero uses in either downstream corpus and zero 3-D bar charts to draw it on, was the only construct whose caption the ENGINE wrote rather than the author, and its parseFloat grammar uniquely accepted 1e3 where every other number in the language is \\d+(\\.\\d+)? — delete the key (MIGRATIONS)'
|
|
108
410
|
};
|
|
411
|
+
// `PLANE-KEYWORD-SPELLING`: the keyword `plane`/`plane=` was spelled `layer`/`layer=`.
|
|
412
|
+
const RETIRED_LAYER='layer has been renamed: use plane (in mxGraph — the geometry model FigDown adopted — a layer is a CONTAINMENT PARENT that establishes coordinates, so layer=overlay reads as "reparent and re-origin this element", which FigDown does not do; Inkscape layers are <g> and may carry a transform, OGC WMS layers each carry an SRS, and CSS @layer is cascade priority with no visual meaning. SVG has no layer concept at all. `plane` is claimed by no standard for a conflicting meaning and removes the layout/layer near-miss) (MIGRATIONS)';
|
|
413
|
+
// `THRESHOLD-KEYWORD-SPELLING`: the scene keyword `guide` became `threshold`.
|
|
414
|
+
const RETIRED_GUIDE='guide has been renamed: use threshold (in Illustrator, Inkscape, Figma and draw.io a "guide" is an author-only construction line that is NEVER rendered, while FigDown\'s is drawn output — an INVERTED name, which `UNSAFE-DEFAULT-ELIMINATION` rates worse than an unfamiliar one, and no counter-example was found where "guide" names rendered output. `guide` was also a FigDown coinage, and `SIZE-AND-DIRECTION-KEY-NAMING` makes coining a last resort; `threshold` comes whole from Grafana, whose "Show thresholds" render option offers "As lines", "As filled regions" and "As filled regions and lines" — FigDown\'s marker + region pair, split the same way — with IETF RED/AQM as the secondary source (RFC 2309: "Two RED parameters, minth (minimum threshold) and maxth (maximum threshold)"; RFC 7567: "an AQM algorithm configured with a threshold"). 78% of the measured corpus marks are thresholds; target/mean/reference marks: 0) (MIGRATIONS)';
|
|
415
|
+
// `EXTERNAL-ENDPOINT-NAMING`: the scene keyword `boundary` became `external`.
|
|
416
|
+
const RETIRED_BOUNDARY='boundary has been renamed: use external (it declares an external I/O endpoint — the spec\'s own words — while UML\'s «boundary» is an INTERNAL interface object, C4\'s System_Boundary is a dashed grouping container FigDown already spells `group`, and BPMN\'s Boundary Event is a third meaning) (MIGRATIONS)';
|
|
417
|
+
// `ROW-BREAK-NAMING`: the `bitfield` child keyword `wrap` became `break`.
|
|
418
|
+
const RETIRED_WRAP='wrap has been renamed: use break (in CSS and typography `wrap` is AUTOMATIC reflow — a mode — while this directive is an EXPLICIT row break, an event; CSS Fragmentation calls it "a forced break … explicitly indicated by the … author", HTML spells it `br`) (MIGRATIONS)';
|
|
419
|
+
// `PRESENCE-FLAG-SPELLING`: the 0.1 rename `optional` -> `conditional` (`PRESENCE-FLAG-SPELLING`)
|
|
420
|
+
// is REVERTED. `conditional` has zero attestation as a wire-format field
|
|
421
|
+
// marker and zero uses in the downstream corpus, while "optional" appears in
|
|
422
|
+
// 34 downstream field LABELS — authors wrote the word in the label precisely
|
|
423
|
+
// because the keyword no longer said it. The MODEL key moves with the surface
|
|
424
|
+
// (`NORMATIVE-SEMANTIC-MODEL`): it is `optional` again on both sides.
|
|
425
|
+
const RETIRED_FIELD_CONDITIONAL='the field flag "conditional" has been retired: write present="<the condition>" (or present="" if the condition is not stated). "conditional" was attested as a wire-format field marker nowhere — zero hits in RFC 2784, ASN.1 X.680, draft-mcquistin-augmented-ascii-diagrams, SystemRDL, IP-XACT, Kaitai Struct and protobuf — and the flag it briefly replaced, "optional", is retired too: an option key carrying the CONDITION says what a bare flag could not (MIGRATIONS)';
|
|
426
|
+
// `PRESENCE-CONDITION-EXPRESSION`: the positional flag `optional` becomes the option key
|
|
427
|
+
// `present=`, whose VALUE is the presence condition. A bare flag could say
|
|
428
|
+
// only THAT a field is conditional; every RFC that draws one also states WHY
|
|
429
|
+
// ("present only if the C bit is set"), and until now that sentence had
|
|
430
|
+
// nowhere to live but `note=` — invisible to the reader and, under `BITFIELD-CONDITIONAL-OFFSETS`,
|
|
431
|
+
// prose the model may not read. `present` is the attested spelling: X.680
|
|
432
|
+
// PRESENT, IP-XACT isPresent, SystemRDL ispresent, RFC 2784 "present only
|
|
433
|
+
// if", draft-mcquistin "present only when".
|
|
434
|
+
const RETIRED_FIELD_OPTIONAL='the field flag "optional" has been retired and replaced by an option key that carries the CONDITION: write present="<the condition>" (e.g. field "Checksum" 16 present="C = 1"), or present="" when the condition is not stated. The bare flag could say only THAT the field was conditional, so the condition had to live in note= — invisible to the human reading the figure, and prose the model may not parse (`BITFIELD-CONDITIONAL-OFFSETS`). present= DRAWS: the field stays dashed and a stated condition becomes a caption under the block (MIGRATIONS)';
|
|
435
|
+
// `TIMING-GENRE-NAMING`: the EXPERIMENTAL genre `wave` became `timing`, both as
|
|
436
|
+
// the header genre token and as the block opener. The old name was WaveDrom's
|
|
437
|
+
// MEMBER KEY, not its figure name: in WaveJSON `signal` is the root object and
|
|
438
|
+
// `wave` is a PROPERTY of one signal — the lane activity string — so FigDown
|
|
439
|
+
// named a whole figure kind after a per-lane attribute and left WaveDrom's own
|
|
440
|
+
// figure name ("Digital Timing Diagram") unused. `timing` is the datasheet and
|
|
441
|
+
// JEDEC term for this figure, and UML 2.5.1's Timing Diagram is the same
|
|
442
|
+
// concept (a lifeline's state over time drawn as a waveform), so it is correct
|
|
443
|
+
// reuse under §10, not a collision. The rename also FREES `wave` for the lane,
|
|
444
|
+
// which is where WaveJSON puts it.
|
|
445
|
+
// `ELEMENT-GEOMETRY-DIRECTIVE`: `size` is RETIRED — it did not move to another
|
|
446
|
+
// directive, it MERGED into one. The message therefore names a whole line
|
|
447
|
+
// rather than a word: an author holding `size a width=120 height=60` needs
|
|
448
|
+
// to know that the keys survive unchanged and only their carrier moved.
|
|
449
|
+
// RULE 6.2 placement: the spelling left the LANGUAGE, so this fires wherever
|
|
450
|
+
// it appears at line start, in every genre, ahead of the `GENRE-KEYWORD-ALLOWLIST` allowlist.
|
|
451
|
+
const RETIRED_SIZE='size has been retired: its keys moved onto pin — write pin <id> width=<px> height=<px> (one directive carries an element\'s whole declared geometry: at= places it, width=/height= extend it; all three keys are optional and a pin with none of them declares nothing) (MIGRATIONS)';
|
|
452
|
+
const RETIRED_WAVE='wave has been renamed: use timing (in WaveJSON `signal` is the root object and `wave` is a PROPERTY of one signal — its lane activity string — so `wave` named a member key, not a figure kind; WaveDrom\'s own name for the figure is "Digital Timing Diagram", "timing diagram" is the datasheet/JEDEC term for it, and UML 2.5.1\'s Timing Diagram is the same concept. The rename frees `wave` for the lane) (MIGRATIONS)';
|
|
453
|
+
// `EDGE-GEOMETRY-CONSTRUCTS`: `path` and `routing` are WITHDRAWN from the language.
|
|
454
|
+
// These two diagnostics are a NEW SHAPE for this table. Every retirement
|
|
455
|
+
// before them named a replacement spelling — `size` named `pin`, `guide` named
|
|
456
|
+
// `threshold`, `layer` named `plane`, and even `level`, which was DELETED,
|
|
457
|
+
// could say "delete the key" because the key was a modifier on a surviving
|
|
458
|
+
// line. These name none, because there is none: the constructs are gone and
|
|
459
|
+
// nothing in v0.1 does what they did. RULE 6.2 still binds — the spelling must
|
|
460
|
+
// be a line error that names its MIGRATIONS entry, never silently accepted and
|
|
461
|
+
// never silently rejected — so what changes is the sentence, not the contract.
|
|
462
|
+
// Each message therefore does three things: says WITHDRAWN (not "renamed",
|
|
463
|
+
// not "use X"), says what an author should do instead (delete the line and let
|
|
464
|
+
// auto layout draw it, with the content-zone means named), and points at where
|
|
465
|
+
// the decision is RECORDED so the reasoning is one lookup away.
|
|
466
|
+
const WITHDRAWN_WHERE=' The decision and its evidence: MIGRATIONS 0.1, core §9 `EDGE-IDENTITY-AND-GEOMETRY`, decisions/registry.md';
|
|
467
|
+
const RETIRED_PATH='path has been WITHDRAWN from the language (`EDGE-GEOMETRY-CONSTRUCTS`) — removed, not renamed, so there is no replacement spelling. A prior-art study of Visio, draw.io/mxGraph, Graphviz and ELK found author waypoints OUTSIDE the stable intersection: only 2 of the 4 model them, and those 2 disagree on what happens when an endpoint moves. The dock realisation was outside it too — written-order attachment has zero prior art in any surveyed system. Delete the line: the edge draws under auto layout, and `rank`, `flow`, declaration order and `pin` are the content-zone means of shaping it.'+WITHDRAWN_WHERE;
|
|
468
|
+
const RETIRED_ROUTING='routing has been WITHDRAWN from the language (`EDGE-GEOMETRY-CONSTRUCTS`) — removed, not renamed, so there is no replacement spelling. Two routing modes and two scopes ARE inside the stable prior-art intersection, so the need is recognised and its shape is known; what is missing is the evidence and the implementation (6 of the 8 in-repo `routing=orthogonal` writings were provable no-ops, and downstream adoption was zero), and the per-edge scope cannot be restored without an edge-identity construct FigDown does not have. Delete the line; the edges draw straight.'+WITHDRAWN_WHERE;
|
|
469
|
+
// `TIMING-LANE-ALPHABET`: the timing lane digits `2`-`9` left the closed alphabet.
|
|
470
|
+
const RETIRED_LANE_DIGIT='timing lane digits 2-9 have been retired: write "=" for a data cell and name it in data= (WaveDrom defines 2..9 as "value with color N" and "=" as "value (default color 2)" — the same brick with a palette index, while FigDown drew the digit character itself as the box label and consumed no data entry, so the two readings of one lane differed silently) (MIGRATIONS)';
|
|
471
|
+
// 0.1 (§8.4): `edge`, `threshold` and `bundle` have NO interior, so
|
|
472
|
+
// `fill=` and `stroke=` named the SAME channel and `stroke=` won silently —
|
|
473
|
+
// two keys for one channel, resolved by an undocumented precedence that
|
|
474
|
+
// produced a legal, wrong figure whenever both were written (16 lines in
|
|
475
|
+
// this repository, 3 of them writing both on one line). Same defect shape as
|
|
476
|
+
// the retired `color=`; same cure, a named diagnostic.
|
|
477
|
+
const NO_INTERIOR=new Set(['edge','threshold','bundle']);
|
|
478
|
+
const FILL_NO_INTERIOR=k=>k+' has no interior, so fill= and stroke= name the same channel (stroke= won silently) — write stroke= (MIGRATIONS)';
|
|
479
|
+
// `STYLE-KEY-SCOPE`: `style=` left these three directives (it stays live on
|
|
480
|
+
// node/group/edge/class/bundle/threshold/band). The generic
|
|
481
|
+
// "<directive> does not take style=" would be true but would not say why, and
|
|
482
|
+
// on `field` the removal is a CORRECTNESS fix, not a tidy-up — so these three
|
|
483
|
+
// get a named diagnostic, the FILL_NO_INTERIOR precedent.
|
|
484
|
+
// `ROW-HIGHLIGHT-CELL-FILL-COLLISION`: the row tint and a cell fill are ONE channel. `STYLE-KEY-SCOPE`
|
|
485
|
+
// filed both halves of this as an HONEST NEGATIVE and left them open; they
|
|
486
|
+
// are closed here the way every other one-channel-two-keys collision in this
|
|
487
|
+
// language was closed — a named line error, not a precedence rule. A
|
|
488
|
+
// precedence rule is what `STYLE-KEY-SCOPE` had just finished removing from `field`.
|
|
489
|
+
const CELL_HL_ON_CELL='highlight is a ROW mark and takes the single-valued row form (cell <row> highlight) — on a cell address it was SILENTLY DISCARDED and never reached the model, while the cell fill drew. A row tint and a cell fill paint the same channel, so writing both for one cell has no honest resolution: tint the row (cell <row> highlight) or paint the cell (cell (<row>,<col>) fill=…/class=…), not both (MIGRATIONS)';
|
|
490
|
+
const CELL_HL_ROW_CONFLICT=(r,c)=>'cell ('+r+','+c+') resolves to a fill on row '+r+', which is highlighted — the cell fill overrides the row tint, so the model says "row '+r+' is highlighted" while the drawing shows only part of the row tinted (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never delete it). Drop the row highlight, or move the cell fill to a row that carries none (MIGRATIONS)';
|
|
491
|
+
const NO_ITEM_STYLE=new Set(['field','cell','signal']);
|
|
492
|
+
const STYLE_NO_ITEM=k=>k+' does not take style= — '+(k==='field'
|
|
493
|
+
? 'on a field the dash IS conditional presence (`present=`, spelled `optional` until this release), and style=solid erased it while the model still recorded the field as conditionally present (`PRESENTATION-AS-MEANING-CARRIER`: presentation may render meaning, never be its only carrier)'
|
|
494
|
+
: 'a dash on one '+k+' carried no meaning the block does not already carry, and 0 documents outside this repository wrote it')
|
|
495
|
+
// `DESCRIPTION-KEY-SPELLING` corrected the second half of this message. It used to
|
|
496
|
+
// offer `note=` as a place to put knowledge, which was wrong twice over:
|
|
497
|
+
// the key is now spelled `description=`, and NEITHER spelling draws
|
|
498
|
+
// anything beyond a tooltip — sending knowledge there hides it from the
|
|
499
|
+
// human, which is the same defect `STYLE-KEY-SCOPE` exists to close. The two channels
|
|
500
|
+
// that DRAW are the name/label and a `class` meaning (which also earns a
|
|
501
|
+
// legend entry).
|
|
502
|
+
+'. Delete the key; if the distinction is knowledge, write it in the name/label or in a class= meaning — both of which DRAW; description= is documentation prose and produces no ink beyond a tooltip (MIGRATIONS)';
|
|
109
503
|
function splitOpts(toks, laneMode){
|
|
110
|
-
|
|
504
|
+
// a repeated option key on one line is a line error, never
|
|
505
|
+
// silent last-wins. `dup` names the first key that appeared twice.
|
|
506
|
+
// `OPTION-POSITION-PARSING`: `posq` carries the quotedness of each POSITIONAL
|
|
507
|
+
// token, index-aligned with `pos`. Before it existed, the two directives
|
|
508
|
+
// that need to know whether their label was quoted (`bundle`, `threshold`)
|
|
509
|
+
// tested `tk.toks[i].q` — the RAW stream — while reading the value from
|
|
510
|
+
// `pos`, the option-stripped one. The indices diverge the moment an
|
|
511
|
+
// option precedes the label (`bundle t1 fill=red "LAG" a--b`), so a legal
|
|
512
|
+
// line was rejected. Option position is free everywhere else; it is free
|
|
513
|
+
// here now too.
|
|
514
|
+
// `LINK-OPERATOR-IN-IDS`: `posT` and `optT` carry the TOKEN behind each value —
|
|
515
|
+
// `OPTION-POSITION-PARSING` built `posq` for positional quotedness; the value layer (comma
|
|
516
|
+
// splitting, id checks, string-typed option values) needs the same
|
|
517
|
+
// information, and it needs it for option VALUES too. `optT[k]` is
|
|
518
|
+
// {t, off}: the token and the offset in `t.v` at which the value starts.
|
|
519
|
+
const pos=[], posq=[], posT=[], opts={}, optT={}, unk=[]; let dup=null;
|
|
111
520
|
for(const t of toks){
|
|
112
521
|
const m = !t.q && /^([A-Za-z_][A-Za-z0-9_-]*)=([\s\S]*)$/.exec(t.v);
|
|
113
|
-
if(m && OPT_KEYS.has(m[1]))
|
|
522
|
+
if(m && OPT_KEYS.has(m[1])){
|
|
523
|
+
if(Object.prototype.hasOwnProperty.call(opts,m[1]) && dup===null) dup=m[1];
|
|
524
|
+
opts[m[1]]=m[2];
|
|
525
|
+
optT[m[1]]={t, off:m[1].length+1};
|
|
526
|
+
}
|
|
114
527
|
else if(m && !laneMode) unk.push(m[1]);
|
|
115
|
-
else pos.push(t.v);
|
|
528
|
+
else { pos.push(t.v); posq.push(!!t.q); posT.push(t); }
|
|
116
529
|
}
|
|
117
|
-
return {pos,opts,unk};
|
|
530
|
+
return {pos,posq,posT,opts,optT,unk,dup};
|
|
118
531
|
}
|
|
532
|
+
// The comma form a retired space form should have been written as. Trailing
|
|
533
|
+
// commas on the individual tokens are the half-converted spelling (`a, b`),
|
|
534
|
+
// so they are dropped before joining — the suggestion must be paste-ready.
|
|
535
|
+
const joinListForm=toks=>toks.map(t=>String(t).replace(/,+$/,'')).filter(Boolean).join(',');
|
|
536
|
+
// Was the VALUE of option `k` written with quotes anywhere / entirely?
|
|
537
|
+
function optHasQ(optT,k){ const e=optT[k]; return !!e && sliceHasQ(e.t,e.off,e.t.v.length); }
|
|
538
|
+
function optQ(optT,k){ const e=optT[k]; return !!e && sliceQ(e.t,e.off,e.t.v.length); }
|
|
539
|
+
// The elements of a comma list held in option `k`, quote-aware.
|
|
540
|
+
function optList(optT,k){ const e=optT[k]; return e?splitList(e.t,e.off):null; }
|
|
541
|
+
// An id is a BARE token matching this pattern. The pattern is a whole-token
|
|
542
|
+
// anchor: `-` and `_` are ordinary interior characters, so a TRAILING `-` or
|
|
543
|
+
// `_` is legal (`node mux_ "MUX"` parses) — a downstream author renamed ids
|
|
544
|
+
// on the mistaken belief that it was not, so core §1 now says so explicitly.
|
|
119
545
|
const ID_RE=/^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
546
|
+
// `LINK-OPERATOR-IN-IDS`: `--` is the LINK OPERATOR (`edge a -- b`,
|
|
547
|
+
// `bundle t1 a--b`), so it must not also be able to occur inside an id.
|
|
548
|
+
// While it could, `bundle t1 a-x--b` had two legal readings (`a-x`+`b` and
|
|
549
|
+
// `a`+`x--b`) and the greedy member regex silently committed to the first,
|
|
550
|
+
// making the second unreachable and undiagnosed — RULE 6.3 says malformed
|
|
551
|
+
// input is an error, never a guess. Zero ids in either corpus contain `--`.
|
|
552
|
+
const DD_ID='"--" is not allowed inside an id — it is the link operator (edge a -- b, bundle t1 a--b); write a single "-" or "_" (MIGRATIONS)';
|
|
553
|
+
// `QUOTED-IDS`: ONE wording for every id position. It covers both
|
|
554
|
+
// halves of the defect it closes — a needlessly quoted legal id
|
|
555
|
+
// (`node "a"`, silently accepted before) and a quoted token that is not a
|
|
556
|
+
// legal id at all (`node "Router 1"`, which used to report the unhelpful
|
|
557
|
+
// `node needs an id`) — because in both cases the author's next move is the
|
|
558
|
+
// same: put the text in the label slot and leave the id bare.
|
|
559
|
+
// `QUOTING-RULES` — quoting: the TYPE gates eligibility, the DELIMITER
|
|
560
|
+
// gates necessity.
|
|
561
|
+
// whitespace-delimited string position -> quotes MANDATORY
|
|
562
|
+
// comma-delimited list element -> quotes only when the element
|
|
563
|
+
// contains whitespace , " or #
|
|
564
|
+
// (redundant quotes stay legal)
|
|
565
|
+
// [ ] edge label -> quotes only to enable escapes
|
|
566
|
+
// The whitespace row is not a style preference: whitespace is ALSO the
|
|
567
|
+
// positional separator, so a bare token cannot grow into a phrase and the
|
|
568
|
+
// old failure named the wrong thing (`node a Cache miss` reported
|
|
569
|
+
// `unexpected argument "miss"` — a surplus argument, when the defect was a
|
|
570
|
+
// missing quote).
|
|
571
|
+
const Q_WHY='whitespace also separates positionals, so a bare token cannot express a phrase (MIGRATIONS)';
|
|
572
|
+
const ID_RULE='ids are bare and match [A-Za-z_][A-Za-z0-9_-]* — text with spaces or punctuation belongs in the label: node <id> "your text"';
|
|
573
|
+
// isId: the whole id test, used at every id position in the language.
|
|
574
|
+
const isId=v=>typeof v==='string'&&ID_RE.test(v)&&!v.includes('--');
|
|
575
|
+
// idErr(value, quotedFlag, missingMsg) -> the message for an id position, or
|
|
576
|
+
// null when the id is well formed. `missing` fires only when nothing at all
|
|
577
|
+
// was written; a written-but-illegal id always gets ID_RULE or DD_ID, never
|
|
578
|
+
// "<directive> needs an id".
|
|
579
|
+
function idErr(v,quoted,missing){
|
|
580
|
+
if(v===undefined||v===null||v==='') return quoted?ID_RULE:missing;
|
|
581
|
+
if(quoted) return ID_RULE;
|
|
582
|
+
if(ID_RE.test(v)&&v.includes('--')) return DD_ID;
|
|
583
|
+
if(!ID_RE.test(v)) return ID_RULE;
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
// `RULE-POSITION-ENUMERATION`: RULE 2.4's OTHER HALF, enforced. The rule reads "where
|
|
587
|
+
// the grammar expects an id OR AN ENUM VALUE, the token MUST be bare", and
|
|
588
|
+
// until now only the id half existed — 14 of 14 id positions rejected a
|
|
589
|
+
// quoted token through `idErr`, and 0 of the enum positions rejected
|
|
590
|
+
// anything (`shape="box"`, `style="dashed"`, `flow "right"`,
|
|
591
|
+
// `figdown "0.1" block`, `numbering="msb0"`, `cell 1 "highlight"` all
|
|
592
|
+
// parsed). A rule enforced at half its positions is worse than no rule.
|
|
593
|
+
//
|
|
594
|
+
// THE LINE, and it is derivable rather than a list: a position whose value
|
|
595
|
+
// is drawn from a CLOSED SET OF SPELLINGS is written BARE. That is exactly
|
|
596
|
+
// the id argument (`QUOTED-IDS`) generalised — an id is closed by declaration, an
|
|
597
|
+
// enum and a bare keyword flag are closed by the standard — and quoting one
|
|
598
|
+
// invites the same false belief, that arbitrary text is admissible there
|
|
599
|
+
// (`PRIOR-ART-BORROWING`: two spellings of one meaning feed hallucination).
|
|
600
|
+
//
|
|
601
|
+
// Its complement is stated in the same breath, because leaving it unstated
|
|
602
|
+
// is what made RULE 2.3 and RULE 2.4 disagree at the table `width` position:
|
|
603
|
+
// where the value comes from an OPEN VALUE SPACE — a number, a point, a
|
|
604
|
+
// percentage, a range — redundant quotes stay INERT and legal. `index=`'s
|
|
605
|
+
// inertness is normative and load-bearing there (core §12.7, `INDEX-RANGE-STEP`), and
|
|
606
|
+
// 2.3's own reason applies: a generating agent that quotes uniformly must
|
|
607
|
+
// not be punished, and a validator that rejects redundant quotes makes the
|
|
608
|
+
// write->validate->fix loop oscillate.
|
|
609
|
+
//
|
|
610
|
+
// TWO WORD-SHAPED SPELLINGS SIT INSIDE OTHERWISE-NUMERIC GRAMMARS and stay
|
|
611
|
+
// INERT deliberately — table `width auto,90` and `field "A" *`. They are
|
|
612
|
+
// declared exceptions with a stated reason, SYNTAX-STYLE §8.6, not an
|
|
613
|
+
// oversight: both are ONE alternative inside a numeric value grammar, so the
|
|
614
|
+
// element they sit in is typed by that grammar, and splitting one comma list
|
|
615
|
+
// into bare-word and quotable-number elements would be a worse rule than the
|
|
616
|
+
// exception.
|
|
617
|
+
const ENUM_WHY='quoting a value drawn from a closed set suggests the position accepts arbitrary text, when it accepts only the listed spellings (SYNTAX-STYLE RULE 2.4)';
|
|
618
|
+
const ENUM_BARE=shown=>'this position takes a BARE value: write '+shown+' — '+ENUM_WHY;
|
|
619
|
+
// class= is multi-valued (comma-separated ids). Returns {ok, ids, err} —
|
|
620
|
+
// empty value, empty members, quoted members and illegal id spellings are
|
|
621
|
+
// line errors. `els` is the quote-aware element list (`LINK-OPERATOR-IN-IDS` splitList); when
|
|
622
|
+
// it is absent the caller only wants the ids back and quoting was already
|
|
623
|
+
// checked on the same line.
|
|
624
|
+
function parseClassList(v,els){
|
|
625
|
+
if(v===undefined) return {ok:true, ids:undefined};
|
|
626
|
+
if(v==='') return {ok:false, err:'class= must not be empty'};
|
|
627
|
+
const parts=els?els.map(e=>e.v):String(v).split(',');
|
|
628
|
+
if(parts.some(p=>p==='')) return {ok:false, err:'class= must not contain empty members'};
|
|
629
|
+
for(let i=0;i<parts.length;i++){
|
|
630
|
+
const e=idErr(parts[i], els?els[i].h:false, 'bad class id "'+parts[i]+'"');
|
|
631
|
+
if(e) return {ok:false, err:e};
|
|
632
|
+
}
|
|
633
|
+
return {ok:true, ids:parts};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// `BITFIELD-REPETITION-CONSTRUCT`: `index=` on a `field` — the repetition construct.
|
|
637
|
+
//
|
|
638
|
+
// The value is a RANGE, and `..` is the language's ONE range separator
|
|
639
|
+
// (SYNTAX-STYLE §4.8; Ada ISO/IEC 8652 `1 .. 10`, Pascal ISO 7185
|
|
640
|
+
// `array [1..10]`, X.680 `SIZE(1..4)` — all inclusive). `band` moved off the
|
|
641
|
+
// hyphen onto it in the same release (`RANGE-SPELLING`), so the language has one spelling
|
|
642
|
+
// and needs no declared exception for a second.
|
|
643
|
+
//
|
|
644
|
+
// Tri-state, deliberately the same SHAPE as `present=`:
|
|
645
|
+
// key absent no repetition claim — NOT an assertion of one occurrence
|
|
646
|
+
// index="" repeats; nothing about the indices is stated
|
|
647
|
+
// index="0..X" repeats; the last index is opaque prose
|
|
648
|
+
// index=0..7 repeats; the range is DETERMINATE, 8 elements
|
|
649
|
+
//
|
|
650
|
+
// DETERMINACY IS DECIDED BY PARSING BOTH ENDS AS INTEGERS, NEVER BY QUOTING.
|
|
651
|
+
// RULE 2.3 makes redundant quotes inert, so `index="0..7"` is determinate too.
|
|
652
|
+
// This is normative (core §12.7, genres/bitfield.md) rather than an accident
|
|
653
|
+
// of this function, because a future `index="0..7 step 2"` (`INDEX-RANGE-STEP`) rests on
|
|
654
|
+
// it: were quoting made semantic here, that extension would stop being free.
|
|
655
|
+
//
|
|
656
|
+
// `RULE-POSITION-ENUMERATION`: AND THE SPELLING THAT EXTENSION NEEDS IS NOW RESERVED.
|
|
657
|
+
// Inertness alone did not make `step` free. A prose `<last>` accepts ANY
|
|
658
|
+
// non-empty text, so `index="0..7 step 2"` was already a LEGAL document
|
|
659
|
+
// whose model is `{first:0, last:"7 step 2"}` and whose caption reads
|
|
660
|
+
// `[0] … [7 step 2]`. Shipping the step meaning later would therefore change
|
|
661
|
+
// what an existing legal document MEANS, and no engine could separate "prose
|
|
662
|
+
// that happens to read like a step clause" from "the author meant the
|
|
663
|
+
// extension" — the `color=` failure shape, which this project treats as its
|
|
664
|
+
// worst (`COLOUR-KEY-STATUS`). An extension is free only when the syntax carrying it is
|
|
665
|
+
// RESERVED, not merely unused. So `step` is reserved here exactly as `;` was
|
|
666
|
+
// reserved (`SEMICOLON-STATUS`) and for the same reason: a mark or a word that
|
|
667
|
+
// a future release will claim must be an error TODAY, while the claim costs
|
|
668
|
+
// nothing. `INDEX-RANGE-STEP`'s own measurement — 0 of 9 corpus figures with a stepped
|
|
669
|
+
// index — is what makes the price zero.
|
|
670
|
+
//
|
|
671
|
+
// `<first>` is ALWAYS a literal integer. That is what makes the base
|
|
672
|
+
// machine-readable without resolving any name — and 0-based is the canonical
|
|
673
|
+
// form, from IP-XACT's `indices`, which "follows C-semantics for indexing".
|
|
674
|
+
//
|
|
675
|
+
// WHITESPACE AROUND `..` IS NOT SIGNIFICANT (`index="0 .. 7"` is `index=0..7`;
|
|
676
|
+
// Ada writes `1 .. 10`). It is reachable only in the QUOTED form, because an
|
|
677
|
+
// unquoted option value is one whitespace-free token (§1) — normative in
|
|
678
|
+
// core §12.2 and genres/bitfield.md, pinned by golden 420.
|
|
679
|
+
//
|
|
680
|
+
// Returns {ok, val, err}. `val` is the model shape: an object with `first`
|
|
681
|
+
// and `last` being a NUMBER when the run is determinate and a STRING
|
|
682
|
+
// when it is prose. `index=""` yields `{}` — a written value that claims
|
|
683
|
+
// repetition and states no index at all.
|
|
684
|
+
const IDX_INT=/^\d+$/;
|
|
685
|
+
const IDX_SHAPE='index= takes a range written <first>..<last> — one ".." separator, and both ends present: index=0..7, index=53..0 (descending), index="0..Last Entry" when the last index is only named in prose, or index="" to say it repeats without stating any index';
|
|
686
|
+
// 0.1: the LARGEST index either end may state. `<first>` and a literal
|
|
687
|
+
// `<last>` are read as FigDown numbers, and core §12.5 binds a number to an
|
|
688
|
+
// ECMAScript double — so above 2^53-1 the value a reader gets back is NOT the
|
|
689
|
+
// value the author wrote (`9007199254740993` reads back as `…992`, and the
|
|
690
|
+
// derived count |last-first|+1 is then wrong by the rounding). §12.7 requires
|
|
691
|
+
// the model to be recoverable FROM THE SOURCE; a silently rounded index is the
|
|
692
|
+
// one thing it must not do, so the bound is a line error rather than a value.
|
|
693
|
+
const IDX_MAX=Number.MAX_SAFE_INTEGER; // 9007199254740991
|
|
694
|
+
// `RULE-POSITION-ENUMERATION`: the reserved `step` clause. The TRIGGER is a bare
|
|
695
|
+
// lowercase `step` token in the prose `<last>` — whitespace-delimited on both
|
|
696
|
+
// sides (or at either end of the prose), matched case-sensitively.
|
|
697
|
+
// CAUGHT: index="0..7 step 2" index="0..N step 2" index="0..end step"
|
|
698
|
+
// NOT CAUGHT: index="0..7 Step 2" index="0..stepping" index="0..8 steps"
|
|
699
|
+
// index="0..7 in steps of 2"
|
|
700
|
+
// The trigger is deliberately narrow: only the EXACT spelling the extension
|
|
701
|
+
// would use can ever be ambiguous with it, and FigDown's vocabulary is
|
|
702
|
+
// lowercase everywhere (core §1), so `Step` and `steps` can never become the
|
|
703
|
+
// extension's spelling. A wider trigger would reject prose that could never
|
|
704
|
+
// collide, which is a cost with no reservation behind it.
|
|
705
|
+
const IDX_STEP=/(^|\s)step(\s|$)/;
|
|
706
|
+
const IDX_STEP_ERR='"step" is RESERVED inside an index= range and has no meaning in v0.1 — a stepped range (index="0..7 step 2", core §9 `INDEX-RANGE-STEP`) is a v0.2 extension, and the spelling is reserved now so that shipping it cannot change what a document already written means. If the last index really is prose containing this word, spell it differently ("0..7 stepping by 2", "0..7 in steps of 2"); if you meant every other element, v0.1 has no way to say it — write the run you can state and put the rest in description=';
|
|
707
|
+
function parseIndexRange(v){
|
|
708
|
+
if(v===undefined) return {ok:true, val:undefined};
|
|
709
|
+
if(v==='') return {ok:true, val:{}}; // repeats, indices unstated
|
|
710
|
+
const s=String(v);
|
|
711
|
+
// 0.1: a dot RUN longer than two is a mistyped separator, not a
|
|
712
|
+
// separator followed by prose. `'0...7'.split('..')` yields ['0','.7'], two
|
|
713
|
+
// parts, so the letter of "one '..' separator" passed it and the third dot
|
|
714
|
+
// was silently absorbed into an opaque last index ".7" — an accepted figure
|
|
715
|
+
// whose model contradicted the source. Checked on the RAW value, before the
|
|
716
|
+
// split, because the split is exactly what loses the evidence.
|
|
717
|
+
if(/\.{3}/.test(s)) return {ok:false, err:IDX_SHAPE};
|
|
718
|
+
const parts=s.split('..');
|
|
719
|
+
if(parts.length!==2) return {ok:false, err:IDX_SHAPE};
|
|
720
|
+
const a=parts[0].trim(), b=parts[1].trim();
|
|
721
|
+
if(a===''||b==='') return {ok:false, err:IDX_SHAPE};
|
|
722
|
+
if(!IDX_INT.test(a))
|
|
723
|
+
return {ok:false, err:'index= needs a LITERAL first index, and "'+a+'" is not one: a FigDown number is a run of DIGITS — no sign, no decimal point, no separators. Only the LAST index may be prose — the first is machine-readable by construction, which is what lets a reader place the run without resolving any name. Write index=<digits>..<last>: index=0..7, or index="0..'+a+'" if the run really starts at 0 and ends where "'+a+'" says'};
|
|
724
|
+
const first=+a;
|
|
725
|
+
if(first>IDX_MAX)
|
|
726
|
+
return {ok:false, err:'index= first index "'+a+'" is above '+IDX_MAX+', the largest integer a FigDown number carries exactly (core §12.5 binds a number to an ECMAScript double) — a reader would get '+first+' back, not what you wrote'};
|
|
727
|
+
if(IDX_INT.test(b)){
|
|
728
|
+
const last=+b;
|
|
729
|
+
if(last>IDX_MAX)
|
|
730
|
+
return {ok:false, err:'index= last index "'+b+'" is above '+IDX_MAX+', the largest integer a FigDown number carries exactly (core §12.5 binds a number to an ECMAScript double) — a reader would get '+last+' back, not what you wrote, and the element count with it'};
|
|
731
|
+
if(first===last)
|
|
732
|
+
return {ok:false, err:'index='+a+'..'+b+' is a range of ONE element, which is not repetition — drop index= (an absent key claims nothing), or write the range the run really covers'};
|
|
733
|
+
return {ok:true, val:{first,last}};
|
|
734
|
+
}
|
|
735
|
+
// `RULE-POSITION-ENUMERATION`: the prose `<last>` is where the reserved `step` clause would land,
|
|
736
|
+
// and where it is therefore an error. Checked AFTER the integer branch, so
|
|
737
|
+
// a determinate range never pays for it.
|
|
738
|
+
if(IDX_STEP.test(b)) return {ok:false, err:IDX_STEP_ERR};
|
|
739
|
+
return {ok:true, val:{first,last:b}};
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// `GENRE-KEYWORD-ALLOWLIST`: per-header-genre top-level keyword allowlist.
|
|
743
|
+
// `UNIVERSAL-CORE-KEYWORDS` core is in every set (fixity, not ubiquity). Scene genres keep
|
|
744
|
+
// experimental scene/layout keywords (B2) but they are not the "main
|
|
745
|
+
// standard" taught to agents. Pure bitfield/table/timing reject scene.
|
|
746
|
+
//
|
|
747
|
+
// `LAYOUT-ZONE-NAMESPACE`: the universal core is THREE — the document's structure,
|
|
748
|
+
// readable before the genre is known. `layout` stays here because it is the
|
|
749
|
+
// zone OPENER, a structural marker, not a directive inside the zone. The
|
|
750
|
+
// zone's own members are a SEPARATE namespace (`LAYOUT-ZONE-NAMESPACE`).
|
|
751
|
+
// `EDGE-GEOMETRY-CONSTRUCTS`: that namespace now holds `pin` and NOTHING ELSE.
|
|
752
|
+
// `path` and `routing` were its two experimental members and both were
|
|
753
|
+
// withdrawn, so `LAYOUT_EXP_KW` is gone rather than empty — an empty list
|
|
754
|
+
// would imply the slot is waiting to be refilled, and the two SPELLINGS are
|
|
755
|
+
// released back to the language (core §9 `EDGE-GEOMETRY-CONSTRUCTS` closes on that fact).
|
|
756
|
+
const CORE_KW=['figdown','title','layout'];
|
|
757
|
+
const LAYOUT_KW=['pin']; // `LAYOUT-ZONE-NAMESPACE`, NORMATIVE — the whole namespace
|
|
758
|
+
const GENRE_FREE_KW=CORE_KW.concat(LAYOUT_KW);
|
|
759
|
+
const SCENE_KW_TOP=['node','group','external','edge','class','flow','rank'];
|
|
760
|
+
const SCENE_EXP_KW=['threshold','band','bundle','plane'];
|
|
761
|
+
const SCENE_HOST_KW=GENRE_FREE_KW.concat(SCENE_KW_TOP, SCENE_EXP_KW, ['bitfield','table','timing','chart']);
|
|
762
|
+
// `FLOWCHART-ROLE-KEYWORDS`: the flowchart ROLE vocabulary — the FIRST exercise of
|
|
763
|
+
// `GENRE-NAMESPACE` `GENRE-VOCABULARY-OBLIGATION` ("a genre owns its words"). These three are legal ONLY under
|
|
764
|
+
// `figdown 0.1 flowchart`; `GENRE-NAMESPACE`'s allowlist is what makes `decision x` a line
|
|
765
|
+
// error under `block` with no extra code. Names taken whole from ISO 5807
|
|
766
|
+
// §9.2.1/§9.2.2.4/§9.4.2, whose own definitions are of the REFERENT ("this
|
|
767
|
+
// symbol represents any kind of processing function"), never of the drawn
|
|
768
|
+
// form — which is why `SHAPE-ENUM-VOCABULARY` is satisfied by borrowing them (`FLOWCHART-ROLE-SOURCE`).
|
|
769
|
+
const FLOWCHART_ROLE_KW=['process','decision','terminator'];
|
|
770
|
+
// Geometry DERIVED from role (`SHAPE-ENUM-VOCABULARY`'s own mapping, requirements-notes `SHAPE-ENUM-VOCABULARY`:
|
|
771
|
+
// "decision→diamond, terminator→rounded"). The role is the model; this is
|
|
772
|
+
// only its default drawing, and `shape=` on the same line overrides the
|
|
773
|
+
// drawing without touching the role (§12.7).
|
|
774
|
+
const ROLE_SHAPE={process:'box',decision:'diamond',terminator:'rounded'};
|
|
775
|
+
const GENRE_KW={
|
|
776
|
+
block:new Set(SCENE_HOST_KW),
|
|
777
|
+
topology:new Set(SCENE_HOST_KW),
|
|
778
|
+
flowchart:new Set(SCENE_HOST_KW.concat(FLOWCHART_ROLE_KW)),
|
|
779
|
+
bitfield:new Set(GENRE_FREE_KW.concat(['class','bitfield'])),
|
|
780
|
+
// chart is experimental and attaches to a table id in the same document
|
|
781
|
+
table:new Set(GENRE_FREE_KW.concat(['class','table','chart'])),
|
|
782
|
+
timing:new Set(GENRE_FREE_KW.concat(['class','timing']))
|
|
783
|
+
};
|
|
784
|
+
const CHILD_KW=new Set(['field','break','cell','width','signal','gap']);
|
|
120
785
|
|
|
786
|
+
// Multi-section files (`MULTI-FIGURE-DOCUMENTS`): each `figdown 0.1 <genre>` starts a section.
|
|
787
|
+
// Comments/blanks before the first header belong to section 0.
|
|
788
|
+
function splitFigdownSections(text){
|
|
789
|
+
const lines=String(text).split('\n');
|
|
790
|
+
const at=[];
|
|
791
|
+
for(let i=0;i<lines.length;i++){
|
|
792
|
+
let t=lines[i];
|
|
793
|
+
const hi=findComment(t); if(hi>=0) t=t.slice(0,hi);
|
|
794
|
+
if(/^figdown(\s|$)/.test(t.trim())) at.push(i);
|
|
795
|
+
}
|
|
796
|
+
if(at.length<=1) return null;
|
|
797
|
+
const secs=[];
|
|
798
|
+
for(let s=0;s<at.length;s++){
|
|
799
|
+
const from=s===0?0:at[s];
|
|
800
|
+
const to=s+1<at.length?at[s+1]:lines.length;
|
|
801
|
+
secs.push({startLine:from+1, text:lines.slice(from,to).join('\n')});
|
|
802
|
+
}
|
|
803
|
+
return secs;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// parse(text) -> {doc, errs, docs}
|
|
807
|
+
// Single-section: docs=[doc] (backward-compatible doc/errs).
|
|
808
|
+
// Multi-section: one doc per figdown header; errs use full-file line numbers;
|
|
809
|
+
// doc is docs[0] for naive callers. Render stacks sections into one SVG (`MULTI-FIGURE-DOCUMENTS`).
|
|
121
810
|
function parse(text){
|
|
811
|
+
// 0.1: strip a leading U+FEFF BOM EXPLICITLY (core §1). It was
|
|
812
|
+
// already tolerated, but only because JS `\s` happens to include U+FEFF, so
|
|
813
|
+
// `.trim()` ate it — an accident of the host language that a second
|
|
814
|
+
// implementation had no way to know it had to reproduce. Stripping it here
|
|
815
|
+
// makes the normative rule ("a BOM at the very start of the document is
|
|
816
|
+
// ignored") implementable from the spec alone. Behaviour is unchanged.
|
|
817
|
+
text=String(text).replace(/^\uFEFF/,'');
|
|
818
|
+
const secs=splitFigdownSections(text);
|
|
819
|
+
if(!secs){
|
|
820
|
+
const r=parseOne(String(text));
|
|
821
|
+
return {doc:r.doc, errs:r.errs, docs:[r.doc]};
|
|
822
|
+
}
|
|
823
|
+
const docs=[]; const errs=[];
|
|
824
|
+
for(const sec of secs){
|
|
825
|
+
const r=parseOne(sec.text);
|
|
826
|
+
for(const e of r.errs){
|
|
827
|
+
const m=/^Line (\d+): (.*)$/.exec(e);
|
|
828
|
+
if(m) errs.push('Line '+(+m[1]+sec.startLine-1)+': '+m[2]);
|
|
829
|
+
else errs.push(e);
|
|
830
|
+
}
|
|
831
|
+
docs.push(r.doc);
|
|
832
|
+
}
|
|
833
|
+
return {doc:docs[0], errs, docs};
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function parseOne(text){
|
|
122
837
|
const errs=[];
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
838
|
+
// `EMPTY-LABEL-STATE`: `title` and a plane label start ABSENT (null), never as an empty
|
|
839
|
+
// string — an author who writes `title ""` has made a distinction the model
|
|
840
|
+
// must keep, and the implicit `base` plane wrote no label at all.
|
|
841
|
+
const doc={title:null,nodes:[],groups:[],edges:[],planes:[{id:'base',label:null,z:0}],
|
|
842
|
+
flow:'right',ranks:[],pins:{},blocks:[],trunks:[],thresholds:[],bands:[],
|
|
843
|
+
classes:[],boundaries:[]};
|
|
844
|
+
const nodeIds=new Set(), groupIds=new Set(), planeIds=new Set(['base']), classIds=new Set(),
|
|
845
|
+
bundleIds=new Set(), boundaryIds=new Set(), blockIds=new Set();
|
|
846
|
+
// §1: "IDs are ... unique per document" — nodes, groups, boundaries AND the
|
|
847
|
+
// typed blocks (bitfield/table/timing) share ONE namespace, so a bare id in
|
|
848
|
+
// `edge`/`pin`/`chart` can never be ambiguous. `plane`, `class` and
|
|
849
|
+
// `bundle` keep their own namespaces: each is referenced through a dedicated
|
|
850
|
+
// option or keyword, never as a bare id.
|
|
851
|
+
const dupId=id=>nodeIds.has(id)||groupIds.has(id)||boundaryIds.has(id)||blockIds.has(id);
|
|
852
|
+
let cur=null; // current typed block (bitfield/table/timing)
|
|
853
|
+
// `REPEATED-DIRECTIVE-HANDLING`: `title`, `flow`, `layout` and a per-id `pin` are SINGLE-VALUED — a
|
|
854
|
+
// repetition is a line error on the second occurrence, never a silent
|
|
855
|
+
// last-one-wins (§8: malformed input is an error, never ignored).
|
|
856
|
+
let sawHeader=false, firstContent=true, sawLayout=false, sawTitle=false, sawFlow=false;
|
|
857
|
+
const LAYOUT_DIRECTIVES=new Set(['pin']); // `EDGE-GEOMETRY-CONSTRUCTS`: `pin` is the whole layout namespace
|
|
131
858
|
const lines=text.split('\n');
|
|
132
859
|
const err=(n,m)=>errs.push('Line '+n+': '+m);
|
|
860
|
+
// `RULE-POSITION-ENUMERATION`: the typed-block openers' LABEL is a positional string
|
|
861
|
+
// and takes RULE 2.1's mandatory quotes, exactly as `node`, `group`,
|
|
862
|
+
// `external`, `plane`, `class` and `bundle` already do. Until now
|
|
863
|
+
// `bitfield b Hdr` and `table t Caption` were accepted with the label
|
|
864
|
+
// `Hdr`/`Caption` while `node a Hdr` was a line error, and BOTH genre
|
|
865
|
+
// documents write the form as `<keyword> <id> ["label"]` — so the docs
|
|
866
|
+
// taught a rule the engine did not enforce, at 3 of 15 labelled positions.
|
|
867
|
+
// It is the failure §2.1 says it retired: an unquoted multi-word
|
|
868
|
+
// positional is not a spelling, it is a hole — `table t My Caption`
|
|
869
|
+
// reported `unexpected argument "Caption"`, naming a surplus argument when
|
|
870
|
+
// the defect was a missing quote. Returns true when it reported.
|
|
871
|
+
const BLK_LBL=(n,kw,id,pos,posq)=>{
|
|
872
|
+
if(pos[2]===undefined||posq[2]) return false;
|
|
873
|
+
err(n,kw+' label must be quoted: '+kw+' '+id+' "'+pos[2]+'" — '+Q_WHY);
|
|
874
|
+
return true;
|
|
875
|
+
};
|
|
133
876
|
|
|
134
|
-
//
|
|
877
|
+
// `EDGE-LABEL-PLACEMENT`/`REVERSE-ARROW-OPERATOR`: edge <id> [tail] <op> [head] <id> — a [mid] label splits the
|
|
135
878
|
// operator into halves: -[x]- -[x]-> <-[x]- <-[x]->. Bracket content:
|
|
136
879
|
// balanced brackets nest verbatim ([flags[3:0]] just works); ["..."] takes
|
|
137
880
|
// the standard quoted-string escapes for unbalanced brackets / \n.
|
|
138
881
|
function parseEdgeLine(s,n){
|
|
139
882
|
let i=4; // past 'edge'
|
|
140
883
|
const ws=()=>{ while(i<s.length&&/\s/.test(s[i])) i++; };
|
|
141
|
-
|
|
884
|
+
// `LINK-OPERATOR-IN-IDS`: a hyphen is an id character only when it is NOT
|
|
885
|
+
// followed by a second one, because `--` is the link operator. This is
|
|
886
|
+
// what lets `edge a--b` mean the same thing as `bundle t1 a--b`; before
|
|
887
|
+
// the ban the greedy scanner ate `a--b` as one id and then reported
|
|
888
|
+
// "edge needs an operator", so the same token read oppositely two lines
|
|
889
|
+
// apart (SYNTAX-STYLE §6.3).
|
|
890
|
+
const readId=()=>{ const m=/^[A-Za-z_](?:[A-Za-z0-9_]|-(?!-))*/.exec(s.slice(i));
|
|
142
891
|
if(!m) return null; i+=m[0].length; return m[0]; };
|
|
892
|
+
// `QUOTED-IDS`: an endpoint is an id position. A quoted token or a spelling that
|
|
893
|
+
// is not an id gets the ID RULE — not "edge needs <id> …", which named
|
|
894
|
+
// the wrong thing when the operator was plainly there.
|
|
895
|
+
const idHere=()=>i<s.length&&!/[\s[\-<>]/.test(s[i]);
|
|
143
896
|
const readLbl=()=>{ // called at '['
|
|
144
897
|
i++;
|
|
145
898
|
if(s[i]==='"'){ // ["..."] — quoted content
|
|
@@ -171,8 +924,11 @@ function parse(text){
|
|
|
171
924
|
}
|
|
172
925
|
return {error:'unterminated [label] — for unbalanced brackets use ["..."]'};
|
|
173
926
|
};
|
|
174
|
-
ws();
|
|
175
|
-
if(
|
|
927
|
+
ws();
|
|
928
|
+
if(s[i]==='"'){ err(n,ID_RULE); return; }
|
|
929
|
+
const a=readId();
|
|
930
|
+
if(!a){ err(n, idHere()?ID_RULE:'edge needs <id> ->|<-|--|<-> <id>'); return; }
|
|
931
|
+
if(idHere()){ err(n,ID_RULE); return; }
|
|
176
932
|
ws(); let tail=null;
|
|
177
933
|
if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } tail=r.v; }
|
|
178
934
|
ws();
|
|
@@ -195,21 +951,57 @@ function parse(text){
|
|
|
195
951
|
}
|
|
196
952
|
ws(); let head=null;
|
|
197
953
|
if(s[i]==='['){ const r=readLbl(); if(r.error){ err(n,r.error); return; } head=r.v; }
|
|
198
|
-
ws();
|
|
199
|
-
if(
|
|
954
|
+
ws();
|
|
955
|
+
if(s[i]==='"'){ err(n,ID_RULE); return; }
|
|
956
|
+
const b=readId();
|
|
957
|
+
if(!b){ err(n, idHere()?ID_RULE:'edge needs a target id after the operator'); return; }
|
|
958
|
+
if(idHere()){ err(n,ID_RULE); return; }
|
|
200
959
|
const tk2=tokenize(s.slice(i).trim());
|
|
201
960
|
if(tk2.error){ err(n,tk2.error); return; }
|
|
202
|
-
const {pos:p2,opts:o2,unk:u2}=splitOpts(tk2.toks);
|
|
961
|
+
const {pos:p2,opts:o2,optT:oT2,unk:u2,dup:d2}=splitOpts(tk2.toks);
|
|
962
|
+
if(d2){ err(n,'duplicate option "'+d2+'=" on one line'); return; }
|
|
203
963
|
if(u2.length){ err(n,'unknown option "'+u2[0]+'="'); return; }
|
|
204
964
|
if(p2.length){ err(n,'unexpected argument "'+p2[0]+'"'); return; }
|
|
965
|
+
// 0.1: `edge` has its own scanner, so the language-wide retired
|
|
966
|
+
// keys need their own check here or `edge` would be the one directive
|
|
967
|
+
// that reports the generic message for a retired spelling.
|
|
968
|
+
for(const rk in RETIRED_OPT_KEYS)
|
|
969
|
+
if(o2[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); return; }
|
|
205
970
|
for(const k in o2)
|
|
206
971
|
if(!DIRECTIVE_OPTS.edge.includes(k)){ err(n,'edge does not take '+k+'='); return; }
|
|
207
972
|
for(const k of ['label','taillabel','headlabel'])
|
|
208
|
-
if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: edge A [tail] -[mid]-> [head] B (MIGRATIONS
|
|
209
|
-
if(o2.
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
973
|
+
if(o2[k]!==undefined){ err(n,k+'= is retired — write the label inline: edge A [tail] -[mid]-> [head] B (MIGRATIONS)'); return; }
|
|
974
|
+
if(o2.fill!==undefined){ err(n,FILL_NO_INTERIOR('edge')); return; }
|
|
975
|
+
for(const k of ['fill','stroke'])
|
|
976
|
+
if(o2[k]!==undefined && !isColor(o2[k])){ err(n,'unknown color "'+o2[k]+'" (#hex or CSS color name)'); return; }
|
|
977
|
+
// `RULE-POSITION-ENUMERATION`: `edge` was the ONE id position in the language that
|
|
978
|
+
// tolerated a quoted id. `parseEdgeLine` has its own scanner and
|
|
979
|
+
// therefore its own copies of the genre gate, the layout-zone gate and
|
|
980
|
+
// the retired-key sweep — but it never got a copy of the id-quoting
|
|
981
|
+
// check that `badOpts` applies to `in=`/`plane=` everywhere else, so
|
|
982
|
+
// `node c "C" plane="over"` was a line error and
|
|
983
|
+
// `edge a -> b plane="over"` was accepted. The endpoints were already
|
|
984
|
+
// covered (the scanner rejects a leading `"`); this is the option value.
|
|
985
|
+
if(o2.plane!==undefined){
|
|
986
|
+
const e=idErr(o2.plane, optHasQ(oT2,'plane'), null);
|
|
987
|
+
if(e){ err(n,e); return; }
|
|
988
|
+
}
|
|
989
|
+
// `RULE-POSITION-ENUMERATION`: and the enum half of RULE 2.4, for the one enum key `edge` takes.
|
|
990
|
+
// Checked before the value, exactly as `badOpts` does it.
|
|
991
|
+
if(o2.style!==undefined && optHasQ(oT2,'style')){ err(n,ENUM_BARE('style='+o2.style)); return; }
|
|
992
|
+
if(o2.style!==undefined && !STYLES.includes(o2.style)){ err(n,'style must be solid|dashed|dotted'); return; }
|
|
993
|
+
let ecls;
|
|
994
|
+
if(o2['class']!==undefined){
|
|
995
|
+
const pc=parseClassList(o2['class'],optList(oT2,'class'));
|
|
996
|
+
if(!pc.ok){ err(n,pc.err); return; }
|
|
997
|
+
ecls=pc.ids;
|
|
998
|
+
}
|
|
999
|
+
// §5 on an edge: the line IS a stroke and has no interior, so `stroke=`
|
|
1000
|
+
// and `fill=` name the same channel (`stroke=` wins when both are
|
|
1001
|
+
// written); `text=` colours the [tail]/[mid]/[head] labels.
|
|
1002
|
+
doc.edges.push({a,b,op,tail,mid,head,style:o2.style,cls:ecls,
|
|
1003
|
+
stroke:o2.stroke,
|
|
1004
|
+
plane:o2.plane||'base',line:n});
|
|
213
1005
|
}
|
|
214
1006
|
|
|
215
1007
|
for(let li=0; li<lines.length; li++){
|
|
@@ -236,9 +1028,9 @@ function parse(text){
|
|
|
236
1028
|
segs.push(acc);
|
|
237
1029
|
const isSep=segs.every(x=>/^\s*:?-+:?\s*$/.test(x)) && segs.length>0; // GFM: 1+ hyphens
|
|
238
1030
|
if(isSep){
|
|
239
|
-
if(cur.sep){ err(n,'duplicate
|
|
240
|
-
if(!cur.heads.length){ err(n,'
|
|
241
|
-
if(segs.length!==cur.cols.length){ err(n,'
|
|
1031
|
+
if(cur.sep){ err(n,'duplicate delimiter row'); continue; }
|
|
1032
|
+
if(!cur.heads.length){ err(n,'delimiter row before any header row'); continue; }
|
|
1033
|
+
if(segs.length!==cur.cols.length){ err(n,'delimiter row has '+segs.length+' columns, expected '+cur.cols.length); continue; }
|
|
242
1034
|
cur.sep=true;
|
|
243
1035
|
cur.aligns=segs.map(x=>{ x=x.trim();
|
|
244
1036
|
const l=x.startsWith(':'), r=x.endsWith(':');
|
|
@@ -248,12 +1040,17 @@ function parse(text){
|
|
|
248
1040
|
// content row: raw empty segment = colspan-left (multimd "||");
|
|
249
1041
|
// cell exactly ^^ = rowspan-up (multimd). The only cell escapes are
|
|
250
1042
|
// \| (handled during segmentation) and \^^ (literal caret pair) —
|
|
251
|
-
// any other
|
|
1043
|
+
// any other backslash (including \n) is literal cell text (`TABLE-CELL-BACKSLASH`).
|
|
1044
|
+
// Multi-line cells (Br2): GFM-style HTML line breaks
|
|
1045
|
+
// <br> / <br/> / <br /> only — normalized to U+000A in the model;
|
|
1046
|
+
// other HTML is literal text, never markup.
|
|
252
1047
|
const cells=segs.map(x=>{
|
|
253
1048
|
if(x.length===0) return {v:'', m:'left'};
|
|
254
1049
|
const t=x.trim();
|
|
255
1050
|
if(t==='^^') return {v:'', m:'up'};
|
|
256
|
-
|
|
1051
|
+
let v=t.startsWith('\\^^')?t.slice(1):t;
|
|
1052
|
+
v=v.replace(/<br\s*\/?>/gi,'\n');
|
|
1053
|
+
return {v, m:null};
|
|
257
1054
|
});
|
|
258
1055
|
if(cells[0].m==='left'){ err(n,'colspan cannot start in the first column'); continue; }
|
|
259
1056
|
if(cur.heads.length===0 && cells.some(c=>c.m==='up')){ err(n,'"^^" cannot appear in the first row'); continue; }
|
|
@@ -264,43 +1061,120 @@ function parse(text){
|
|
|
264
1061
|
} else {
|
|
265
1062
|
if(cells.length!==cur.cols.length){ err(n,'row has '+cells.length+' cells, expected '+cur.cols.length); continue; }
|
|
266
1063
|
// rowspan cannot cross the thead/tbody boundary (multimd prior
|
|
267
|
-
// art): "^^" in the first data row is a line error (
|
|
268
|
-
if(!cur.rows.length && cells.some(c=>c.m==='up')){ err(n,'"^^" cannot appear in the first data row (rowspan does not cross the header
|
|
1064
|
+
// art): "^^" in the first data row is a line error (`MERGE-ACROSS-DELIMITER-ROW`).
|
|
1065
|
+
if(!cur.rows.length && cells.some(c=>c.m==='up')){ err(n,'"^^" cannot appear in the first data row (rowspan does not cross the header delimiter row)'); continue; }
|
|
269
1066
|
cur.rows.push({cells,hl:false,line:n});
|
|
270
1067
|
}
|
|
271
1068
|
continue;
|
|
272
1069
|
}
|
|
1070
|
+
// `SEMICOLON-STATUS`: the `;` reservation, enforced. Runs after the pipe-row
|
|
1071
|
+
// block (a table cell is raw GFM content) and before every other line
|
|
1072
|
+
// form, so no directive can quietly give `;` a meaning.
|
|
1073
|
+
if(findReservedSemi(raw)>=0){ err(n,RESERVED_SEMI); continue; }
|
|
273
1074
|
// edge lines carry inline [labels] with free text — dedicated scanner,
|
|
274
1075
|
// not the generic tokenizer
|
|
275
1076
|
if(/^edge(\s|$)/.test(raw.trim())){
|
|
276
|
-
if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1
|
|
1077
|
+
if(firstContent){ firstContent=false; err(n,'first line must be "figdown 0.1 <genre>"'); }
|
|
277
1078
|
cur=null;
|
|
1079
|
+
// `CONTENT-LAYOUT-ZONE-SPLIT`: `edge` is a semantic directive, and it is dispatched here —
|
|
1080
|
+
// before the generic layout-zone gate below — so it needs its own copy
|
|
1081
|
+
// of that gate, or it is the one semantic line that escapes the zone.
|
|
1082
|
+
if(sawLayout){ err(n,'"edge" is a semantic directive — it must appear before the layout zone (`CONTENT-LAYOUT-ZONE-SPLIT`)'); continue; }
|
|
1083
|
+
// `GENRE-KEYWORD-ALLOWLIST`: edge is scene vocabulary only
|
|
1084
|
+
if(sawHeader && doc.genre && GENRE_KW[doc.genre] && !GENRE_KW[doc.genre].has('edge')){
|
|
1085
|
+
err(n,'"edge" is not allowed in genre '+doc.genre); continue; }
|
|
278
1086
|
parseEdgeLine(raw.trim(),n);
|
|
279
1087
|
continue;
|
|
280
1088
|
}
|
|
281
1089
|
const tk=tokenize(raw.trim());
|
|
282
1090
|
if(tk.error){ err(n,tk.error); continue; }
|
|
283
1091
|
const lead=(tk.toks.length&&!tk.toks[0].q)?tk.toks[0].v:'';
|
|
284
|
-
const {pos,opts,unk}=splitOpts(tk.toks, lead==='signal');
|
|
1092
|
+
const {pos,posq,posT,opts,optT,unk,dup}=splitOpts(tk.toks, lead==='signal');
|
|
285
1093
|
const kw=pos[0];
|
|
286
|
-
//
|
|
1094
|
+
// `INAPPLICABLE-OPTION-KEYS`/`UNKNOWN-OPTION-DEGRADATION`/`COLOUR-VALUE-VALIDATION`: uniform per-directive option checks —
|
|
287
1095
|
// unknown keys, registered-but-inapplicable keys, invalid colors.
|
|
288
|
-
// Directives not in DIRECTIVE_OPTS (title's
|
|
1096
|
+
// Directives not in DIRECTIVE_OPTS (title's single quoted string, unknown
|
|
289
1097
|
// keywords) are handled by their own paths.
|
|
290
1098
|
const badOpts=(k)=>{
|
|
291
1099
|
const allowed=DIRECTIVE_OPTS[k];
|
|
292
1100
|
if(!allowed) return false;
|
|
293
1101
|
let bad=false;
|
|
1102
|
+
// same-line repeated option key (last-wins was silent data loss)
|
|
1103
|
+
if(dup){ err(n,'duplicate option "'+dup+'=" on one line'); bad=true; }
|
|
294
1104
|
for(const u of unk){ err(n,'unknown option "'+u+'="'); bad=true; }
|
|
1105
|
+
// Retired spelling: `color=` → `fill=`. Fires only where
|
|
1106
|
+
// the key was accepted; on a directive that never took it the existing
|
|
1107
|
+
// `<directive> does not take color=` is still the right answer.
|
|
1108
|
+
if(opts.fill!==undefined && NO_INTERIOR.has(k)){ err(n,FILL_NO_INTERIOR(k)); bad=true; }
|
|
1109
|
+
if(opts.style!==undefined && NO_ITEM_STYLE.has(k)){ err(n,STYLE_NO_ITEM(k)); bad=true; }
|
|
1110
|
+
// 0.1 retirements: reported on every directive, because the
|
|
1111
|
+
// spelling left the language rather than moving between directives.
|
|
1112
|
+
for(const rk in RETIRED_OPT_KEYS)
|
|
1113
|
+
if(opts[rk]!==undefined){ err(n,RETIRED_OPT_KEYS[rk]); bad=true; }
|
|
1114
|
+
// `RULE-POSITION-ENUMERATION`: RULE 2.4's enum half on the OPTION keys. One loop
|
|
1115
|
+
// for every enum-valued key, the same device the id-valued keys below
|
|
1116
|
+
// use — so a key that gains an enum grammar later is covered by
|
|
1117
|
+
// listing it in ENUM_OPT_KEYS, not by remembering to write a check.
|
|
1118
|
+
// It runs BEFORE the value checks and suppresses them for that key
|
|
1119
|
+
// (`enumQ`): ONE TOKEN, ONE ERROR. A quoted enum has not been
|
|
1120
|
+
// established to be IN the position at all, so reporting what its
|
|
1121
|
+
// CONTENT would have meant is premature — `shape="bkx"` says the
|
|
1122
|
+
// spelling is wrong, once, rather than the spelling and `unknown
|
|
1123
|
+
// shape` on one line. This is the convention `idErr` has followed at
|
|
1124
|
+
// every id position (a quoted id gets the ID RULE and
|
|
1125
|
+
// nothing else), `parseClassList` follows, and the `edge` scanner
|
|
1126
|
+
// follows by returning; the `figdown` header was made to follow it
|
|
1127
|
+
// here too, so all four sites agree.
|
|
1128
|
+
const enumQ=new Set();
|
|
1129
|
+
for(const k of ENUM_OPT_KEYS)
|
|
1130
|
+
if(opts[k]!==undefined && allowed.includes(k) && optHasQ(optT,k)){
|
|
1131
|
+
err(n,ENUM_BARE(k+'='+opts[k])); enumQ.add(k); bad=true; }
|
|
295
1132
|
for(const o in opts){
|
|
1133
|
+
if(RETIRED_OPT_KEYS[o]) continue; // already reported above
|
|
1134
|
+
if(o==='style'&&NO_ITEM_STYLE.has(k)) continue; // `STYLE-KEY-SCOPE`: named above
|
|
296
1135
|
if(allowed.includes(o)) continue;
|
|
297
1136
|
if(k==='group'&&o==='in') err(n,'group does not take in= — nesting is one level (node in=group) in v0.1');
|
|
298
1137
|
else err(n,k+' does not take '+o+'=');
|
|
299
1138
|
bad=true;
|
|
300
1139
|
}
|
|
301
|
-
for(const o of ['
|
|
1140
|
+
for(const o of ['fill','stroke'])
|
|
302
1141
|
if(opts[o]!==undefined && allowed.includes(o) && !isColor(opts[o])){
|
|
303
1142
|
err(n,'unknown color "'+opts[o]+'" (#hex or CSS color name)'); bad=true; }
|
|
1143
|
+
// §5 style enum — checked once, for every directive that takes style=
|
|
1144
|
+
if(opts.style!==undefined && allowed.includes('style') && !enumQ.has('style') && !STYLES.includes(opts.style)){
|
|
1145
|
+
err(n,'style must be solid|dashed|dotted'); bad=true; }
|
|
1146
|
+
// class= multi-value form: validate spelling here so every
|
|
1147
|
+
// directive that accepts class= shares one check; the parsed list is
|
|
1148
|
+
// re-derived at the push site via parseClassList.
|
|
1149
|
+
if(opts['class']!==undefined && allowed.includes('class')){
|
|
1150
|
+
const pc=parseClassList(opts['class'],optList(optT,'class'));
|
|
1151
|
+
if(!pc.ok){ err(n,pc.err); bad=true; }
|
|
1152
|
+
}
|
|
1153
|
+
// `QUOTING-RULES`: a STRING-typed option value in a whitespace-
|
|
1154
|
+
// delimited position is quoted — the same rule that governs a
|
|
1155
|
+
// positional string. Without the quotes `description=a b` silently
|
|
1156
|
+
// keeps only `a` and turns `b` into a surplus positional on a
|
|
1157
|
+
// different line of the diagnostic. There are TWO string-typed option
|
|
1158
|
+
// values: `description=` (`DESCRIPTION-KEY-SPELLING`, spelled `note=`
|
|
1159
|
+
// until then) and `present=` (`PRESENCE-CONDITION-EXPRESSION`). `present=""` is legal and is the
|
|
1160
|
+
// "conditional, condition not stated" form — an EMPTY quoted value,
|
|
1161
|
+
// not an unquoted one, so the same rule admits it.
|
|
1162
|
+
for(const sk of ['description','present'])
|
|
1163
|
+
if(opts[sk]!==undefined && allowed.includes(sk) && !optQ(optT,sk)){
|
|
1164
|
+
err(n,sk+'= must be quoted: '+sk+'="'+opts[sk]+'" — '+Q_WHY); bad=true; }
|
|
1165
|
+
// `QUOTED-IDS`: `in=` and `plane=` are ID-VALUED options, so the
|
|
1166
|
+
// same rule that governs a positional id governs them. An empty value
|
|
1167
|
+
// keeps its directive-specific message (`threshold needs in=…`); a written
|
|
1168
|
+
// one that is quoted or not a legal id gets the ID RULE.
|
|
1169
|
+
for(const k of ['in','plane'])
|
|
1170
|
+
if(opts[k]!==undefined && allowed.includes(k)){
|
|
1171
|
+
const e=idErr(opts[k], optHasQ(optT,k), null);
|
|
1172
|
+
if(e){ err(n,e); bad=true; }
|
|
1173
|
+
}
|
|
1174
|
+
// `RULE-POSITION-ENUMERATION`: RULE 2.4's enum half on the OPTION keys. One loop
|
|
1175
|
+
// for every enum-valued key, the same device the id-valued keys above
|
|
1176
|
+
// use — so a key that gains an enum grammar later is covered by
|
|
1177
|
+
// listing it once, not by remembering to write a check.
|
|
304
1178
|
return bad;
|
|
305
1179
|
};
|
|
306
1180
|
|
|
@@ -308,216 +1182,561 @@ function parse(text){
|
|
|
308
1182
|
firstContent=false;
|
|
309
1183
|
if(kw==='figdown'){
|
|
310
1184
|
sawHeader=true;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
1185
|
+
// `RULE-POSITION-ENUMERATION`: the version and the genre are both enum
|
|
1186
|
+
// positions (vocabulary-sources.tsv `figdown.version`,
|
|
1187
|
+
// `figdown.genre`), so both are BARE.
|
|
1188
|
+
//
|
|
1189
|
+
// ONE TOKEN, ONE ERROR — the convention `idErr` has followed at
|
|
1190
|
+
// every id position, and `parseClassList` and the
|
|
1191
|
+
// `edge` scanner follow too. A quoted token has not been established
|
|
1192
|
+
// to be IN the position at all, so reporting what its CONTENT would
|
|
1193
|
+
// have meant is premature: `figdown 0.1 "blok"` says the spelling is
|
|
1194
|
+
// wrong, once, rather than the spelling and `unknown genre` on one
|
|
1195
|
+
// line. So the quoting check runs first and suppresses the value
|
|
1196
|
+
// check for that token, exactly as `badOpts` does for `shape=`.
|
|
1197
|
+
const hq1=!!posq[1], hq2=!!posq[2];
|
|
1198
|
+
if(hq1) err(n,ENUM_BARE('figdown '+pos[1]+' <genre>'));
|
|
1199
|
+
else if(hq2) err(n,ENUM_BARE('figdown 0.1 '+pos[2]));
|
|
1200
|
+
if(!hq1 && pos[1]!=='0.1') err(n,'unsupported version "'+(pos[1]||'')+'" (expected 0.1)');
|
|
1201
|
+
const GENRES=['block','topology','flowchart','bitfield','table','timing'];
|
|
1202
|
+
// 0.1: the genre token is REQUIRED. `bitfield`/`table`/`timing`
|
|
1203
|
+
// documents declare their kind in their content, but `block`,
|
|
1204
|
+
// `topology` and `flowchart` share the SAME vocabulary
|
|
1205
|
+
// (node/edge/group) and differ only in default flow — so the header
|
|
1206
|
+
// is the ONLY place such a document states which kind of figure it
|
|
1207
|
+
// is, and omitting it destroys the distinction with no recoverable
|
|
1208
|
+
// fallback. The message lists the six legal values so an authoring
|
|
1209
|
+
// agent fixes it in one step.
|
|
1210
|
+
if(pos[2]===undefined) err(n,'figdown header requires a genre (block|topology|flowchart|bitfield|table|timing)');
|
|
1211
|
+
else if(hq2){ /* the quoting error above is this token's one error */ }
|
|
1212
|
+
else if(pos[2]==='wave') err(n,RETIRED_WAVE);
|
|
1213
|
+
else if(!GENRES.includes(pos[2])) err(n,'unknown genre "'+pos[2]+'" (block|topology|flowchart|bitfield|table|timing)');
|
|
1214
|
+
else{ doc.genre=pos[2];
|
|
1215
|
+
// genre defaults (`GENRE-NAMESPACE`/`DEFAULT-VALUE-SELECTION`): flowchart figures flow down —
|
|
1216
|
+
// the census-dominant direction; an explicit flow line overrides
|
|
1217
|
+
if(pos[2]==='flowchart') doc.flow='down'; }
|
|
1218
|
+
// §1: a directive line carrying positional arguments its grammar does
|
|
1219
|
+
// not accept MUST be rejected — the header is a directive like any other
|
|
1220
|
+
if(pos.length>3) err(n,'unexpected argument "'+pos[3]+'"');
|
|
320
1221
|
badOpts('figdown');
|
|
321
1222
|
continue;
|
|
322
|
-
} else { err(n,'first line must be "figdown 0.1
|
|
1223
|
+
} else { err(n,'first line must be "figdown 0.1 <genre>"'); }
|
|
323
1224
|
} else if(kw==='figdown'){ err(n,'duplicate version header'); continue; }
|
|
324
1225
|
|
|
1226
|
+
// Retired spelling: `colw` → `width`. Keyword naming
|
|
1227
|
+
// discipline — one lowercase word, borrowed standard terminology; `colw`
|
|
1228
|
+
// was the only invented abbreviation in the registry. Reported wherever
|
|
1229
|
+
// it appears (inside a block or not) so the migration is unmissable.
|
|
1230
|
+
if(kw==='colw'){ err(n,'colw has been renamed: use width (one lowercase word; the table block scopes it to columns)'); continue; }
|
|
1231
|
+
|
|
1232
|
+
// Retired spellings: four keywords whose names collided with
|
|
1233
|
+
// something else in the language.
|
|
1234
|
+
// - `line` collided with the model's own `line` field (the source line
|
|
1235
|
+
// number every element carries) and read as "draw a line", which it is
|
|
1236
|
+
// not; §2.6's prose already called it a guide. 0.1 (`THRESHOLD-KEYWORD-SPELLING`) renamed
|
|
1237
|
+
// that replacement again, to `threshold`, so this message names the
|
|
1238
|
+
// CURRENT spelling — a two-hop migration is one lookup, not two;
|
|
1239
|
+
// - `fill` read as "fill colour" (which was `color=` then) and was also an
|
|
1240
|
+
// option key, so one word lived in two namespaces; §2.6's prose already
|
|
1241
|
+
// called it a range band. 0.1 gave the word back to the OPTION-KEY
|
|
1242
|
+
// namespace only (`color=` → `fill=`); the KEYWORD stays retired, so
|
|
1243
|
+
// `fill` at line start is still this error. §10 records the declared
|
|
1244
|
+
// exception to `UNSAFE-DEFAULT-ELIMINATION` that this one-directional reuse costs;
|
|
1245
|
+
// - `route` sat three characters from the document-level `routing`, which
|
|
1246
|
+
// is ALSO an option key on the same directive, so a typo produced
|
|
1247
|
+
// another legal directive instead of an error, and `route` is a domain
|
|
1248
|
+
// noun in a topology figure (`SHAPE-ENUM-VOCABULARY`: no domain nouns in the presentation
|
|
1249
|
+
// vocabulary) while the directive means geometric waypoints;
|
|
1250
|
+
// - `render` was a verb naming a zone that admits only geometry
|
|
1251
|
+
// (`pin` — and, until this release, `path` and `routing`), and it
|
|
1252
|
+
// collided with the renderer
|
|
1253
|
+
// and the render options of §7. `layout` is the cross-tool word for
|
|
1254
|
+
// this half of a diagram language, and the zone it opens carried the
|
|
1255
|
+
// `# --- layout` comment convention before it became a keyword.
|
|
1256
|
+
// 0.1 (`EDGE-GEOMETRY-CONSTRUCTS`) added `path` and `routing` to this block, and they are
|
|
1257
|
+
// the first entries in it that name NO replacement. `route`'s own message
|
|
1258
|
+
// has to change with them: it pointed at `path`, which no longer exists,
|
|
1259
|
+
// so it now states the whole chain and ends where the others end.
|
|
1260
|
+
if(kw==='line'){ err(n,'line has been renamed: use threshold (a labelled reference value drawn across the target'+"'"+'s box; "line" now only names a source line number, and the 0.1 replacement `guide` was itself retired) (MIGRATIONS)'); continue; }
|
|
1261
|
+
if(kw==='fill'){ err(n,'fill has been renamed: use band (a range band; the KEYWORD is retired — fill= is the presentation option key)'); continue; }
|
|
1262
|
+
if(kw==='route'){ err(n,'route has been WITHDRAWN: it was renamed path, and path was withdrawn from the language (`EDGE-GEOMETRY-CONSTRUCTS`). There is no replacement spelling. Delete the line: the edge draws under auto layout.'+WITHDRAWN_WHERE); continue; }
|
|
1263
|
+
if(kw==='path'){ err(n,RETIRED_PATH); continue; }
|
|
1264
|
+
if(kw==='routing'){ err(n,RETIRED_ROUTING); continue; }
|
|
1265
|
+
if(kw==='render'){ err(n,'render has been renamed: use layout (the zone takes only pin — geometry, not presentation) (MIGRATIONS)'); continue; }
|
|
1266
|
+
if(kw==='wrap'){ err(n,RETIRED_WRAP); continue; }
|
|
1267
|
+
if(kw==='boundary'){ err(n,RETIRED_BOUNDARY); continue; }
|
|
1268
|
+
if(kw==='layer'){ err(n,RETIRED_LAYER); continue; }
|
|
1269
|
+
if(kw==='guide'){ err(n,RETIRED_GUIDE); continue; }
|
|
1270
|
+
if(kw==='wave'){ err(n,RETIRED_WAVE); continue; }
|
|
1271
|
+
if(kw==='size'){ err(n,RETIRED_SIZE); continue; }
|
|
1272
|
+
if(kw==='plot'){ err(n,'plot has been renamed: use chart (plot reads as an imperative — the reason render was retired — while every other block opener is a noun; ECharts, Chart.js and Mermaid all name the object a chart) (MIGRATIONS)'); continue; }
|
|
1273
|
+
|
|
325
1274
|
// typed-block children
|
|
326
|
-
if(cur && ['field','
|
|
1275
|
+
if(cur && ['field','break','cell','width','signal','gap'].includes(kw)){
|
|
327
1276
|
if(badOpts(kw)) continue;
|
|
328
1277
|
if(cur.type==='bitfield' && kw==='field'){
|
|
329
1278
|
// Compact form (C bit-field convention): field F1:16,F2:8 SYN:1 ...
|
|
330
1279
|
// — bare name:width items separated by commas and/or spaces, no
|
|
331
1280
|
// per-field options. Classic form: field <name> <width> [options].
|
|
332
|
-
// Classic form: field <name> <width-in-bits|*> [
|
|
333
|
-
//
|
|
1281
|
+
// Classic form: field <name> <width-in-bits|*> [fill=] [description=]
|
|
1282
|
+
// [present=]
|
|
1283
|
+
// Conditional presence was a POSITIONAL FLAG until this release:
|
|
1284
|
+
// `optional` (…0.1), `conditional` (0.1…0.1),
|
|
1285
|
+
// `optional` again (`PRESENCE-FLAG-SPELLING`). `PRESENCE-CONDITION-EXPRESSION` replaces the flag with
|
|
1286
|
+
// `present=`, an option key whose value is the presence CONDITION as
|
|
1287
|
+
// authored prose. Tri-state: key absent = no presence claim;
|
|
1288
|
+
// present="" = conditional, condition not stated; present="C = 1" =
|
|
1289
|
+
// conditional, condition stated.
|
|
1290
|
+
// '*' = variable-length field: fills the remainder of the current row.
|
|
1291
|
+
// At most one '*' per bitfield block (maintainer A):
|
|
1292
|
+
// '*' is "row remainder / trailing blob", not present-only-if-and-fixed
|
|
1293
|
+
// (use `present=`) and not multi-segment TLV (use table / description=
|
|
1294
|
+
// / v0.2).
|
|
334
1295
|
const cname=pos[1], cw=pos[2];
|
|
335
1296
|
if(cname!==undefined && (/^\d+$/.test(cw||'')&&+cw>=1 || cw==='*')){
|
|
336
|
-
|
|
1297
|
+
// `QUOTING-RULES`: the CLASSIC form's name is a whitespace-
|
|
1298
|
+
// delimited string position, so it is quoted. The compact form's
|
|
1299
|
+
// names are comma-delimited and stay bare (see below).
|
|
1300
|
+
if(!posq[1]){ err(n,'field name must be quoted in the classic form: field "'+cname+'" '+cw+' — '+Q_WHY); continue; }
|
|
1301
|
+
const fextra=pos[3];
|
|
1302
|
+
if(fextra==='optional'){ err(n,RETIRED_FIELD_OPTIONAL); continue; }
|
|
1303
|
+
if(fextra==='conditional'){ err(n,RETIRED_FIELD_CONDITIONAL); continue; }
|
|
337
1304
|
if(fextra!==undefined){ err(n,'unexpected argument "'+fextra+'"'); continue; }
|
|
338
|
-
|
|
1305
|
+
// `BITFIELD-REPETITION-CONSTRUCT`: `index=` states that this field is one element
|
|
1306
|
+
// of a repeated run. It is refused on a `*` field: `*` already means
|
|
1307
|
+
// "the length is not in the document", so "an element of unstated
|
|
1308
|
+
// width, n times" states nothing at all — two indeterminacies
|
|
1309
|
+
// multiplied, with a picture that would look like knowledge.
|
|
1310
|
+
if(cw==='*' && opts.index!==undefined){
|
|
1311
|
+
err(n,'index= does not apply to a * field: * means the length is NOT in the document, so "an element of unstated width, repeated" asserts nothing a reader could use. Give the repeated element its real width and put the range on it (field "Segment List" 128 index="0..Last Entry"), or keep * and drop index='); continue; }
|
|
1312
|
+
const irange=parseIndexRange(opts.index);
|
|
1313
|
+
if(!irange.ok){ err(n,irange.err); continue; }
|
|
1314
|
+
if(cw==='*'){
|
|
1315
|
+
if(cur.starCount){ err(n,'only one * field is allowed per bitfield (* fills the remainder of the row)'); continue; }
|
|
1316
|
+
cur.starCount=1;
|
|
1317
|
+
}
|
|
1318
|
+
cur.fields.push({name:cname,w:cw==='*'?'*':+cw,present:opts.present,
|
|
1319
|
+
index:irange.val,
|
|
1320
|
+
fill:opts.fill,stroke:opts.stroke,
|
|
1321
|
+
cls:parseClassList(opts['class'],optList(optT,'class')).ids,
|
|
1322
|
+
description:opts.description,line:n});
|
|
1323
|
+
cur.rowFields=true;
|
|
339
1324
|
continue;
|
|
340
1325
|
}
|
|
341
|
-
// Compact form (C bit-field convention): field a:1,
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
1326
|
+
// Compact form (C bit-field convention): field a:1,b:2,"Long Name":16
|
|
1327
|
+
//
|
|
1328
|
+
// 0.1: the list is read from `pos` — the OPTION-STRIPPED token
|
|
1329
|
+
// stream — not from the raw source line, so line-wide options apply
|
|
1330
|
+
// to every item.
|
|
1331
|
+
//
|
|
1332
|
+
// `COMMA-LIST-WHITESPACE`: the list is ONE whitespace-free token (RULE 3.1 —
|
|
1333
|
+
// a positional list must terminate, and whitespace is the only
|
|
1334
|
+
// terminator available). `field a:1, b:2` used to be accepted and is
|
|
1335
|
+
// now an error: one policy for every comma list in the language.
|
|
1336
|
+
// `QUOTING-RULES`: an ELEMENT is comma-delimited, so it is bare unless it
|
|
1337
|
+
// contains whitespace, a comma, a quote, a paren or `#` — and the
|
|
1338
|
+
// quotes now protect a comma inside the element too (`LINK-OPERATOR-IN-IDS` splitList).
|
|
1339
|
+
const items0=pos.slice(1);
|
|
1340
|
+
if(!items0.length||!items0.some(t=>t.includes(':'))){ err(n,'field needs <name> <width-in-bits>, or a name:width list'); continue; }
|
|
1341
|
+
// `BITFIELD-REPETITION-CONSTRUCT`: `index=` is CLASSIC-FORM ONLY in v0.1. Every
|
|
1342
|
+
// other `field` option is LINE-wide on the compact form, which is
|
|
1343
|
+
// right for presentation and for a shared condition — and wrong here:
|
|
1344
|
+
// one index range applied to every item in the list would say that
|
|
1345
|
+
// each of them repeats over the SAME indices, which is a claim no
|
|
1346
|
+
// author has yet wanted to make and which the drawing cannot show.
|
|
1347
|
+
if(opts.index!==undefined){
|
|
1348
|
+
err(n,'index= is not available on the compact field form — the range would apply LINE-wide, saying that every item repeats over the same indices. Write the repeated element in the classic form on its own line: field "<name>" <width> index=0..7'); continue; }
|
|
1349
|
+
if(items0.length>1){
|
|
1350
|
+
err(n,'field: the item list is ONE comma-delimited token — unexpected argument "'+items0[1]+'" (write field a:1,b:2 with no space after the comma; quote a name that contains whitespace: "Long Name":16) (MIGRATIONS)'); continue; }
|
|
347
1351
|
let bad=null; const parsed=[];
|
|
348
|
-
for(const
|
|
1352
|
+
for(const el of splitList(posT[1],0)){
|
|
1353
|
+
const it=el.v; if(!it) continue;
|
|
349
1354
|
const m=/^(.+):(\d+|\*)$/.exec(it);
|
|
350
|
-
if(!m||(m[2]!=='*'&&+m[2]<1)){
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
1355
|
+
if(!m||(m[2]!=='*'&&+m[2]<1)){
|
|
1356
|
+
bad=it.includes(':')
|
|
1357
|
+
? 'bad item "'+it+'" (expected name:width)'
|
|
1358
|
+
: 'bad item "'+it+'" (expected name:width; quote a name that contains spaces: "Long Name":16)';
|
|
1359
|
+
break; }
|
|
1360
|
+
const nm=m[1];
|
|
1361
|
+
// Bit-width overflow (genre doc: "beyond `word` on a NON-SPANNING
|
|
1362
|
+
// field"). A classic `field X 128` wider than the row is the
|
|
1363
|
+
// documented spanning case and stays legal; the compact form is the
|
|
1364
|
+
// C bit-field convention, where an item must fit its storage unit,
|
|
1365
|
+
// and its options are LINE-wide, so no single item in the list can
|
|
1366
|
+
// say "this one is meant to span". So an over-word compact item is
|
|
1367
|
+
// the overflow.
|
|
1368
|
+
if(m[2]!=='*' && +m[2]>cur.word){
|
|
1369
|
+
// The suggestion spells the CLASSIC form, and the classic name is
|
|
1370
|
+
// QUOTED (`QUOTING-RULES`). Until this release this string said
|
|
1371
|
+
// `write "field P 64"` — a second line error, so a user who
|
|
1372
|
+
// followed the diagnostic was told off twice.
|
|
1373
|
+
bad='"'+nm+':'+m[2]+'" is wider than word='+cur.word+' — a compact item must fit one row; write it in the classic form to span rows: field "'+nm+'" '+m[2]; break; }
|
|
1374
|
+
if(m[2]==='*'){
|
|
1375
|
+
if(cur.starCount){ bad='only one * field is allowed per bitfield (* fills the remainder of the row)'; break; }
|
|
1376
|
+
cur.starCount=1;
|
|
1377
|
+
}
|
|
354
1378
|
parsed.push({name:nm,w:m[2]==='*'?'*':+m[2]});
|
|
355
1379
|
}
|
|
1380
|
+
// On error, still keep any items parsed before the bad one so a
|
|
1381
|
+
// second `*` does not also cascade into "bitfield has no fields".
|
|
1382
|
+
if(parsed.length){
|
|
1383
|
+
// 0.1: line-wide options apply to every item in the list.
|
|
1384
|
+
for(const it of parsed) cur.fields.push({name:it.name,w:it.w,present:opts.present,
|
|
1385
|
+
fill:opts.fill,stroke:opts.stroke,
|
|
1386
|
+
cls:parseClassList(opts['class'],optList(optT,'class')).ids,
|
|
1387
|
+
description:opts.description,line:n});
|
|
1388
|
+
cur.rowFields=true;
|
|
1389
|
+
}
|
|
356
1390
|
if(bad){ err(n,bad); continue; }
|
|
357
|
-
|
|
358
|
-
|
|
1391
|
+
} else if(cur.type==='bitfield' && kw==='break'){
|
|
1392
|
+
if(pos.length>1){ err(n,'unexpected argument "'+pos[1]+'"'); continue; }
|
|
1393
|
+
// `break` ends the row after the fields declared since the block
|
|
1394
|
+
// opened (or since the previous break). With none there is nothing to
|
|
1395
|
+
// break — genre doc: "break with no preceding field in the current row".
|
|
1396
|
+
// Spelled `wrap` until this release (`ROW-BREAK-NAMING`): in CSS/typography `wrap` is
|
|
1397
|
+
// AUTOMATIC reflow — a mode — while this is an EXPLICIT break, an
|
|
1398
|
+
// event; CSS Fragmentation calls exactly this "a forced break …
|
|
1399
|
+
// explicitly indicated by the … author" and HTML spells it `br`.
|
|
1400
|
+
// is a line error. A field that ends exactly on a row boundary still
|
|
1401
|
+
// counts: it IS the preceding field of the row being closed.
|
|
1402
|
+
if(!cur.rowFields){ err(n,'break has no preceding field in the current row'); continue; }
|
|
359
1403
|
cur.fields.push({wrap:true,line:n});
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
1404
|
+
cur.rowFields=false;
|
|
1405
|
+
} else if(cur.type==='table' && kw==='width'){
|
|
1406
|
+
if(cur.width){ err(n,'duplicate width'); continue; }
|
|
1407
|
+
// 0.1: `width auto,90,auto,25%` — ONE comma-delimited token —
|
|
1408
|
+
// is the canonical spelling; it terminates at the first whitespace
|
|
1409
|
+
// instead of consuming the rest of the line. The space form
|
|
1410
|
+
// `width auto 90 auto 25%` stays accepted (same reasoning as `rank`);
|
|
1411
|
+
// mixing the two on one line is an error.
|
|
1412
|
+
// `POSITIONAL-LIST-SPELLING`: comma form only; the space form is retired.
|
|
1413
|
+
const wtoks=pos.slice(1);
|
|
1414
|
+
if(wtoks.length>1){
|
|
1415
|
+
err(n,'width takes ONE comma-delimited token: write width '+joinListForm(wtoks)+' — the space form is retired (MIGRATIONS)'); continue; }
|
|
1416
|
+
const vals=wtoks.length?splitList(posT[1],0).map(e=>e.v):[];
|
|
1417
|
+
if(!vals.length){ err(n,'width needs one value per column (auto | <px> | <n>%)'); continue; }
|
|
364
1418
|
let badw=null;
|
|
365
1419
|
const parsed=vals.map(v=>{
|
|
366
1420
|
if(v==='auto') return {t:'auto'};
|
|
367
|
-
|
|
1421
|
+
// 0.1: a `px` SUFFIX is rejected. `pin width=90px` was
|
|
1422
|
+
// already an error, and RULE 4.4 says two constructs that share a
|
|
1423
|
+
// spelling on purpose must share the value grammar too.
|
|
1424
|
+
let m=/^(\d+(?:\.\d+)?)$/.exec(v);
|
|
368
1425
|
if(m) return {t:'px',v:+m[1]};
|
|
369
1426
|
m=/^(\d+(?:\.\d+)?)%$/.exec(v);
|
|
370
1427
|
if(m) return {t:'pct',v:+m[1]};
|
|
371
|
-
badw
|
|
1428
|
+
badw=/^\d+(?:\.\d+)?px$/.test(v)
|
|
1429
|
+
? 'bad width "'+v+'" — write the number without a unit: '+v.replace(/px$/,'')+' (auto | <px> | <n>%)'
|
|
1430
|
+
: 'bad width "'+v+'" (auto | <px> | <n>%)';
|
|
1431
|
+
return null;
|
|
372
1432
|
});
|
|
373
1433
|
if(badw){ err(n,badw); continue; }
|
|
374
|
-
cur.
|
|
1434
|
+
cur.width={vals:parsed,line:n};
|
|
375
1435
|
} else if(cur.type==='table' && kw==='cell'){
|
|
376
|
-
// cell h<k>,<c> | <r>,<c>
|
|
377
|
-
// data rows 1-based below the
|
|
378
|
-
|
|
379
|
-
|
|
1436
|
+
// cell h<k>,<c> | <r>,<c> fill=… (h1..hN = header tiers top-down;
|
|
1437
|
+
// data rows 1-based below the delimiter row); cell <r> highlight
|
|
1438
|
+
// 0.1 (RULE 1.1a): a grid ADDRESS is a point, so the two-part
|
|
1439
|
+
// form is parenthesised — `cell (h1,2)`, `cell (3,4)`. The
|
|
1440
|
+
// single-valued row form (`cell 3 highlight`) is not a point and
|
|
1441
|
+
// stays bare, exactly as `threshold offset=50%` does.
|
|
1442
|
+
const cadr=pos[1]||'';
|
|
1443
|
+
if(/^h?\d+,\d+$/.test(cadr)){
|
|
1444
|
+
err(n,'cell address is now a paren point: cell ('+cadr+') — a bare comma pair is a list of two numbers, not an address (MIGRATIONS)'); continue; }
|
|
1445
|
+
const rc=/^\((h?)(\d+),(\d+)\)$/.exec(cadr)||/^(h?)(\d+)$/.exec(cadr);
|
|
1446
|
+
// `RULE-POSITION-ENUMERATION`: `highlight` is a bare keyword FLAG
|
|
1447
|
+
// (vocabulary-sources.tsv `cell.highlight`), so RULE 2.4 governs it
|
|
1448
|
+
// exactly as it governs an enum value: `cell 1 "highlight"` used to
|
|
1449
|
+
// be accepted and set the row mark. The index is found first so the
|
|
1450
|
+
// quotedness can be read off `posq` at the SAME index.
|
|
1451
|
+
const hli=pos.indexOf('highlight');
|
|
1452
|
+
if(hli>0&&posq[hli]){ err(n,ENUM_BARE('cell '+pos[1]+' highlight')); continue; }
|
|
1453
|
+
const hl=hli>0;
|
|
380
1454
|
const cextra=pos.slice(2).find(x=>x!=='highlight');
|
|
381
1455
|
if(cextra!==undefined){ err(n,'unexpected argument "'+cextra+'"'); continue; }
|
|
382
|
-
|
|
1456
|
+
// any §5 presentation attribute is annotation enough to address a cell
|
|
1457
|
+
const cpres=opts.fill!==undefined||opts.stroke!==undefined;
|
|
1458
|
+
if(!rc||(!cpres&&!opts['class']&&!hl)){ err(n,'cell needs [h]<row>[,<col>] with fill=…/class=… or highlight'); continue; }
|
|
383
1459
|
if(!rc[1]&&+rc[2]===0){ err(n,'row 0 is retired — address header tiers as h1..hN (top-down)'); continue; }
|
|
1460
|
+
// `ROW-HIGHLIGHT-CELL-FILL-COLLISION`: `highlight` is a ROW mark, `fill=` is a CELL
|
|
1461
|
+
// mark, and both paint the SAME channel — the cell interior. Written
|
|
1462
|
+
// for one cell they used to resolve silently, and the resolution ran
|
|
1463
|
+
// the wrong way: the flag was DISCARDED and never reached the model.
|
|
1464
|
+
// Two keys for one channel with a silent precedence is the `color=`
|
|
1465
|
+
// and the §8.4 `fill=`/`stroke=` defect, and this project's answer to
|
|
1466
|
+
// it has always been a named line error, never a precedence rule.
|
|
1467
|
+
if(hl&&rc[3]!==undefined){ err(n,CELL_HL_ON_CELL); continue; }
|
|
384
1468
|
if(hl&&rc[3]===undefined){
|
|
385
1469
|
if(rc[1]){ err(n,'highlight applies to data rows only'); continue; }
|
|
386
1470
|
cur.rowmarks=cur.rowmarks||[];
|
|
387
1471
|
cur.rowmarks.push({r:+rc[2],line:n});
|
|
388
|
-
} else if(rc[3]!==undefined&&(
|
|
1472
|
+
} else if(rc[3]!==undefined&&(cpres||opts['class'])){
|
|
389
1473
|
cur.marks=cur.marks||[];
|
|
390
|
-
cur.marks.push({hdr:!!rc[1],r:+rc[2],c:+rc[3],
|
|
391
|
-
|
|
392
|
-
|
|
1474
|
+
cur.marks.push({hdr:!!rc[1],r:+rc[2],c:+rc[3],fill:opts.fill,stroke:opts.stroke,
|
|
1475
|
+
cls:parseClassList(opts['class'],optList(optT,'class')).ids,line:n});
|
|
1476
|
+
} else { err(n,'cell needs [h]<row>,<col> fill=…/class=… or <row> highlight'); }
|
|
1477
|
+
} else if(cur.type==='timing' && kw==='signal'){
|
|
393
1478
|
const name=pos[1], lane=pos[2];
|
|
394
1479
|
if(!name||!lane){ err(n,'signal needs <name> <lane>'); continue; }
|
|
395
|
-
if(
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
1480
|
+
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); continue; }
|
|
1481
|
+
// `TIMING-LANE-ALPHABET`: `2`-`9` LEFT the alphabet. WaveDrom's schema gives
|
|
1482
|
+
// them "value with color 2".."value with color 9" and `=` "value
|
|
1483
|
+
// (default color 2)", so `=` and `2` are the same brick and a
|
|
1484
|
+
// WaveDrom-literate reader counts every contiguous value cell as
|
|
1485
|
+
// needing a data entry. FigDown instead drew the digit CHARACTER as
|
|
1486
|
+
// the box label and consumed no entry, so `x..=3=5x data=cfg,val`
|
|
1487
|
+
// read as four data cells to them and as boxes cfg,3,val,5 here —
|
|
1488
|
+
// legal, different, and silent. Named diagnostic, not `unknown
|
|
1489
|
+
// character`, because the author's mental model is the thing to fix.
|
|
1490
|
+
if(/[2-9]/.test(lane)){ err(n,RETIRED_LANE_DIGIT); continue; }
|
|
1491
|
+
if(!/^[01pnx=.]+$/.test(lane)){ err(n,'lane may contain only 0 1 p n x = .'); continue; }
|
|
1492
|
+
// / `TYPED-BLOCK-SILENT-FALLBACK`: data= is ABSENCE vs presence. An empty value, empty
|
|
1493
|
+
// members (a,b), or a count that does not match the lane's `=`
|
|
1494
|
+
// cells are all line errors — never silent drop or shift.
|
|
1495
|
+
// Spelled `labels=` until this release (`SIGNAL-DATA-KEY-SPELLING`): WaveDrom's own key is
|
|
1496
|
+
// `data`, "an array of signal labels" naming every value cell, and
|
|
1497
|
+
// after the `2`-`9` retirement (`TIMING-LANE-ALPHABET`) the two scopes coincide exactly.
|
|
1498
|
+
let labels;
|
|
1499
|
+
if(opts.data!==undefined){
|
|
1500
|
+
if(opts.data===''){ err(n,'data= must not be empty'); continue; }
|
|
1501
|
+
// `LINK-OPERATOR-IN-IDS`/2g: split on UNQUOTED commas, so a quoted
|
|
1502
|
+
// element protects its own comma (`data="a,b"` is ONE label).
|
|
1503
|
+
// Before this the tokenizer discarded quotedness before the split
|
|
1504
|
+
// and the language had no spelling at all for such an element.
|
|
1505
|
+
// Whether a data= element SHOULD be allowed to contain a comma is
|
|
1506
|
+
// deliberately left open (202 elements observed, 0 needing one, and
|
|
1507
|
+
// the construct is experimental) — this only makes it expressible.
|
|
1508
|
+
const parts=optList(optT,'data').map(e=>e.v);
|
|
1509
|
+
if(parts.some(p=>p==='')){ err(n,'data= must not contain empty members'); continue; }
|
|
1510
|
+
const eqN=(lane.match(/=/g)||[]).length;
|
|
1511
|
+
if(parts.length!==eqN){
|
|
1512
|
+
err(n,'data= has '+parts.length+' label'+(parts.length===1?'':'s')+', expected '+eqN+' (one per = in the lane)');
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
labels=parts;
|
|
1516
|
+
}
|
|
1517
|
+
cur.signals.push({name,lane,labels,
|
|
1518
|
+
fill:opts.fill,stroke:opts.stroke});
|
|
1519
|
+
} else if(cur.type==='timing' && kw==='gap'){
|
|
1520
|
+
// `TYPED-BLOCK-SILENT-FALLBACK`: integer only (no parseInt truncation of 1.5→1), no silent
|
|
1521
|
+
// duplicates, range checked after the block closes (needs all lanes).
|
|
1522
|
+
const raw=pos[1];
|
|
1523
|
+
if(raw===undefined||!/^\d+$/.test(raw)){ err(n,'gap needs a non-negative integer cycle'); continue; }
|
|
1524
|
+
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); continue; }
|
|
1525
|
+
const t=+raw;
|
|
1526
|
+
if(cur.gaps.some(g=>g.t===t)){ err(n,'duplicate gap at cycle '+t); continue; }
|
|
1527
|
+
cur.gaps.push({t,line:n});
|
|
401
1528
|
} else { err(n,'"'+kw+'" not valid inside '+cur.type); }
|
|
402
1529
|
continue;
|
|
403
1530
|
}
|
|
404
1531
|
cur=null; // any other keyword closes the block
|
|
1532
|
+
// Dynamic-profile reserved words keep their dedicated message (before `GENRE-KEYWORD-ALLOWLIST`).
|
|
1533
|
+
if(kw==='page'||kw==='set'||kw==='pulse'){
|
|
1534
|
+
err(n,'"'+kw+'" is reserved for the dynamic profile (not in v0.1)'); continue; }
|
|
1535
|
+
// `GENRE-KEYWORD-ALLOWLIST`: after closing a typed region, top-level keywords
|
|
1536
|
+
// must be in the header genre allowlist. Child keywords still use the
|
|
1537
|
+
// "needs a bitfield/table/timing above" path when they appear with no cur.
|
|
1538
|
+
if(sawHeader && doc.genre && GENRE_KW[doc.genre] && !CHILD_KW.has(kw) && !GENRE_KW[doc.genre].has(kw)){
|
|
1539
|
+
err(n,'"'+kw+'" is not allowed in genre '+doc.genre);
|
|
1540
|
+
continue;
|
|
1541
|
+
}
|
|
405
1542
|
if(badOpts(kw)) continue;
|
|
406
1543
|
|
|
407
|
-
//
|
|
408
|
-
if(
|
|
409
|
-
err(n,'"'+kw+'" is a semantic directive — it must appear before the
|
|
1544
|
+
// `CONTENT-LAYOUT-ZONE-SPLIT`: layout zone — after `layout`, only layout directives are legal
|
|
1545
|
+
if(sawLayout && !LAYOUT_DIRECTIVES.has(kw) && kw!=='layout'){
|
|
1546
|
+
err(n,'"'+kw+'" is a semantic directive — it must appear before the layout zone (`CONTENT-LAYOUT-ZONE-SPLIT`)');
|
|
410
1547
|
continue;
|
|
411
1548
|
}
|
|
412
1549
|
|
|
413
1550
|
switch(kw){
|
|
414
1551
|
case 'title': {
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
t+=s0[ii];
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
doc.title=t; break;
|
|
1552
|
+
if(sawTitle){ err(n,'duplicate title line'); break; }
|
|
1553
|
+
// 0.1: the string MUST be quoted. `title` was the only
|
|
1554
|
+
// directive that accepted an unquoted string containing spaces —
|
|
1555
|
+
// `node`, `class`, `group` and `external` all report
|
|
1556
|
+
// `unexpected argument "…"` for the second word — and the
|
|
1557
|
+
// rest-of-line form carried three defects of its own: a title
|
|
1558
|
+
// could never contain a `#` (the comment scanner cut it), inner
|
|
1559
|
+
// quotes ended up inside the value, and escapes resolved ONLY in
|
|
1560
|
+
// the quoted form, so `title A\nB` and `title "A\nB"` produced
|
|
1561
|
+
// different values for the same visible text. One form, one
|
|
1562
|
+
// meaning: the token is a normal quoted string and the generic
|
|
1563
|
+
// tokenizer above has already resolved its escapes.
|
|
1564
|
+
const t0=tk.toks[1];
|
|
1565
|
+
if(!t0||!t0.q){ err(n,'title needs a quoted string: title "<text>" (MIGRATIONS)'); break; }
|
|
1566
|
+
if(tk.toks.length>2){ err(n,'unexpected argument "'+tk.toks[2].v+'"'); break; }
|
|
1567
|
+
doc.title=t0.v; sawTitle=true; break;
|
|
435
1568
|
}
|
|
436
1569
|
case 'class': {
|
|
437
|
-
// semantic class (
|
|
1570
|
+
// semantic class (`CATEGORICAL-MEANING-MAPPING`): meaning + presentation defaults declared
|
|
438
1571
|
// once; elements join via class=<id>; the legend strip derives
|
|
439
1572
|
const id=pos[1];
|
|
440
|
-
|
|
1573
|
+
{ const e=idErr(id,posq[1],'class needs an id'); if(e){ err(n,e); break; } }
|
|
441
1574
|
if(classIds.has(id)){ err(n,'duplicate class "'+id+'"'); break; }
|
|
442
|
-
|
|
1575
|
+
// 0.1 (`CLASS-EMPTY-MEANING`): the meaning field is syntactically MANDATORY and
|
|
1576
|
+
// its value may be EMPTY — the language's own absent/""/"text"
|
|
1577
|
+
// tri-state (`EMPTY-LABEL-STATE`, `EMPTY-LABEL-DIRECTIVE-COVERAGE`), the same rule labels already follow.
|
|
1578
|
+
// class x -> line error (the field is missing)
|
|
1579
|
+
// class x "" -> legal; the author claims NO meaning, so the
|
|
1580
|
+
// class is pure attribute grouping and draws
|
|
1581
|
+
// no legend entry
|
|
1582
|
+
// class x "Hot path" -> legal; the entry is drawn
|
|
1583
|
+
// The test is ABSENCE, never truthiness: `!pos[2]` treated `""` as
|
|
1584
|
+
// missing, which is exactly the collapse `EMPTY-LABEL-STATE` exists to prevent.
|
|
1585
|
+
if(pos[2]===undefined){ err(n,'class needs a meaning: class '+id+' "<meaning>" (write "" to declare no meaning: the class then groups attributes only and draws no legend entry)'); break; }
|
|
1586
|
+
if(!posq[2]){ err(n,'class meaning must be quoted: class '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
|
|
443
1587
|
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
444
|
-
const cstyle=opts.style;
|
|
445
|
-
if(cstyle!==undefined && !['solid','dashed','dotted'].includes(cstyle)){ err(n,'style must be solid|dashed|dotted'); break; }
|
|
446
1588
|
classIds.add(id);
|
|
447
|
-
|
|
448
|
-
|
|
1589
|
+
// `plane=` on a class is the members' default plane (an element's own
|
|
1590
|
+
// plane= wins, rigidity `LAYOUT-STABILITY`) — the class carries all five §5 attributes.
|
|
1591
|
+
doc.classes.push({id,label:pos[2],fill:opts.fill,stroke:opts.stroke,
|
|
1592
|
+
style:opts.style,plane:opts.plane,line:n});
|
|
449
1593
|
break;
|
|
450
1594
|
}
|
|
451
|
-
case '
|
|
1595
|
+
case 'plane': {
|
|
452
1596
|
const id=pos[1];
|
|
453
|
-
|
|
454
|
-
if(
|
|
1597
|
+
{ const e=idErr(id,posq[1],'plane needs an id'); if(e){ err(n,e); break; } }
|
|
1598
|
+
if(planeIds.has(id)){ err(n,'duplicate plane id "'+id+'"'); break; }
|
|
1599
|
+
if(pos[2]!==undefined&&!posq[2]){ err(n,'plane label must be quoted: plane '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
|
|
455
1600
|
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
1601
|
+
// 0.1: `z=` -> `z-index=` (CSS's own spelling for the
|
|
1602
|
+
// stacking concept, taken in full per RULE 4.2). The retired `z=`
|
|
1603
|
+
// is caught language-wide in RETIRED_OPT_KEYS before this runs.
|
|
1604
|
+
let z=doc.planes.length;
|
|
1605
|
+
const zi=opts['z-index'];
|
|
1606
|
+
if(zi!==undefined){
|
|
1607
|
+
if(!/^-?\d+$/.test(zi)){ err(n,'z-index must be a number'); break; }
|
|
1608
|
+
z=parseInt(zi,10);
|
|
460
1609
|
}
|
|
461
|
-
|
|
462
|
-
|
|
1610
|
+
planeIds.add(id);
|
|
1611
|
+
// `EMPTY-LABEL-STATE`: absent is absent, `""` is a written value — same
|
|
1612
|
+
// non-collapsing form as node/group/bundle and the typed blocks.
|
|
1613
|
+
doc.planes.push({id,label:pos[2]!==undefined?pos[2]:null,z});
|
|
463
1614
|
break;
|
|
464
1615
|
}
|
|
1616
|
+
// `FLOWCHART-ROLE-KEYWORDS`: `process` / `decision` / `terminator` DESUGAR to
|
|
1617
|
+
// `node` plus a `role` field. One case, four spellings, so there is
|
|
1618
|
+
// exactly one place where a node is built and no second id namespace:
|
|
1619
|
+
// a role line shares the node/group/external/typed-block id space and
|
|
1620
|
+
// is addressable by `edge`, `pin` and `rank` like any node.
|
|
1621
|
+
//
|
|
1622
|
+
// A bare `node` under `flowchart` stays ROLE-UNSTATED — it does NOT
|
|
1623
|
+
// default to `process`. `UNSAFE-DEFAULT-ELIMINATION` §3: a default is legitimate only when
|
|
1624
|
+
// being wrong is harmless, and a flowchart node may be a datastore, an
|
|
1625
|
+
// annotation or a state, so `role:"process"` by default would let the
|
|
1626
|
+
// model assert a falsehood the figure cannot be inspected to catch.
|
|
1627
|
+
case 'process': case 'decision': case 'terminator':
|
|
465
1628
|
case 'node': {
|
|
1629
|
+
const role=kw==='node'?null:kw;
|
|
466
1630
|
const id=pos[1];
|
|
467
|
-
|
|
468
|
-
if(
|
|
469
|
-
if(opts.
|
|
470
|
-
|
|
471
|
-
|
|
1631
|
+
{ const e=idErr(id,posq[1],kw+' needs an id'); if(e){ err(n,e); break; } }
|
|
1632
|
+
if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
|
|
1633
|
+
if(opts.width!==undefined||opts.height!==undefined){ err(n,kw+' does not take width=/height= — use a pin line'); break; }
|
|
1634
|
+
// Geometry is DERIVED from the role, and `shape=` overrides the
|
|
1635
|
+
// DRAWING only. §12.7: presentation is not meaning on its own, so
|
|
1636
|
+
// `decision q "…" shape=box` is still a decision in the model — the
|
|
1637
|
+
// role is the model, the shape is how it happens to be drawn.
|
|
1638
|
+
const shape=opts.shape||(role?ROLE_SHAPE[role]:'box');
|
|
1639
|
+
if(RETIRED_SHAPES[shape]){ err(n,RETIRED_SHAPES[shape]); break; }
|
|
472
1640
|
if(!SHAPES.includes(shape)){ err(n,'unknown shape "'+shape+'" ('+SHAPES.join('|')+')'); break; }
|
|
1641
|
+
if(pos[2]!==undefined&&!posq[2]){ err(n,kw+' label must be quoted: '+kw+' '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
|
|
473
1642
|
nodeIds.add(id);
|
|
474
1643
|
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
1644
|
+
// `OMITTED-LABEL-RECORDING`: an omitted label is recorded as ABSENT (null), never silently
|
|
1645
|
+
// replaced by the id — a reading agent must be able to tell "the author
|
|
1646
|
+
// wrote no label" from "the author's label happens to equal the id".
|
|
1647
|
+
// Display falls back to the id in render(), so the figure is unchanged.
|
|
1648
|
+
doc.nodes.push({id,label:pos[2]!==undefined?pos[2]:null,shape,role,fill:opts.fill,stroke:opts.stroke,
|
|
1649
|
+
style:opts.style,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
|
|
1650
|
+
group:opts['in']||null,plane:opts.plane||'base',line:n});
|
|
480
1651
|
break;
|
|
481
1652
|
}
|
|
482
1653
|
case 'group': {
|
|
483
1654
|
const id=pos[1];
|
|
484
|
-
|
|
485
|
-
if(
|
|
1655
|
+
{ const e=idErr(id,posq[1],'group needs an id'); if(e){ err(n,e); break; } }
|
|
1656
|
+
if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
|
|
1657
|
+
if(pos[2]!==undefined&&!posq[2]){ err(n,'group label must be quoted: group '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
|
|
486
1658
|
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
487
1659
|
groupIds.add(id);
|
|
488
1660
|
let ggap;
|
|
489
1661
|
if(opts.gap!==undefined){
|
|
1662
|
+
// the numeric-value rule: exact number grammar (non-negative, including 0 for flush)
|
|
1663
|
+
if(!/^\d+(\.\d+)?$/.test(opts.gap)){ err(n,'gap must be a non-negative number'); break; }
|
|
490
1664
|
ggap=parseFloat(opts.gap);
|
|
491
1665
|
if(!isFinite(ggap)||ggap<0){ err(n,'gap must be a non-negative number'); break; }
|
|
492
1666
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
text:opts.text,style:gstyle,gap:ggap,cls:opts['class'],
|
|
497
|
-
layer:opts.layer||null,line:n});
|
|
1667
|
+
doc.groups.push({id,label:pos[2]!==undefined?pos[2]:null,fill:opts.fill,stroke:opts.stroke,
|
|
1668
|
+
style:opts.style,gap:ggap,cls:parseClassList(opts['class'],optList(optT,'class')).ids,
|
|
1669
|
+
plane:opts.plane||null,line:n});
|
|
498
1670
|
break;
|
|
499
1671
|
}
|
|
500
|
-
case '
|
|
501
|
-
// external I/O endpoint (
|
|
1672
|
+
case 'external': {
|
|
1673
|
+
// external I/O endpoint (`EXTERNAL-EDGE-ENDPOINTS`): "the outside world", not a
|
|
502
1674
|
// participant node. Referenced by edges like a node, pinnable for
|
|
503
1675
|
// layout, NEVER drawn as a shape — the edge simply ends open at an
|
|
504
1676
|
// invisible anchor, optionally with the small label beyond the end.
|
|
505
|
-
// Shares the node/group id namespace
|
|
1677
|
+
// Shares the node/group/block id namespace. Of the §5 attributes it
|
|
1678
|
+
// can carry only the two that need no drawn shape: `text=` (the label
|
|
1679
|
+
// colour) and `plane=` (organizational, exactly as on a node).
|
|
1680
|
+
// Spelled `boundary` until this release (`EXTERNAL-ENDPOINT-NAMING`): three standards claim that
|
|
1681
|
+
// word for the OPPOSITE meaning (UML ECB «boundary» is an internal
|
|
1682
|
+
// interface object, C4 System_Boundary is a dashed grouping container,
|
|
1683
|
+
// BPMN's is an event), and this spec's own prose had already stopped
|
|
1684
|
+
// using it — §2.8 says "declares an external I/O endpoint".
|
|
506
1685
|
const id=pos[1];
|
|
507
|
-
|
|
508
|
-
if(
|
|
1686
|
+
{ const e=idErr(id,posq[1],'external needs an id'); if(e){ err(n,e); break; } }
|
|
1687
|
+
if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
|
|
1688
|
+
if(pos[2]!==undefined&&!posq[2]){ err(n,'external label must be quoted: external '+id+' "'+pos[2]+'" — '+Q_WHY); break; }
|
|
509
1689
|
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
510
1690
|
boundaryIds.add(id);
|
|
511
|
-
|
|
1691
|
+
// `EMPTY-LABEL-STATE`: the `OMITTED-LABEL-RECORDING` batch never converted this line; `pos[2]||undefined`
|
|
1692
|
+
// collapsed an explicitly empty label into absence. Same form as the
|
|
1693
|
+
// other eight label-bearing directives now.
|
|
1694
|
+
doc.boundaries.push({id,label:pos[2]!==undefined?pos[2]:null,plane:opts.plane,line:n});
|
|
512
1695
|
break;
|
|
513
1696
|
}
|
|
514
1697
|
case 'flow': {
|
|
1698
|
+
if(sawFlow){ err(n,'duplicate flow line'); break; }
|
|
1699
|
+
// `RULE-POSITION-ENUMERATION`: `flow.direction` is an enum position — bare.
|
|
1700
|
+
if(posq[1]){ err(n,ENUM_BARE('flow '+pos[1])); break; }
|
|
515
1701
|
if(!['right','down','left','up'].includes(pos[1])){ err(n,'flow needs right|down|left|up'); break; }
|
|
516
1702
|
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
|
|
517
|
-
doc.flow=pos[1]; break;
|
|
1703
|
+
doc.flow=pos[1]; sawFlow=true; break;
|
|
518
1704
|
}
|
|
519
1705
|
case 'rank': {
|
|
520
|
-
|
|
1706
|
+
// 0.1: `rank a,b,c` — ONE comma-delimited token — is the
|
|
1707
|
+
// canonical spelling. It terminates at the first whitespace, so the
|
|
1708
|
+
// rest of the line stays free for future `key=` options, and it reads
|
|
1709
|
+
// as one list rather than as a directive whose arity is "the rest of
|
|
1710
|
+
// the line". The space form `rank a b c` is NOT deprecated and stays
|
|
1711
|
+
// accepted: options are extracted by shape (`key=`) from anywhere on
|
|
1712
|
+
// the line, so the space form blocks only a trailing bare positional.
|
|
1713
|
+
// Migration cost of the comma form is zero — it was unreachable
|
|
1714
|
+
// before (a comma-bearing id is not a legal id), so no document can
|
|
1715
|
+
// have used it. Mixing the two spellings on one line is an error.
|
|
1716
|
+
// `QUOTED-IDS`: the elements are read quote-aware, so
|
|
1717
|
+
// `rank "a",b` reports the ID RULE instead of the old — and simply
|
|
1718
|
+
// wrong — "do not mix the comma form with the space form", which
|
|
1719
|
+
// fired because the lexer split `"a",b` into two tokens.
|
|
1720
|
+
// `POSITIONAL-LIST-SPELLING` REVERSES `POSITIONAL-LIST-SPELLING`'s disposition: the SPACE form is
|
|
1721
|
+
// retired and `rank a,b,c` is the only spelling. `POSITIONAL-LIST-SPELLING` kept both on a
|
|
1722
|
+
// measured migration cost (360 downstream lines, 25 local); the
|
|
1723
|
+
// maintainer has since ruled that cost is not a language argument
|
|
1724
|
+
// before the freeze, and `POSITIONAL-LIST-SPELLING`'s own words concede the change is "a
|
|
1725
|
+
// notation-only improvement" — while the notation IS the language.
|
|
1726
|
+
// Two spellings of one meaning give a generating model two attractors
|
|
1727
|
+
// (`PRIOR-ART-BORROWING`), and this pair is the worst of the three because `bundle`
|
|
1728
|
+
// could not even detect a half-converted line.
|
|
1729
|
+
const rtoks=pos.slice(1), rT=posT.slice(1);
|
|
1730
|
+
if(rtoks.length>1){
|
|
1731
|
+
err(n,'rank takes ONE comma-delimited token: write rank '+joinListForm(rtoks)+' — the space form is retired (MIGRATIONS)'); break; }
|
|
1732
|
+
const rels=rtoks.length?splitList(rT[0],0):[];
|
|
1733
|
+
let badr=null;
|
|
1734
|
+
for(const e of rels){
|
|
1735
|
+
const m2=idErr(e.v,e.h,'rank ids must not be empty (write rank a,b,c)');
|
|
1736
|
+
if(m2){ badr=m2; break; }
|
|
1737
|
+
}
|
|
1738
|
+
if(badr){ err(n,badr); break; }
|
|
1739
|
+
const ids=rels.map(e=>e.v);
|
|
521
1740
|
if(ids.length<2){ err(n,'rank needs two or more node ids'); break; }
|
|
522
1741
|
doc.ranks.push({ids,line:n}); break;
|
|
523
1742
|
}
|
|
@@ -525,156 +1744,287 @@ function parse(text){
|
|
|
525
1744
|
// semantic link bundle (LAG / Ethernet Segment): the renderer draws
|
|
526
1745
|
// the dashed ellipse around the member links automatically
|
|
527
1746
|
const id=pos[1];
|
|
528
|
-
|
|
1747
|
+
{ const e=idErr(id,posq[1],'bundle needs an id'); if(e){ err(n,e); break; } }
|
|
529
1748
|
if(bundleIds.has(id)){ err(n,'duplicate bundle id "'+id+'"'); break; }
|
|
530
|
-
|
|
531
|
-
|
|
1749
|
+
// `OPTION-POSITION-PARSING`: quotedness is tested `pos`-aligned (posq), so an option may
|
|
1750
|
+
// precede the label exactly as it may on node/group/class/plane.
|
|
1751
|
+
let restT=posT.slice(2), tlabel=null; // `OMITTED-LABEL-RECORDING`: absent label stays absent
|
|
1752
|
+
if(posq[2]){ tlabel=pos[2]; restT=posT.slice(3); }
|
|
1753
|
+
// 0.1 (EXPERIMENTAL, `bundle` is a demoted construct):
|
|
1754
|
+
// the member list may be ONE comma-delimited token —
|
|
1755
|
+
// `bundle t1 "LAG" a--b,c--d` — so it terminates at whitespace
|
|
1756
|
+
// instead of resting entirely on the `A--B` shape test. The
|
|
1757
|
+
// whitespace-separated form stays accepted, including the trailing
|
|
1758
|
+
// comma per token (`a--b, c--d`) that the old member regex already
|
|
1759
|
+
// tolerated and that local documents already use; that tolerance is
|
|
1760
|
+
// why `bundle` — unlike `rank` and `width` — cannot reject "mixing":
|
|
1761
|
+
// the two spellings were never distinguishable here.
|
|
1762
|
+
//
|
|
1763
|
+
// `LINK-OPERATOR-IN-IDS`: a member is SPLIT on `--`, not matched by a
|
|
1764
|
+
// greedy two-id regex. With `--` banned inside an id the split is
|
|
1765
|
+
// total: exactly two parts, each a legal id, or the line is an
|
|
1766
|
+
// error. `a-x--b` no longer silently picks one of two readings.
|
|
1767
|
+
// `POSITIONAL-LIST-SPELLING`: comma form only; the space form is retired. It
|
|
1768
|
+
// is now detectable — a second positional token after the label is
|
|
1769
|
+
// the half-converted line the old tolerance could not report.
|
|
1770
|
+
if(restT.length>1){
|
|
1771
|
+
err(n,'bundle members take ONE comma-delimited token: write bundle '+id+(tlabel===null?'':' "'+tlabel+'"')+' '+joinListForm(restT.map(t=>t.v))+' — the space form is retired (MIGRATIONS)'); break; }
|
|
532
1772
|
const pairs=[]; let badp=null;
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
1773
|
+
outerB:
|
|
1774
|
+
for(const t of restT){
|
|
1775
|
+
for(const s of splitList(t,0)){
|
|
1776
|
+
const mem=s.v.trim(); if(!mem) continue;
|
|
1777
|
+
if(s.h){ badp=ID_RULE; break outerB; }
|
|
1778
|
+
const parts=mem.split('--');
|
|
1779
|
+
if(parts.length!==2||!isId(parts[0])||!isId(parts[1])){
|
|
1780
|
+
badp='bad member "'+mem+'" (expected A--B)'; break outerB; }
|
|
1781
|
+
pairs.push([parts[0],parts[1]]);
|
|
1782
|
+
}
|
|
537
1783
|
}
|
|
538
1784
|
if(badp){ err(n,badp); break; }
|
|
539
1785
|
if(!pairs.length){ err(n,'bundle needs at least one member link A--B'); break; }
|
|
540
1786
|
bundleIds.add(id);
|
|
541
|
-
|
|
1787
|
+
// §5 on the derived ring: `fill=` stays the ring colour (stroke +
|
|
1788
|
+
// label) as before, `stroke=`/`text=` split it, `style=` picks the
|
|
1789
|
+
// dash (the conventional default is dashed), `plane=` orders this
|
|
1790
|
+
// ring against the other rings.
|
|
1791
|
+
doc.trunks.push({id,label:tlabel,pairs,stroke:opts.stroke,
|
|
1792
|
+
style:opts.style,plane:opts.plane,line:n});
|
|
542
1793
|
break;
|
|
543
1794
|
}
|
|
544
|
-
case '
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
1795
|
+
case 'threshold': {
|
|
1796
|
+
// horizontal threshold marker across the target's box:
|
|
1797
|
+
// threshold "<label>" in=<group|node> offset=<0..100>%
|
|
1798
|
+
// Spelled `guide` in an earlier release (`THRESHOLD-KEYWORD-SPELLING`): in every
|
|
1799
|
+
// vector editor a "guide" is an author-only construction line that is
|
|
1800
|
+
// never rendered, so the name was inverted; `threshold` is Grafana's,
|
|
1801
|
+
// with IETF RED/AQM min_th/max_th as the secondary source.
|
|
1802
|
+
// The target may be a GROUP or a single NODE, exactly like `band`
|
|
1803
|
+
// (§2.6: scope follows the meaning, `AUTHORING-INTENT-OVER-RENDERING`) — the pair is symmetric.
|
|
1804
|
+
// `OPTION-POSITION-PARSING`: `pos`-aligned quotedness — `threshold in=a "Thr" offset=50%`
|
|
1805
|
+
// is legal.
|
|
1806
|
+
// 0.1 (`THRESHOLD-VALUE-SCOPE`): NO `value=` and NO `ref=`. Zero corpus figures
|
|
1807
|
+
// carry a literal numeric value — every reference is a named
|
|
1808
|
+
// software-configurable register, and the name already lives in the
|
|
1809
|
+
// mandatory label. A second carrier has no demonstrated need.
|
|
1810
|
+
const glabel=posq[1]?pos[1]:null;
|
|
1811
|
+
if(glabel===null){ err(n,'threshold needs a quoted "<label>" first'); break; }
|
|
1812
|
+
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
|
|
1813
|
+
if(!opts['in']){ err(n,'threshold needs in=<node-or-group-id>'); break; }
|
|
1814
|
+
// 0.1: `at=` -> `offset=`. `at=` carried TWO value shapes —
|
|
1815
|
+
// a point in canvas px on `pin`, a percentage here — and no reader
|
|
1816
|
+
// can predict which applies without knowing the directive (RULE
|
|
1817
|
+
// 4.3). SVG already spells "a position along an extent, as a
|
|
1818
|
+
// percentage" `offset` (on <stop>), which is the same source the
|
|
1819
|
+
// paint keys come from, so nothing is invented.
|
|
1820
|
+
if(opts.at!==undefined){
|
|
1821
|
+
err(n,'threshold at= has been renamed: use offset=<0..100>% (at= is the pin POINT in canvas px; SVG spells a position along an extent "offset") (MIGRATIONS)'); break; }
|
|
1822
|
+
const m=/^(\d+(?:\.\d+)?)%$/.exec(opts.offset||''); // % is mandatory (`BARE-FRACTION-VALUES`)
|
|
1823
|
+
if(!m||+m[1]<0||+m[1]>100){ err(n,'threshold needs offset=<0..100>% (with the % sign)'); break; }
|
|
1824
|
+
// §5: `stroke=` is the marker colour, `color=` the label colour,
|
|
1825
|
+
// `style=` picks the dash (default dashed), `plane=` orders this
|
|
1826
|
+
// marker against the other markers.
|
|
1827
|
+
doc.thresholds.push({label:glabel,target:opts['in'],pct:+m[1],
|
|
1828
|
+
stroke:opts.stroke,style:opts.style,
|
|
1829
|
+
plane:opts.plane,line:n});
|
|
554
1830
|
break;
|
|
555
1831
|
}
|
|
556
|
-
case '
|
|
557
|
-
// chart family:
|
|
1832
|
+
case 'chart': {
|
|
1833
|
+
// chart family: chart <table-id> [type=bar3d]
|
|
558
1834
|
// rows -> X, columns -> Y, numeric cells -> Z (the table IS the data)
|
|
1835
|
+
// Spelled `plot` with `kind=bars3d` until this release (`CHART-BLOCK-NAMING`).
|
|
1836
|
+
// 0.1 (`CHART-LEVEL-KEY`): `level=` is DELETED. Zero uses corpus-wide, zero
|
|
1837
|
+
// 3-D bar charts, zero requests; one in-repo example and two fixtures.
|
|
1838
|
+
// It was the only construct whose caption the ENGINE wrote rather than
|
|
1839
|
+
// the author, and its parseFloat grammar uniquely accepted `1e3`,
|
|
1840
|
+
// breaking the otherwise-uniform \d+(\.\d+)? numeric grammar. The
|
|
1841
|
+
// retirement message lives in RETIRED_OPT_KEYS.
|
|
559
1842
|
const tid=pos[1];
|
|
560
|
-
|
|
1843
|
+
{ const e=idErr(tid,posq[1],'chart needs a table id'); if(e){ err(n,e); break; } }
|
|
561
1844
|
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
|
|
562
|
-
const
|
|
563
|
-
if(
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
if(!isFinite(plevel)||plevel<0){ err(n,'level must be a non-negative number'); break; }
|
|
568
|
-
}
|
|
569
|
-
doc.blocks.push({type:'plot',id:'plot_'+doc.blocks.length,tid,kind:pkind,level:plevel,line:n});
|
|
1845
|
+
const ptype=opts.type||'bar3d';
|
|
1846
|
+
if(ptype!=='bar3d'){ err(n,'unknown chart type "'+ptype+'" (bar3d)'); break; }
|
|
1847
|
+
// NB: the block discriminator is already `type`, so the chart-type
|
|
1848
|
+
// value is stored as `ctype` and surfaced in the model as `type`.
|
|
1849
|
+
doc.blocks.push({type:'chart',id:'chart_'+doc.blocks.length,tid,ctype:ptype,line:n});
|
|
570
1850
|
break;
|
|
571
1851
|
}
|
|
572
|
-
case '
|
|
573
|
-
// zone band:
|
|
574
|
-
// "
|
|
575
|
-
if(opts.from!==undefined||opts.to!==undefined){ err(n,'from=/to= retired — write the range positionally:
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
1852
|
+
case 'band': {
|
|
1853
|
+
// zone band: band "<label>" <pct>% | <a>..<b>% in=<node|group> [extend=]
|
|
1854
|
+
// "band 15%" = 0..15; "band 15..35%" = the range in one token
|
|
1855
|
+
if(opts.from!==undefined||opts.to!==undefined){ err(n,'from=/to= retired — write the range positionally: band "<label>" 15% or band "<label>" 15..35%'); break; }
|
|
1856
|
+
// 0.1 (`BAND-LABEL-STATUS`): the label is MANDATORY and QUOTED. Until now
|
|
1857
|
+
// `band` had no label slot at ALL, so its complete model was
|
|
1858
|
+
// {target, from, to, extend, fill, line}; strip `fill=`, which §5/`PRESENTATION-AS-MEANING-CARRIER`
|
|
1859
|
+
// entitle a reader to discard, and a band asserted NOTHING — its
|
|
1860
|
+
// meaning rode on colour alone, which §5 declares must never happen.
|
|
1861
|
+
// Every interval region in the measured corpus is a NAMED one
|
|
1862
|
+
// (Headroom/Share/Guarantee, Latency/Block/Transmit, G/Y/R), and the
|
|
1863
|
+
// buffer-region figure was forced to carry those names in three
|
|
1864
|
+
// `class` declarations all literally spelled "region".
|
|
1865
|
+
// The label comes FIRST — the position every other labelled directive
|
|
1866
|
+
// in the language puts it in (`node`, `group`, `external`, `bundle`,
|
|
1867
|
+
// `threshold`), so one reader rule covers the whole vocabulary and
|
|
1868
|
+
// the sibling pair `threshold "X" in=… ` / `band "X" 15..35% in=…`
|
|
1869
|
+
// reads the same way. `OPTION-POSITION-PARSING` quotedness is `pos`-aligned, so an option
|
|
1870
|
+
// may still precede it.
|
|
1871
|
+
const flabel=posq[1]?pos[1]:null;
|
|
1872
|
+
if(flabel===null){ err(n,'band needs a quoted "<label>" first: band "<name>" <a>..<b>% in=<node-or-group-id> (a band with no label asserts nothing a reader may keep — fill= is presentation, and §5 forbids meaning riding on colour alone) (MIGRATIONS)'); break; }
|
|
1873
|
+
if(!opts['in']){ err(n,'band needs in=<node-or-group-id>'); break; }
|
|
1874
|
+
// 0.1: the `%` is MANDATORY, matching `threshold offset=` (`BARE-FRACTION-VALUES`).
|
|
1875
|
+
// `band 15`, `band 15-35` and `band 15%-35` all parsed before (the
|
|
1876
|
+
// separator was a hyphen until this release); one concept in one
|
|
1877
|
+
// document must not have two value grammars (RULE 4.4).
|
|
1878
|
+
// `RANGE-SPELLING`: the separator is `..`, and the HYPHEN form it
|
|
1879
|
+
// replaces gets its own named diagnostic. `15-35%` reads as
|
|
1880
|
+
// subtraction, and it was the language's SECOND range spelling — the
|
|
1881
|
+
// first being `index=` (`BITFIELD-REPETITION-CONSTRUCT`, same release), which is single-sourced
|
|
1882
|
+
// from Ada (ISO/IEC 8652 `1 .. 10`) and Pascal (ISO 7185
|
|
1883
|
+
// `array [1..10]`), both inclusive, with X.680's `SIZE(1..4)` already
|
|
1884
|
+
// cited by `bitfield`. `band` is EXPERIMENTAL (`CONSTRUCT-STATUS-TIERS`), so no
|
|
1885
|
+
// compatibility promise is owed; the diagnostic is owed anyway.
|
|
1886
|
+
if(/^(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)%$/.test(pos[2]||'')){
|
|
1887
|
+
err(n,'the hyphen range "'+pos[2]+'" is no longer the spelling: write band "'+flabel+'" '+String(pos[2]).replace('-','..')+' in=… — FigDown has ONE range grammar, "..", and a hyphen between two numbers reads as subtraction (MIGRATIONS)'); break; }
|
|
1888
|
+
const m=/^(\d+(?:\.\d+)?)%$|^(\d+(?:\.\d+)?)\.\.(\d+(?:\.\d+)?)%$/.exec(pos[2]||'');
|
|
1889
|
+
if(!m){ err(n,'band needs a range with the % sign: band "<label>" <pct>% or band "<label>" <a>..<b>%'); break; }
|
|
1890
|
+
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
1891
|
+
const from=m[1]!==undefined?0:+m[2];
|
|
1892
|
+
const to=m[1]!==undefined?+m[1]:+m[3];
|
|
1893
|
+
if(from<0||to>100||from>=to){ err(n,'band range needs 0 <= from < to <= 100'); break; }
|
|
1894
|
+
// 0.1: `dir=` -> `extend=` (`UNSAFE-DEFAULT-ELIMINATION`: `dir` is HTML's text writing
|
|
1895
|
+
// direction; this says which way the band extends from its anchor).
|
|
1896
|
+
const fdir=opts.extend||'up';
|
|
1897
|
+
if(!['up','down','left','right'].includes(fdir)){ err(n,'extend must be up|down|left|right'); break; }
|
|
1898
|
+
// §5: `fill=` is the band fill; `stroke=` and/or `style=` draw the
|
|
1899
|
+
// band outline; `color=` is the label colour (with the
|
|
1900
|
+
// label); `plane=` orders this band against the other bands.
|
|
1901
|
+
doc.bands.push({label:flabel,target:opts['in'],from,to,dir:fdir,fill:opts.fill||'#e5e7eb',
|
|
1902
|
+
stroke:opts.stroke,style:opts.style,plane:opts.plane,line:n});
|
|
585
1903
|
break;
|
|
586
1904
|
}
|
|
1905
|
+
// `ELEMENT-GEOMETRY-DIRECTIVE`: ONE directive for an element's declared geometry.
|
|
1906
|
+
// `at=` places it, `width=`/`height=` extend it; all three are optional
|
|
1907
|
+
// and at least one is required, because a `pin` line that declares
|
|
1908
|
+
// nothing is not a weaker constraint — it is a typo.
|
|
587
1909
|
case 'pin': {
|
|
588
1910
|
const id=pos[1], at=opts.at;
|
|
1911
|
+
const PIN_SHAPE='pin needs <id> and at=(<x>,<y>) and/or width=<px> and/or height=<px>';
|
|
589
1912
|
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
break;
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
1913
|
+
// the numeric-value rule: value grammar is exactly -?\d+(\.\d+)? per component —
|
|
1914
|
+
// no scientific notation, no trailing junk, no multi-dot.
|
|
1915
|
+
// Negative values stay legal (canvas can place off-origin).
|
|
1916
|
+
{ const e=idErr(id,posq[1],null); if(e){ err(n,e); break; } }
|
|
1917
|
+
if(!id){ err(n,PIN_SHAPE); break; }
|
|
1918
|
+
// 0.1 (RULE 1.1a): the pair is PARENTHESISED — at=(x,y).
|
|
1919
|
+
if(at!==undefined&&/^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/.test(at)){
|
|
1920
|
+
err(n,'pin at= now takes a paren point: at=('+at+') — a bare comma pair is a list of two numbers, not a point (MIGRATIONS)'); break; }
|
|
1921
|
+
const m=at===undefined?null:/^\((-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)\)$/.exec(at);
|
|
1922
|
+
// A WRITTEN `at=` that does not parse is its own error, never the
|
|
1923
|
+
// whole-line shape message: the author declared a point and got the
|
|
1924
|
+
// point wrong, and telling them the line "needs an id" hides that.
|
|
1925
|
+
if(at!==undefined&&!m){ err(n,'pin at= needs a paren point: at=(<x>,<y>)'); break; }
|
|
599
1926
|
let badsz=false;
|
|
600
|
-
const dim=(k)=>{ // px only (
|
|
601
|
-
const v=opts[k]; if(v===undefined
|
|
1927
|
+
const dim=(k)=>{ // px only (`NON-NUMERIC-EXTENT`/`PERCENT-VALUES-LOST`)
|
|
1928
|
+
const v=opts[k]; if(v===undefined) return null;
|
|
1929
|
+
if(v===''){ err(n,k+' must be a positive number (px)'); badsz=true; return null; }
|
|
602
1930
|
if(/%$/.test(v)){ err(n,'percentage sizes are not in v0.1 — use px'); badsz=true; return null; }
|
|
1931
|
+
// 0.1 (RULE 4.4): the `width` KEYWORD and the `width=`
|
|
1932
|
+
// OPTION share one unit grammar — a bare number in px, `<n>%`, or
|
|
1933
|
+
// `auto`. `%` and `auto` are outside v0.1 SCOPE on `pin` (a node
|
|
1934
|
+
// has no containing extent for a percentage to resolve against,
|
|
1935
|
+
// and `auto` is what an absent extent already means), so each gets
|
|
1936
|
+
// its own named diagnostic instead of the generic number message.
|
|
1937
|
+
if(v==='auto'){ err(n,k+'=auto is the default — omit the key (a node with no width=/height= is already auto-sized)'); badsz=true; return null; }
|
|
1938
|
+
// the numeric-value rule: exact grammar \d+(\.\d+)? — rejects em/px suffixes,
|
|
1939
|
+
// hex (0x10), scientific notation, negatives, and zero. `at=`
|
|
1940
|
+
// keeps negatives; an extent is a box dimension and must be positive.
|
|
1941
|
+
if(!/^\d+(\.\d+)?$/.test(v)){ err(n,k+' must be a positive number (px)'); badsz=true; return null; }
|
|
603
1942
|
const f=parseFloat(v);
|
|
604
|
-
if(!isFinite(f)){ err(n,k+' must be a number'); badsz=true; return null; }
|
|
1943
|
+
if(!isFinite(f)||f<=0){ err(n,k+' must be a positive number (px)'); badsz=true; return null; }
|
|
605
1944
|
return f;
|
|
606
1945
|
};
|
|
607
|
-
const w=dim('
|
|
1946
|
+
const w=dim('width'), h=dim('height');
|
|
608
1947
|
if(badsz) break;
|
|
609
|
-
|
|
1948
|
+
// Declares nothing: no position and no extent (§3, `ELEMENT-GEOMETRY-DIRECTIVE`).
|
|
1949
|
+
if(!m&&w===null&&h===null){ err(n,PIN_SHAPE); break; }
|
|
1950
|
+
// single-valued PER ID (`REPEATED-DIRECTIVE-HANDLING` / ): `pin a` + `pin b` is fine,
|
|
1951
|
+
// `pin a` twice is a line error — one element cannot sit at two
|
|
1952
|
+
// places or carry two extents, and last-wins would silently delete
|
|
1953
|
+
// whichever keys the second line omitted.
|
|
1954
|
+
if(doc.pins[id]!==undefined){ err(n,'duplicate pin for "'+id+'"'); break; }
|
|
1955
|
+
doc.pins[id]={fx:m?parseFloat(m[1]):null,fy:m?parseFloat(m[2]):null,w,h,line:n};
|
|
610
1956
|
break;
|
|
611
1957
|
}
|
|
612
|
-
case '
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
// only — no semantics; belongs in the trailing layout section.
|
|
617
|
-
if(!['orthogonal','straight'].includes(pos[1]||'')){ err(n,'routing needs orthogonal|straight'); break; }
|
|
618
|
-
if(pos.length>2){ err(n,'unexpected argument "'+pos[2]+'"'); break; }
|
|
619
|
-
doc.routing=pos[1]; break;
|
|
620
|
-
}
|
|
621
|
-
case 'route': {
|
|
622
|
-
// per-edge declared waypoints (0.1-dev.13): route <a> <op> <b>
|
|
623
|
-
// via=x,y;x,y;… [routing=orthogonal|straight]. References ONE
|
|
624
|
-
// existing edge exactly as written (a, operator, b — endpoint
|
|
625
|
-
// order matters, unlike bundle members); waypoints are rigid
|
|
626
|
-
// canvas px, the same space as ungrouped pin. Layout section only.
|
|
627
|
-
const a=pos[1], op=pos[2], b=pos[3];
|
|
628
|
-
if(!a||!ID_RE.test(a)||!['->','<-','--','<->'].includes(op||'')||!b||!ID_RE.test(b)){
|
|
629
|
-
err(n,'route needs <a> ->|<-|--|<-> <b> via=x,y;x,y;…'); break; }
|
|
630
|
-
if(pos.length>4){ err(n,'unexpected argument "'+pos[4]+'"'); break; }
|
|
631
|
-
if(opts.via===undefined){ err(n,'route needs via=x,y;x,y;… (canvas px waypoints)'); break; }
|
|
632
|
-
const pairs=[]; let badv=null;
|
|
633
|
-
for(const t of String(opts.via).split(';')){
|
|
634
|
-
const m=/^(-?[\d.]+),(-?[\d.]+)$/.exec(t.trim());
|
|
635
|
-
const px=m&&parseFloat(m[1]), py=m&&parseFloat(m[2]);
|
|
636
|
-
if(!m||!isFinite(px)||!isFinite(py)){ badv='bad via point "'+t.trim()+'" (expected x,y)'; break; }
|
|
637
|
-
pairs.push([px,py]);
|
|
638
|
-
}
|
|
639
|
-
if(badv){ err(n,badv); break; }
|
|
640
|
-
if(opts.routing!==undefined && !['orthogonal','straight'].includes(opts.routing)){
|
|
641
|
-
err(n,'routing must be orthogonal|straight'); break; }
|
|
642
|
-
doc.routes.push({a,op,b,via:pairs,routing:opts.routing,line:n});
|
|
643
|
-
break;
|
|
644
|
-
}
|
|
645
|
-
case 'render': {
|
|
646
|
-
if(sawRender){ err(n,'duplicate render line'); break; }
|
|
647
|
-
if(pos.length>1){ err(n,'render takes no arguments'); break; }
|
|
648
|
-
sawRender=true; break;
|
|
1958
|
+
case 'layout': {
|
|
1959
|
+
if(sawLayout){ err(n,'duplicate layout line'); break; }
|
|
1960
|
+
if(pos.length>1){ err(n,'layout takes no arguments'); break; }
|
|
1961
|
+
sawLayout=true; break;
|
|
649
1962
|
}
|
|
650
1963
|
case 'bitfield': {
|
|
651
1964
|
const id=pos[1];
|
|
652
|
-
|
|
1965
|
+
{ const e=idErr(id,posq[1],'bitfield needs an id'); if(e){ err(n,e); break; } }
|
|
1966
|
+
if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
|
|
1967
|
+
if(BLK_LBL(n,kw,id,pos,posq)) break;
|
|
653
1968
|
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
1969
|
+
blockIds.add(id);
|
|
1970
|
+
// §5 on a typed block: `fill=` is the default cell fill, `stroke=`
|
|
1971
|
+
// the cell border colour, `text=` the block caption colour.
|
|
1972
|
+
// `TYPED-BLOCK-SILENT-FALLBACK`: word= empty or non-integer was a silent fallback/truncation
|
|
1973
|
+
// (word= → 32, word=8.5 → 8). Positive integer only; absence → 32.
|
|
1974
|
+
// Spelled `unit=` until this release (`BITS-PER-ROW-KEY-NAMING`).
|
|
1975
|
+
let word=32;
|
|
1976
|
+
if(opts.word!==undefined){
|
|
1977
|
+
if(opts.word===''||!/^\d+$/.test(opts.word)||+opts.word<1){
|
|
1978
|
+
err(n,'word must be a positive integer');
|
|
1979
|
+
word=32; // keep a usable value so field lines still check
|
|
1980
|
+
} else word=+opts.word;
|
|
1981
|
+
}
|
|
1982
|
+
cur={type:'bitfield',id,label:pos[2]!==undefined?pos[2]:null,word,
|
|
1983
|
+
numbering:opts.numbering||'lsb0',fields:[],starCount:0,
|
|
1984
|
+
fill:opts.fill,stroke:opts.stroke,
|
|
1985
|
+
cls:parseClassList(opts['class'],optList(optT,'class')).ids,line:n};
|
|
1986
|
+
// 0.1: `numbering=` is REQUIRED — there is no default. Unlike an
|
|
1987
|
+
// absent label (which has a harmless rendering fallback), bit-numbering
|
|
1988
|
+
// direction has no harmless fallback: whichever value were defaulted,
|
|
1989
|
+
// the other half of the corpus would silently render a ruler that
|
|
1990
|
+
// asserts something false. Bit numbering is meaning, and under `MEANING-RECOVERY-SOURCE`
|
|
1991
|
+
// meaning comes from syntax, never from a silent default. The block
|
|
1992
|
+
// still opens (with the old lsb0 behaviour) so the `field` lines below
|
|
1993
|
+
// report their own problems in the same pass.
|
|
1994
|
+
if(opts.numbering===undefined) err(n,'bitfield requires numbering= (lsb0 or msb0)');
|
|
1995
|
+
else if(!['lsb0','msb0'].includes(cur.numbering)) err(n,'numbering must be lsb0 or msb0');
|
|
658
1996
|
doc.blocks.push(cur); break;
|
|
659
1997
|
}
|
|
660
1998
|
case 'table': {
|
|
661
1999
|
const id=pos[1];
|
|
662
|
-
|
|
2000
|
+
{ const e=idErr(id,posq[1],'table needs an id'); if(e){ err(n,e); break; } }
|
|
2001
|
+
if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
|
|
2002
|
+
if(BLK_LBL(n,kw,id,pos,posq)) break;
|
|
663
2003
|
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
664
|
-
|
|
2004
|
+
blockIds.add(id);
|
|
2005
|
+
cur={type:'table',id,label:pos[2]!==undefined?pos[2]:null,cols:[],heads:[],rows:[],
|
|
2006
|
+
fill:opts.fill,stroke:opts.stroke,
|
|
2007
|
+
cls:parseClassList(opts['class'],optList(optT,'class')).ids,line:n};
|
|
665
2008
|
doc.blocks.push(cur); break;
|
|
666
2009
|
}
|
|
667
|
-
case '
|
|
2010
|
+
case 'timing': {
|
|
668
2011
|
const id=pos[1];
|
|
669
|
-
|
|
2012
|
+
{ const e=idErr(id,posq[1],'timing needs an id'); if(e){ err(n,e); break; } }
|
|
2013
|
+
if(dupId(id)){ err(n,'duplicate id "'+id+'"'); break; }
|
|
2014
|
+
if(BLK_LBL(n,kw,id,pos,posq)) break;
|
|
670
2015
|
if(pos.length>3){ err(n,'unexpected argument "'+pos[3]+'"'); break; }
|
|
671
|
-
|
|
2016
|
+
blockIds.add(id);
|
|
2017
|
+
cur={type:'timing',id,label:pos[2]!==undefined?pos[2]:null,signals:[],gaps:[],
|
|
2018
|
+
fill:opts.fill,stroke:opts.stroke,
|
|
2019
|
+
cls:parseClassList(opts['class'],optList(optT,'class')).ids,line:n};
|
|
672
2020
|
doc.blocks.push(cur); break;
|
|
673
2021
|
}
|
|
674
|
-
|
|
2022
|
+
// `step` was released: the §6 dynamic sketch uses
|
|
2023
|
+
// `page`/`set`/`pulse` only, and no genre claimed the word.
|
|
2024
|
+
case 'page': case 'set': case 'pulse':
|
|
675
2025
|
err(n,'"'+kw+'" is reserved for the dynamic profile (not in v0.1)'); break;
|
|
676
|
-
case 'field': case '
|
|
677
|
-
err(n,'"'+kw+'" is a typed-block child — it needs a bitfield/table/
|
|
2026
|
+
case 'field': case 'break': case 'cell': case 'width': case 'signal': case 'gap':
|
|
2027
|
+
err(n,'"'+kw+'" is a typed-block child — it needs a bitfield/table/timing block above it'); break;
|
|
678
2028
|
default:
|
|
679
2029
|
err(n,'unrecognized line (unknown keyword "'+kw+'")');
|
|
680
2030
|
}
|
|
@@ -684,25 +2034,26 @@ function parse(text){
|
|
|
684
2034
|
// semantic checks
|
|
685
2035
|
for(const nd of doc.nodes){
|
|
686
2036
|
if(nd.group && !groupIds.has(nd.group)) errs.push('Line '+nd.line+': unknown group "'+nd.group+'"');
|
|
687
|
-
if(nd.
|
|
2037
|
+
if(nd.plane && !planeIds.has(nd.plane)) errs.push('Line '+nd.line+': unknown plane "'+nd.plane+'"');
|
|
688
2038
|
}
|
|
689
2039
|
for(const g of doc.groups){
|
|
690
|
-
if(g.
|
|
2040
|
+
if(g.plane && !planeIds.has(g.plane)) errs.push('Line '+g.line+': unknown plane "'+g.plane+'"');
|
|
691
2041
|
}
|
|
692
2042
|
for(const e of doc.edges){
|
|
693
2043
|
if(!nodeIds.has(e.a)&&!groupIds.has(e.a)&&!boundaryIds.has(e.a)) errs.push('Line '+e.line+': unknown endpoint "'+e.a+'"');
|
|
694
2044
|
else if(groupIds.has(e.a)) errs.push('Line '+e.line+': edge endpoint "'+e.a+'" is a group — connect to a member node (group edges are not in v0.1)');
|
|
695
2045
|
if(!nodeIds.has(e.b)&&!groupIds.has(e.b)&&!boundaryIds.has(e.b)) errs.push('Line '+e.line+': unknown endpoint "'+e.b+'"');
|
|
696
2046
|
else if(groupIds.has(e.b)) errs.push('Line '+e.line+': edge endpoint "'+e.b+'" is a group — connect to a member node (group edges are not in v0.1)');
|
|
697
|
-
if(e.
|
|
2047
|
+
if(e.plane && !planeIds.has(e.plane)) errs.push('Line '+e.line+': unknown plane "'+e.plane+'"');
|
|
698
2048
|
}
|
|
699
2049
|
for(const r of doc.ranks) for(const id of r.ids)
|
|
700
2050
|
if(!nodeIds.has(id)) errs.push('Line '+r.line+': unknown node "'+id+'" in rank');
|
|
701
|
-
for(const gl of doc.
|
|
702
|
-
if(!groupIds.has(gl.
|
|
703
|
-
|
|
2051
|
+
for(const gl of doc.thresholds)
|
|
2052
|
+
if(!groupIds.has(gl.target)&&!nodeIds.has(gl.target))
|
|
2053
|
+
errs.push('Line '+gl.line+': unknown target "'+gl.target+'" for threshold');
|
|
2054
|
+
for(const f of doc.bands)
|
|
704
2055
|
if(!groupIds.has(f.target)&&!nodeIds.has(f.target))
|
|
705
|
-
errs.push('Line '+f.line+': unknown target "'+f.target+'" for
|
|
2056
|
+
errs.push('Line '+f.line+': unknown target "'+f.target+'" for band');
|
|
706
2057
|
for(const t of doc.trunks) for(const [a,b] of t.pairs){
|
|
707
2058
|
if((!nodeIds.has(a)&&!boundaryIds.has(a))||(!nodeIds.has(b)&&!boundaryIds.has(b))){ errs.push('Line '+t.line+': unknown endpoint in "'+a+'--'+b+'"'); continue; }
|
|
708
2059
|
const matches=doc.edges.filter(e=>(e.a===a&&e.b===b)||(e.a===b&&e.b===a)).length;
|
|
@@ -711,51 +2062,156 @@ function parse(text){
|
|
|
711
2062
|
else if(matches>1)
|
|
712
2063
|
errs.push('Line '+t.line+': "'+a+'--'+b+'" is ambiguous ('+matches+' parallel edges); parallel edges are out of scope for v0.1');
|
|
713
2064
|
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
2065
|
+
// `ELEMENT-GEOMETRY-DIRECTIVE`: `pin` has a SPLIT DOMAIN, and both halves are checked
|
|
2066
|
+
// here because both need the finished id sets.
|
|
2067
|
+
// at= nodes, groups and external endpoints;
|
|
2068
|
+
// width=/height= nodes ONLY — a group sizes to its members (D2 reached
|
|
2069
|
+
// the same conclusion independently), and an external
|
|
2070
|
+
// endpoint or a typed block derives its geometry from
|
|
2071
|
+
// its content, so an extent there is a claim the
|
|
2072
|
+
// renderer cannot honour. Silently ignoring it was the
|
|
2073
|
+
// 0.1 defect; each now names its own subject.
|
|
2074
|
+
for(const id in doc.pins){
|
|
2075
|
+
const p=doc.pins[id];
|
|
2076
|
+
if(p.w!==null||p.h!==null){
|
|
2077
|
+
if(groupIds.has(id)){
|
|
2078
|
+
errs.push('Line '+p.line+': pin width=/height= do not apply to group "'+id+'" — groups size to their members'); continue; }
|
|
2079
|
+
if(boundaryIds.has(id)){
|
|
2080
|
+
errs.push('Line '+p.line+': pin width=/height= do not apply to external endpoint "'+id+'" — its geometry derives from its content'); continue; }
|
|
2081
|
+
const blk=doc.blocks.find(b=>b.id===id);
|
|
2082
|
+
if(blk){
|
|
2083
|
+
errs.push('Line '+p.line+': pin width=/height= do not apply to the '+blk.type+' block "'+id+'" — its geometry derives from its content'); continue; }
|
|
2084
|
+
}
|
|
2085
|
+
if(!nodeIds.has(id)&&!groupIds.has(id)&&!boundaryIds.has(id))
|
|
2086
|
+
errs.push('Line '+p.line+': pin of unknown id "'+id+'"');
|
|
2087
|
+
}
|
|
2088
|
+
{ // 0.1 (§8.4), the class-mediated half. Rejecting `fill=` on the
|
|
2089
|
+
// `edge` LINE is not enough on its own: before this release the edge
|
|
2090
|
+
// renderer read `e.stroke||e.fill`, so a class carrying only `fill=`
|
|
2091
|
+
// painted edges through the fill channel, and `fill=` would have gone on
|
|
2092
|
+
// meaning "an edge's colour" by the back door.
|
|
2093
|
+
//
|
|
2094
|
+
// A class is a bundle of channel defaults for HETEROGENEOUS members, so
|
|
2095
|
+
// the rule is per channel, not per class: `fill` applies to members that
|
|
2096
|
+
// HAVE an interior and is inapplicable to those that do not. One class
|
|
2097
|
+
// can therefore still carry one meaning for both — `class hot "…"
|
|
2098
|
+
// fill=#fee2e2 stroke=#dc2626` paints the box of a node member and the
|
|
2099
|
+
// line of an edge member, with no key meaning two things and no second
|
|
2100
|
+
// legend entry for one meaning (`CATEGORICAL-MEANING-MAPPING`).
|
|
2101
|
+
//
|
|
2102
|
+
// What is forbidden is the case that would be SILENT: a class that sets
|
|
2103
|
+
// `fill=` and no `stroke=`, used by an edge. Ignoring it would drop the
|
|
2104
|
+
// edge's colour with nothing to warn on; honouring it would make `fill`
|
|
2105
|
+
// mean "stroke" for that member. So it is a line error that names the
|
|
2106
|
+
// key to add. `edge` is the only interior-less construct taking `class=`.
|
|
2107
|
+
//
|
|
2108
|
+
// 0.1 (`CLASS-PAINT-REQUIREMENT`): the SAME hole sat one key over and was left open.
|
|
2109
|
+
// 0.1 rejected `fill=`-only and said nothing about a class that
|
|
2110
|
+
// paints NEITHER channel — `class p "Path" color=#dc2626` plus `edge a ->
|
|
2111
|
+
// b class=p` was accepted, drew a #555 line, and rendered a legend swatch
|
|
2112
|
+
// that showed nothing, so the class's meaning was invisible in its own
|
|
2113
|
+
// derived legend. With `color=` retired (`COLOUR-KEY-STATUS`) the remaining shape of the
|
|
2114
|
+
// hole is a class carrying only `style=` and/or `plane=`, or nothing at
|
|
2115
|
+
// all: the edge silently takes the default colour and the author who
|
|
2116
|
+
// declared a class to CLASSIFY the edge gets no colour and no warning.
|
|
2117
|
+
// Both halves are the same rule — a class an edge joins must declare at
|
|
2118
|
+
// least one channel an edge HAS — so they share one diagnostic shape.
|
|
2119
|
+
// An edge has exactly two: `stroke` (its colour) and `style` (its dash).
|
|
2120
|
+
// `style=`-only is therefore FINE and must stay fine: the dash reaches the
|
|
2121
|
+
// edge, nothing is lost, and it is how a multi-class cascade splits one
|
|
2122
|
+
// meaning across two declarations (`class=hot,deprecated`). The test is
|
|
2123
|
+
// per class, per channel — the same shape `INTERIOR-LESS-ELEMENT-PAINT` chose, for the same reason.
|
|
2124
|
+
for(const e of doc.edges) for(const cid of (e.cls||[])){
|
|
2125
|
+
const c=doc.classes.find(x=>x.id===cid);
|
|
2126
|
+
if(!c||c.stroke!==undefined) continue;
|
|
2127
|
+
if(c.fill===undefined&&c.style!==undefined) continue;
|
|
2128
|
+
if(c.fill!==undefined)
|
|
2129
|
+
errs.push('Line '+e.line+': class "'+cid+'" sets fill= but no stroke=, and an edge has no interior — add stroke= to the class (it paints the edge; fill= keeps painting members that have an interior) (MIGRATIONS)');
|
|
2130
|
+
else
|
|
2131
|
+
errs.push('Line '+e.line+': class "'+cid+'" declares no channel an edge has — add stroke= (an edge has only stroke= and style=: no interior, and v0.1 has no label-colour key). Without one the edge takes the default colour and the class shows nothing in the legend (MIGRATIONS)');
|
|
726
2132
|
}
|
|
727
2133
|
}
|
|
728
|
-
for(const id in doc.pins) if(!nodeIds.has(id)&&!groupIds.has(id)&&!boundaryIds.has(id))
|
|
729
|
-
errs.push('Line '+doc.pins[id].line+': pin of unknown id "'+id+'"');
|
|
730
2134
|
{ // class references must resolve (closed grammar)
|
|
731
|
-
const chk=(cls,line)=>{
|
|
732
|
-
|
|
2135
|
+
const chk=(cls,line)=>{
|
|
2136
|
+
if(cls===undefined||cls===null) return;
|
|
2137
|
+
const ids=Array.isArray(cls)?cls:[cls];
|
|
2138
|
+
for(const id of ids) if(!classIds.has(id))
|
|
2139
|
+
errs.push('Line '+line+': unknown class "'+id+'"');
|
|
2140
|
+
};
|
|
733
2141
|
for(const x of doc.nodes) chk(x.cls,x.line);
|
|
734
2142
|
for(const x of doc.groups) chk(x.cls,x.line);
|
|
735
2143
|
for(const x of doc.edges) chk(x.cls,x.line);
|
|
736
2144
|
for(const b of doc.blocks){
|
|
737
2145
|
if(b.fields) for(const f of b.fields) chk(f.cls,f.line);
|
|
738
2146
|
if(b.marks) for(const mk of b.marks) chk(mk.cls,mk.line);
|
|
2147
|
+
chk(b.cls,b.line);
|
|
739
2148
|
}
|
|
740
2149
|
}
|
|
741
|
-
for(const id in doc.sizes) if(!nodeIds.has(id)&&!groupIds.has(id))
|
|
742
|
-
errs.push('Line '+doc.sizes[id].line+': size of unknown id "'+id+'"');
|
|
743
2150
|
for(const b of doc.blocks){
|
|
744
2151
|
if(b.type==='table'&&!b.heads.length) errs.push('Line '+b.line+': table has no header row');
|
|
745
|
-
if(b.type==='table'&&b.heads.length&&!b.sep) errs.push('Line '+b.line+': table has no |---|
|
|
2152
|
+
if(b.type==='table'&&b.heads.length&&!b.sep) errs.push('Line '+b.line+': table has no |---| delimiter row');
|
|
746
2153
|
if(b.type==='bitfield'&&!b.fields.some(f=>!f.wrap)) errs.push('Line '+b.line+': bitfield has no fields');
|
|
747
|
-
if(b.type==='
|
|
748
|
-
|
|
2154
|
+
if(b.type==='timing'&&!b.signals.length) errs.push('Line '+b.line+': timing has no signals');
|
|
2155
|
+
// `TYPED-BLOCK-SILENT-FALLBACK`: gap cycle must be an integer in [0, max lane length]; duplicates
|
|
2156
|
+
// already rejected at parse. Range needs all signals, so it runs here.
|
|
2157
|
+
if(b.type==='timing'&&b.gaps&&b.gaps.length){
|
|
2158
|
+
const maxLen=b.signals.reduce((m,s)=>Math.max(m,s.lane.length),0);
|
|
2159
|
+
for(const g of b.gaps){
|
|
2160
|
+
if(g.t>maxLen)
|
|
2161
|
+
errs.push('Line '+g.line+': gap cycle '+g.t+' is past the end of the timing lane (length '+maxLen+')');
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
// `TYPED-BLOCK-SILENT-FALLBACK`: table merges must form rectangles. An L-shape (^^ next to ||
|
|
2165
|
+
// in a way the model cannot describe) is a line error. Prior art
|
|
2166
|
+
// (markdown-it-multimd-table) rejects the same class of input.
|
|
2167
|
+
if(b.type==='table'&&(b.heads.length||b.rows.length)){
|
|
2168
|
+
const grid=b.heads.map(hr=>hr.map(c=>({m:c.m,line:b.line})))
|
|
2169
|
+
.concat(b.rows.map(r=>r.cells.map(c=>({m:c.m,line:r.line}))));
|
|
2170
|
+
const covered=grid.map(row=>row.map(()=>false));
|
|
2171
|
+
let mergeErr=null;
|
|
2172
|
+
for(let r=0;r<grid.length&&!mergeErr;r++){
|
|
2173
|
+
for(let c=0;c<grid[r].length&&!mergeErr;c++){
|
|
2174
|
+
if(grid[r][c].m) continue;
|
|
2175
|
+
let cs=1; while(c+cs<grid[r].length&&grid[r][c+cs].m==='left') cs++;
|
|
2176
|
+
let rs=1; while(r+rs<grid.length&&grid[r+rs][c].m==='up') rs++;
|
|
2177
|
+
for(let i=0;i<rs&&!mergeErr;i++){
|
|
2178
|
+
for(let j=0;j<cs&&!mergeErr;j++){
|
|
2179
|
+
if(i===0&&j===0){ covered[r][c]=true; continue; }
|
|
2180
|
+
const cell=grid[r+i][c+j];
|
|
2181
|
+
if(!cell.m){
|
|
2182
|
+
mergeErr={line:cell.line,msg:'merge region is not rectangular (L-shaped or overlapping spans)'};
|
|
2183
|
+
} else {
|
|
2184
|
+
if(i===0&&cell.m!=='left')
|
|
2185
|
+
mergeErr={line:cell.line,msg:'merge region is not rectangular (L-shaped or overlapping spans)'};
|
|
2186
|
+
else if(j===0&&cell.m!=='up')
|
|
2187
|
+
mergeErr={line:cell.line,msg:'merge region is not rectangular (L-shaped or overlapping spans)'};
|
|
2188
|
+
else covered[r+i][c+j]=true;
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
if(!mergeErr){
|
|
2195
|
+
for(let r=0;r<grid.length&&!mergeErr;r++){
|
|
2196
|
+
for(let c=0;c<grid[r].length&&!mergeErr;c++){
|
|
2197
|
+
if(grid[r][c].m&&!covered[r][c])
|
|
2198
|
+
mergeErr={line:grid[r][c].line,msg:'merge marker does not attach to a rectangular span'};
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
if(mergeErr) errs.push('Line '+mergeErr.line+': '+mergeErr.msg);
|
|
2203
|
+
}
|
|
2204
|
+
if(b.type==='chart'){
|
|
749
2205
|
const t=doc.blocks.find(x=>x.type==='table'&&x.id===b.tid);
|
|
750
|
-
if(!t) errs.push('Line '+b.line+':
|
|
2206
|
+
if(!t) errs.push('Line '+b.line+': chart references unknown table "'+b.tid+'"');
|
|
751
2207
|
else{
|
|
752
2208
|
for(const r of t.rows) for(let c=1;c<r.cells.length;c++)
|
|
753
2209
|
if(!r.cells[c].m && !/^-?\d+(?:\.\d+)?$/.test(r.cells[c].v))
|
|
754
|
-
{ errs.push('Line '+b.line+':
|
|
2210
|
+
{ errs.push('Line '+b.line+': chart data must be numeric (row value "'+r.cells[c].v+'")'); break; }
|
|
755
2211
|
}
|
|
756
2212
|
}
|
|
757
|
-
if(b.type==='table'&&b.
|
|
758
|
-
errs.push('Line '+b.
|
|
2213
|
+
if(b.type==='table'&&b.width&&b.width.vals.length!==b.cols.length)
|
|
2214
|
+
errs.push('Line '+b.width.line+': width has '+b.width.vals.length+' values, expected '+b.cols.length);
|
|
759
2215
|
if(b.type==='table'&&b.marks) for(const mk of b.marks){
|
|
760
2216
|
const H=b.heads.length;
|
|
761
2217
|
const inRange = mk.hdr ? (mk.r>=1&&mk.r<=H) : (mk.r>=1&&mk.r<=b.rows.length);
|
|
@@ -771,16 +2227,121 @@ function parse(text){
|
|
|
771
2227
|
if(mk.r<1||mk.r>b.rows.length)
|
|
772
2228
|
errs.push('Line '+mk.line+': row '+mk.r+' out of range');
|
|
773
2229
|
}
|
|
2230
|
+
// `ROW-HIGHLIGHT-CELL-FILL-COLLISION`, the SECOND half of the collision: the two marks
|
|
2231
|
+
// written on two lines. `cell 1 highlight` + `cell (1,2) fill=#ffffff`
|
|
2232
|
+
// both parsed, and the per-cell fill won at paint time, so the model
|
|
2233
|
+
// asserted "row 1 is highlighted" while the drawing tinted only part of
|
|
2234
|
+
// the row — presentation deleting the visual carrier of meaning, which is
|
|
2235
|
+
// exactly the `field … style=solid` shape `STYLE-KEY-SCOPE` removed `style=` for. Only
|
|
2236
|
+
// a mark that RESOLVES to a fill collides: `stroke=`/`style=` paint a
|
|
2237
|
+
// different channel and stay legal on a highlighted row.
|
|
2238
|
+
if(b.type==='table'&&b.marks&&b.rowmarks&&b.rowmarks.length) for(const mk of b.marks){
|
|
2239
|
+
if(mk.hdr||!b.rowmarks.some(r=>r.r===mk.r)) continue;
|
|
2240
|
+
const fills=mk.fill!==undefined
|
|
2241
|
+
|| (mk.cls||[]).some(id=>{const c=doc.classes.find(x=>x.id===id); return c&&c.fill!==undefined;});
|
|
2242
|
+
if(fills) errs.push('Line '+mk.line+': '+CELL_HL_ROW_CONFLICT(mk.r,mk.c));
|
|
2243
|
+
}
|
|
774
2244
|
}
|
|
2245
|
+
// §5 `plane=` resolves against the declared planes wherever it is written —
|
|
2246
|
+
// the rule nodes/groups/edges have always had, now applied uniformly.
|
|
2247
|
+
// Declaration order does not matter (the check runs after the whole pass).
|
|
2248
|
+
for(const x of (doc.boundaries||[]).concat(doc.trunks||[], doc.thresholds||[],
|
|
2249
|
+
doc.bands||[], doc.classes||[]))
|
|
2250
|
+
if(x.plane && !planeIds.has(x.plane)) errs.push('Line '+x.line+': unknown plane "'+x.plane+'"');
|
|
775
2251
|
return {doc,errs};
|
|
776
2252
|
}
|
|
2253
|
+
|
|
2254
|
+
// Stack several single-section render results into ONE svg (multi-section `MULTI-FIGURE-DOCUMENTS`).
|
|
2255
|
+
// Each section is an independent figure; no shared ids or cross edges.
|
|
2256
|
+
// Marker/pattern ids are prefixed so defs do not collide.
|
|
2257
|
+
function stackSectionSvgs(results){
|
|
2258
|
+
const GAP=28;
|
|
2259
|
+
let y=0, maxW=0;
|
|
2260
|
+
const chunks=[];
|
|
2261
|
+
for(let i=0;i<results.length;i++){
|
|
2262
|
+
const svg=results[i].svg;
|
|
2263
|
+
const vb=/viewBox="0 0 ([0-9.]+) ([0-9.]+)"/.exec(svg);
|
|
2264
|
+
const W=vb?+vb[1]:400, H=vb?+vb[2]:200;
|
|
2265
|
+
maxW=Math.max(maxW,W);
|
|
2266
|
+
let inner=svg.replace(/^[\s\S]*?<svg[^>]*>/,'').replace(/<\/svg>\s*$/,'');
|
|
2267
|
+
const pfx='s'+i+'_';
|
|
2268
|
+
inner=inner.replace(/\bid="([^"]+)"/g,'id="'+pfx+'$1"')
|
|
2269
|
+
.replace(/url\(#([^)]+)\)/g,'url(#'+pfx+'$1)');
|
|
2270
|
+
chunks.push('<g transform="translate(0,'+y+')">'+inner+'</g>');
|
|
2271
|
+
y+=H+(i+1<results.length?GAP:0);
|
|
2272
|
+
}
|
|
2273
|
+
if(!chunks.length) y=1;
|
|
2274
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 '+Math.ceil(maxW)+' '+Math.ceil(y)+'" width="'+Math.ceil(maxW)+'" height="'+Math.ceil(y)+'" font-family="system-ui,sans-serif">'
|
|
2275
|
+
+chunks.join('')+'</svg>';
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// `SEMICOLON-STATUS`: `;` is RESERVED for a future statement separator, and
|
|
2279
|
+
// RULE 1.3 says a reserved mark MUST NOT be given any other meaning. Until
|
|
2280
|
+
// this release it was an ordinary character: `node a ;` parsed and `;`
|
|
2281
|
+
// became the node's LABEL. It is now a line error wherever it is part of the
|
|
2282
|
+
// GRAMMAR — that is, everywhere except the FOUR verbatim regions (this
|
|
2283
|
+
// function handles three of them; the pipe row is the caller's, see below).
|
|
2284
|
+
// The count read "three" until this release, listing four:
|
|
2285
|
+
// - inside a quoted string ("…;…"),
|
|
2286
|
+
// - inside a comment (already stripped before this runs),
|
|
2287
|
+
// - inside an edge label (edge a -[packet arrives; TMR != 0]-> b),
|
|
2288
|
+
// - inside a GFM pipe row (raw table content; checked by the caller).
|
|
2289
|
+
// The bracket exemption is NOT optional: `[ ]` is the language's verbatim
|
|
2290
|
+
// text region, and three downstream figures write a `;` inside an edge label
|
|
2291
|
+
// as ordinary prose. Returns the index of the offending `;`, or -1.
|
|
2292
|
+
function findReservedSemi(s){
|
|
2293
|
+
let inq=false, depth=0;
|
|
2294
|
+
for(let i=0;i<s.length;i++){
|
|
2295
|
+
const c=s[i];
|
|
2296
|
+
if(inq){
|
|
2297
|
+
if(c==='\\'&&i+1<s.length){ i++; continue; }
|
|
2298
|
+
if(c==='"') inq=false;
|
|
2299
|
+
continue;
|
|
2300
|
+
}
|
|
2301
|
+
if(c==='"'){ inq=true; continue; }
|
|
2302
|
+
if(c==='['){ depth++; continue; }
|
|
2303
|
+
if(c===']'){ if(depth) depth--; continue; }
|
|
2304
|
+
if(c===';'&&!depth) return i;
|
|
2305
|
+
}
|
|
2306
|
+
return -1;
|
|
2307
|
+
}
|
|
2308
|
+
const RESERVED_SEMI='";" is reserved for a future statement separator and has no meaning in v0.1 — write one directive per line; a literal ";" belongs inside a quoted string, an [edge label] or a comment (MIGRATIONS)';
|
|
2309
|
+
|
|
777
2310
|
function findComment(s){
|
|
778
2311
|
// '#' starts a comment only at line start or after whitespace,
|
|
779
|
-
// so hex colors like
|
|
780
|
-
|
|
2312
|
+
// so hex colors like fill=#0d9488 survive.
|
|
2313
|
+
// 0.1: the in-string state honours the SAME escapes the tokenizer
|
|
2314
|
+
// does (\n \" \\). Without this, `title "a \" b # c"` toggled `inq` on the
|
|
2315
|
+
// escaped quote, read the `#` as a comment introducer, cut the line there
|
|
2316
|
+
// and reported `unterminated string` for a perfectly legal input line.
|
|
2317
|
+
//
|
|
2318
|
+
// `VERBATIM-REGION-SCOPE`: `[ ]` IS A VERBATIM REGION FOR `#` TOO. SYNTAX-STYLE
|
|
2319
|
+
// §1 names four verbatim regions — a quoted string, an `[edge label]`, a
|
|
2320
|
+
// comment and a GFM pipe row — and `;` was honoured in all four from
|
|
2321
|
+
// 0.1 (`SEMICOLON-STATUS`, findReservedSemi below carries the same `depth`
|
|
2322
|
+
// counter). `#` was honoured in three: comment stripping ran before edge
|
|
2323
|
+
// dispatch and knew nothing about brackets, so `edge a -[hop #1]-> b` was
|
|
2324
|
+
// cut at the `#` and reported `unterminated [label]`. §1's own
|
|
2325
|
+
// justification for giving `[ ]` a row is this exact bug class, met and
|
|
2326
|
+
// fixed once for `;`; the lesson was simply not carried to the other mark,
|
|
2327
|
+
// and `#1`/`#2` is the natural spelling for hop and sequence numbers in
|
|
2328
|
+
// the protocol figures this language targets.
|
|
2329
|
+
//
|
|
2330
|
+
// The counter is safe to run on EVERY line, not just `edge` lines: it can
|
|
2331
|
+
// only affect a `#` that appears AFTER an unclosed `[`, and this function
|
|
2332
|
+
// returns at the first `#` outside brackets — so a `[` inside a comment is
|
|
2333
|
+
// always reached too late to matter, and `[` occurs nowhere else in the
|
|
2334
|
+
// grammar outside a quoted string. A stray unbalanced `[` before a `#` on
|
|
2335
|
+
// a non-edge line therefore swallows the comment marker and the line fails
|
|
2336
|
+
// as the malformed line it is, rather than half-parsing.
|
|
2337
|
+
let inq=false, depth=0;
|
|
781
2338
|
for(let i=0;i<s.length;i++){
|
|
2339
|
+
if(inq&&s[i]==='\\'&&i+1<s.length){ i++; continue; }
|
|
782
2340
|
if(s[i]==='"') inq=!inq;
|
|
783
|
-
else if(
|
|
2341
|
+
else if(inq) continue;
|
|
2342
|
+
else if(s[i]==='[') depth++;
|
|
2343
|
+
else if(s[i]===']'){ if(depth) depth--; }
|
|
2344
|
+
else if(s[i]==='#'&&!depth&&(i===0||/\s/.test(s[i-1]))) return i;
|
|
784
2345
|
}
|
|
785
2346
|
return -1;
|
|
786
2347
|
}
|
|
@@ -789,6 +2350,13 @@ function findComment(s){
|
|
|
789
2350
|
// 2. LAYOUT + RENDER (deterministic; no randomness, no Date)
|
|
790
2351
|
// ============================================================
|
|
791
2352
|
const FONT=13, CH=7.2, PADX=14, NH=36, GAPX=56, GAPY=44;
|
|
2353
|
+
// §5 style= → SVG dash pattern. `def` is the construct's conventional
|
|
2354
|
+
// default (the bundle ring and the threshold line are dashed by convention);
|
|
2355
|
+
// an explicit style= always wins.
|
|
2356
|
+
function dashOf(style,def){
|
|
2357
|
+
const p = style==='dashed'?'6 4' : style==='dotted'?'2 4' : style==='solid'?'' : def;
|
|
2358
|
+
return p?' stroke-dasharray="'+p+'"':'';
|
|
2359
|
+
}
|
|
792
2360
|
function esc(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
793
2361
|
function tw(s){
|
|
794
2362
|
const longest=Math.max(...String(s).split('\n').map(l=>l.length));
|
|
@@ -800,19 +2368,20 @@ function tw(s){
|
|
|
800
2368
|
// half-extents (a,b) about the box centre and the exponent p of the curve
|
|
801
2369
|
// through them,
|
|
802
2370
|
// (|dx|/a)^p + (|dy|/b)^p = 1 p=1 rhombus, 2 ellipse, Inf rectangle
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
//
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
2371
|
+
// Every shape is drawn INSIDE its box, so the box is recoverable from the
|
|
2372
|
+
// axes with no overhang term. (`cloud` was the one exception — it drew an
|
|
2373
|
+
// ellipse 10/8 px outside the box, and carried an (ox,oy) overhang through
|
|
2374
|
+
// this helper, outSide() and fitOutline(); it was retired.)
|
|
2375
|
+
// Node sizing (how large must the box be for the label to sit inside the
|
|
2376
|
+
// outline) and borderPoint() (where does a ray from the centre leave the
|
|
2377
|
+
// outline) both read this, so the two can never disagree about where a shape
|
|
2378
|
+
// ends. Keep it in step with the shape drawing in renderScene.
|
|
809
2379
|
function shapeAxes(n){
|
|
810
2380
|
const s=n.shape;
|
|
811
|
-
if(s==='diamond') return {a:n.w/2, b:n.h/2, p:1
|
|
812
|
-
if(s==='
|
|
813
|
-
if(s==='
|
|
814
|
-
|
|
815
|
-
return {a:n.w/2, b:n.h/2, p:Infinity, ox:0, oy:0}; // box, rounded, cylinder
|
|
2381
|
+
if(s==='diamond') return {a:n.w/2, b:n.h/2, p:1};
|
|
2382
|
+
if(s==='circle') return {a:n.w/2, b:n.w/2, p:2}; // rx=ry=w/2
|
|
2383
|
+
if(s==='ellipse') return {a:n.w/2, b:n.h/2, p:2};
|
|
2384
|
+
return {a:n.w/2, b:n.h/2, p:Infinity}; // box, rounded, cylinder
|
|
816
2385
|
}
|
|
817
2386
|
// outlineNorm: <1 inside the outline, =1 on it, >1 outside. Homogeneous in
|
|
818
2387
|
// (dx,dy) — scaling the offset scales the norm — which is what lets the same
|
|
@@ -824,11 +2393,12 @@ function outlineNorm(g,dx,dy){
|
|
|
824
2393
|
}
|
|
825
2394
|
// outSide: the coordinate an orthogonal run has to reach to touch the DRAWN
|
|
826
2395
|
// outline on one side ('l','r','t','b') — what a channel route needs when it
|
|
827
|
-
// arrives square-on instead of along a ray. Every shape
|
|
828
|
-
// inside its box, so this
|
|
2396
|
+
// arrives square-on instead of along a ray. Every registered shape is drawn
|
|
2397
|
+
// inside its box, so this is the box edge; the call site is kept so a future
|
|
2398
|
+
// outside-the-box outline has one place to answer for (`cloud`, the only one
|
|
2399
|
+
// that ever was, is retired — 0.1).
|
|
829
2400
|
function outSide(n,k){
|
|
830
|
-
|
|
831
|
-
return k==='l'?n.x-g.ox : k==='r'?n.x+n.w+g.ox : k==='t'?n.y-g.oy : n.y+n.h+g.oy;
|
|
2401
|
+
return k==='l'?n.x : k==='r'?n.x+n.w : k==='t'?n.y : n.y+n.h;
|
|
832
2402
|
}
|
|
833
2403
|
// simplifyPts: Douglas–Peucker simplification in place. The waypoint chain
|
|
834
2404
|
// emits an entry+exit port per crossed rank; home-anchoring keeps those ports
|
|
@@ -919,8 +2489,8 @@ function labelBox(label){
|
|
|
919
2489
|
function fitOutline(n,box){
|
|
920
2490
|
const g=shapeAxes(n), k=outlineNorm(g,box[0]/2,box[1]/2);
|
|
921
2491
|
if(k<=1) return;
|
|
922
|
-
n.w=Math.max(n.w,2*
|
|
923
|
-
n.h=Math.max(n.h,2*
|
|
2492
|
+
n.w=Math.max(n.w,2*k*g.a);
|
|
2493
|
+
n.h=Math.max(n.h,2*k*g.b);
|
|
924
2494
|
if(n.shape==='circle') n.w=n.h=Math.max(n.w,n.h); // rx=ry=w/2: box stays square
|
|
925
2495
|
}
|
|
926
2496
|
// multi-line <text>: "\n" in labels becomes centered tspans
|
|
@@ -943,19 +2513,60 @@ function textEl(x,y,fs,anchor,fill,content,extraAttrs){
|
|
|
943
2513
|
|
|
944
2514
|
function render(doc,ropts){
|
|
945
2515
|
// presentation options (renderer tier, not language): {title:true}
|
|
946
|
-
// draws the title. Default is NOT drawn (
|
|
2516
|
+
// draws the title. Default is NOT drawn (`DEFAULT-VALUE-SELECTION`: embedded figures almost
|
|
947
2517
|
// always sit under a caption in the host document; the title text
|
|
948
2518
|
// stays semantic in the source either way).
|
|
949
2519
|
const RO=ropts||{};
|
|
950
|
-
//
|
|
2520
|
+
// `OMITTED-LABEL-RECORDING` display fallback: the semantic model records an omitted label as
|
|
2521
|
+
// absent (null); the RENDERER — never the model — substitutes the id, so a
|
|
2522
|
+
// label-less `node a` still draws "a". This is the whole of the difference
|
|
2523
|
+
// between the model and the figure, and it lives here, in one place.
|
|
2524
|
+
// The element is COPIED rather than patched: a caller that parses, renders
|
|
2525
|
+
// and then reads its own `doc` must still see the absence it was given.
|
|
2526
|
+
// `EMPTY-LABEL-STATE`: the test is ABSENCE, not truthiness. An explicitly empty label
|
|
2527
|
+
// (`node a ""`) is an authorial act — the figure draws no text at all. The
|
|
2528
|
+
// id is an internal handle, never authored display text, so substituting it
|
|
2529
|
+
// would put words into a figure whose source has none.
|
|
2530
|
+
const lbl=(x)=>(x.label===null||x.label===undefined)?Object.assign({},x,{label:x.id}):x;
|
|
2531
|
+
doc=Object.assign({},doc,{
|
|
2532
|
+
nodes : doc.nodes.map(lbl),
|
|
2533
|
+
groups: doc.groups.map(lbl),
|
|
2534
|
+
trunks: (doc.trunks||[]).map(lbl),
|
|
2535
|
+
blocks: doc.blocks.map(b=>b.type==='chart'?b:lbl(b)),
|
|
2536
|
+
});
|
|
2537
|
+
// resolve class defaults (explicit element attributes win — rigidity, `LAYOUT-STABILITY`).
|
|
2538
|
+
// class= is multi-valued: ids apply left-to-right, later wins per channel
|
|
2539
|
+
// (same cascade as CSS multi-class). Single-id documents are unchanged.
|
|
951
2540
|
const C={}; for(const c of doc.classes||[]) C[c.id]=c;
|
|
952
|
-
const
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
2541
|
+
const classIdsOf=(x)=>{
|
|
2542
|
+
if(x.cls===undefined||x.cls===null) return [];
|
|
2543
|
+
return Array.isArray(x.cls)?x.cls:[x.cls];
|
|
2544
|
+
};
|
|
2545
|
+
const rs=(x,k)=>{
|
|
2546
|
+
if(x[k]!==undefined) return;
|
|
2547
|
+
for(const id of classIdsOf(x)){
|
|
2548
|
+
if(C[id] && C[id][k]!==undefined) x[k]=C[id][k];
|
|
2549
|
+
}
|
|
2550
|
+
};
|
|
2551
|
+
// a class carries all five §5 attributes; the element's own value wins (`LAYOUT-STABILITY`).
|
|
2552
|
+
// `plane` is materialized to 'base' at parse time, so "unset" is 'base' here.
|
|
2553
|
+
const rsl=(x)=>{
|
|
2554
|
+
if(!(x.plane===undefined||x.plane===null||x.plane==='base')) return;
|
|
2555
|
+
for(const id of classIdsOf(x)){
|
|
2556
|
+
if(C[id] && C[id].plane!==undefined) x.plane=C[id].plane;
|
|
2557
|
+
}
|
|
2558
|
+
};
|
|
2559
|
+
const rsAll=(x)=>{ rs(x,'fill'); rs(x,'stroke'); rs(x,'style'); rsl(x); };
|
|
2560
|
+
for(const n of doc.nodes){ rsAll(n); if(n.style===undefined) n.style='solid'; }
|
|
2561
|
+
for(const g of doc.groups){ rsAll(g); }
|
|
2562
|
+
// 0.1 (§8.4): an edge has no interior, so it takes every class
|
|
2563
|
+
// channel EXCEPT `fill` — which the parser has already guaranteed is
|
|
2564
|
+
// accompanied by a `stroke` on any class an edge joins.
|
|
2565
|
+
for(const e of doc.edges){ rs(e,'stroke'); rs(e,'style'); rsl(e); if(e.style===undefined) e.style='solid'; }
|
|
956
2566
|
for(const b of doc.blocks){
|
|
957
|
-
|
|
958
|
-
if(b.
|
|
2567
|
+
rsAll(b);
|
|
2568
|
+
if(b.fields) for(const f of b.fields) rsAll(f);
|
|
2569
|
+
if(b.marks) for(const mk of b.marks) rsAll(mk);
|
|
959
2570
|
}
|
|
960
2571
|
const parts=[]; let y=0, maxW=0;
|
|
961
2572
|
if(doc.title && RO.title===true){ parts.push('<text x="0" y="16" font-size="15" font-weight="600">'+esc(doc.title)+'</text>'); y=30;
|
|
@@ -969,19 +2580,41 @@ function render(doc,ropts){
|
|
|
969
2580
|
let s;
|
|
970
2581
|
if(b.type==='bitfield') s=renderBitfield(b,y);
|
|
971
2582
|
else if(b.type==='table') s=renderTable(b,y);
|
|
972
|
-
else if(b.type==='
|
|
973
|
-
else s=
|
|
2583
|
+
else if(b.type==='chart') s=renderChart(b,y,doc);
|
|
2584
|
+
else s=renderTiming(b,y);
|
|
974
2585
|
parts.push(s.svg); y=s.y+24; maxW=Math.max(maxW,s.w);
|
|
975
2586
|
}
|
|
976
|
-
|
|
977
|
-
|
|
2587
|
+
// 0.1 (`CLASS-EMPTY-MEANING`): a class whose meaning is the EMPTY string claims no
|
|
2588
|
+
// meaning, so it has nothing to explain and draws NO legend entry — it is
|
|
2589
|
+
// pure attribute grouping. The filter runs before the `.length` test so a
|
|
2590
|
+
// document whose classes are all meaning-less adds no blank strip and no
|
|
2591
|
+
// vertical space. (Rejected alternative: a `legend=hide` option, which
|
|
2592
|
+
// would have let an author claim a meaning and then hide it — the reader
|
|
2593
|
+
// gets the category from the model while the human sees an unexplained
|
|
2594
|
+
// colour. See `CLASS-EMPTY-MEANING`.)
|
|
2595
|
+
const legendCls=(doc.classes||[]).filter(c=>c.label!=='');
|
|
2596
|
+
if(legendCls.length){
|
|
2597
|
+
// derived legend strip (`CATEGORICAL-MEANING-MAPPING`): declaration order, swatch + meaning
|
|
978
2598
|
const es=[]; let lx=0, ly=y+8; const rowH=20, wrapW=Math.max(maxW,420);
|
|
979
|
-
|
|
980
|
-
|
|
2599
|
+
// 0.1 (`CLASS-PAINT-REQUIREMENT`): the swatch shows the class's DECLARED paint, and a
|
|
2600
|
+
// class that declares none gets no swatch. Before this release the swatch
|
|
2601
|
+
// was drawn unconditionally from `fill`/`stroke` alone, so a class whose
|
|
2602
|
+
// only channel was the (now retired, `COLOUR-KEY-STATUS`) label colour rendered an empty
|
|
2603
|
+
// box — its meaning invisible in the legend the language derives FOR that
|
|
2604
|
+
// meaning — and a class with no paint at all rendered a box
|
|
2605
|
+
// indistinguishable from `fill=white stroke=#555`, which it never wrote.
|
|
2606
|
+
// Retiring `color=` removes the first case; omitting the swatch removes
|
|
2607
|
+
// the second. Every channel a class can still declare (`fill`, `stroke`,
|
|
2608
|
+
// `style`) is drawn, so "declared but not shown" is now unreachable.
|
|
2609
|
+
for(const c of legendCls){
|
|
2610
|
+
const paints=c.fill!==undefined||c.stroke!==undefined||c.style!==undefined;
|
|
2611
|
+
const tw=String(c.label).length*6.6+(paints?30:9);
|
|
981
2612
|
if(lx>0 && lx+tw>wrapW){ lx=0; ly+=rowH; }
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
2613
|
+
if(paints){
|
|
2614
|
+
const dash=c.style==='dashed'?' stroke-dasharray="6 4"':(c.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
2615
|
+
es.push('<rect x="'+lx+'" y="'+(ly+3)+'" width="16" height="11" fill="'+(c.fill||'#fff')+'" stroke="'+(c.stroke||'#555')+'"'+dash+'/>');
|
|
2616
|
+
}
|
|
2617
|
+
es.push('<text x="'+(lx+(paints?21:0))+'" y="'+(ly+12.5)+'" font-size="11" fill="#1d1d1b">'+esc(c.label)+'</text>');
|
|
985
2618
|
lx+=tw+14; maxW=Math.max(maxW,lx);
|
|
986
2619
|
}
|
|
987
2620
|
parts.push(es.join(''));
|
|
@@ -1001,18 +2634,29 @@ function render(doc,ropts){
|
|
|
1001
2634
|
// ---- scene ----
|
|
1002
2635
|
function renderScene(doc,y0){
|
|
1003
2636
|
const nodes=doc.nodes.map(n=>({...n}));
|
|
1004
|
-
//
|
|
2637
|
+
// §2.4 plane z = paint order, applied by every pass that stacks annotations
|
|
2638
|
+
// (edges, bundle rings, threshold lines, zone bands). The sort is stable, so
|
|
2639
|
+
// same-plane items keep document order — "a later line paints on top".
|
|
2640
|
+
const zOf=l=>{const L=doc.planes.find(x=>x.id===l);return L?L.z:0;};
|
|
2641
|
+
const zsort=a=>[...(a||[])].sort((p,q)=>zOf(p.plane)-zOf(q.plane));
|
|
2642
|
+
// a zone band draws an outline when stroke= and/or style= is written
|
|
2643
|
+
const bandEdge=f=>(f.stroke||f.style)?' stroke="'+(f.stroke||'#8a8880')+'"'+dashOf(f.style,''):'';
|
|
2644
|
+
// boundary anchors (`EXTERNAL-EDGE-ENDPOINTS`): external I/O endpoints join the layout as
|
|
1005
2645
|
// small invisible ~12x12 extents so externals land at natural margins;
|
|
1006
2646
|
// they are never drawn as shapes (the edge ends open at the anchor).
|
|
1007
2647
|
for(const b of doc.boundaries||[])
|
|
1008
|
-
nodes.push({id:b.id,label:b.label
|
|
2648
|
+
nodes.push({id:b.id,label:b.label!==null&&b.label!==undefined?b.label:'',boundary:true,plane:b.plane||'base',group:null,
|
|
2649
|
+
line:b.line});
|
|
1009
2650
|
const byId={}; nodes.forEach(n=>byId[n.id]=n);
|
|
1010
2651
|
// sizes
|
|
1011
2652
|
for(const n of nodes){
|
|
1012
2653
|
if(n.boundary){ n.w=n.h=12; continue; }
|
|
1013
2654
|
const nLines=String(n.label).split('\n').length;
|
|
1014
2655
|
n.w=tw(n.label); n.h=NH+(nLines-1)*16;
|
|
1015
|
-
|
|
2656
|
+
// `ELEMENT-GEOMETRY-DIRECTIVE`: the declared extent now rides on the `pin` entry.
|
|
2657
|
+
// A pin that declares only `at=` leaves w/h null and the node auto-sizes,
|
|
2658
|
+
// exactly as an id with no `size` line did before the merge.
|
|
2659
|
+
const s=doc.pins[n.id]; if(s&&(s.w!==null||s.h!==null)){ if(s.w)n.w=s.w; if(s.h)n.h=s.h; n.rigid=true; }
|
|
1016
2660
|
if(n.shape==='diamond'){ n.w=Math.max(n.w+20,70); n.h=Math.max(n.h,48); }
|
|
1017
2661
|
if(n.shape==='circle'){ n.w=n.h=Math.max(n.w,n.h); }
|
|
1018
2662
|
// The box is what the layout reserves; the OUTLINE is what the reader
|
|
@@ -1020,15 +2664,18 @@ function renderScene(doc,y0){
|
|
|
1020
2664
|
// sized as if it were a rectangle lets the label cross the drawn outline
|
|
1021
2665
|
// (a rhombus offers only its inscribed rectangle, ~half the box in each
|
|
1022
2666
|
// direction). Grow the box until the label's text box is inside the
|
|
1023
|
-
// outline. An explicit
|
|
1024
|
-
// its box and the label shrinks to the inscribed width instead (
|
|
2667
|
+
// outline. An explicit extent is the author's word: a rigid node keeps
|
|
2668
|
+
// its box and the label shrinks to the inscribed width instead (`UNDECLARED-ATTRIBUTE-BEHAVIOUR`).
|
|
1025
2669
|
if(!n.rigid) fitOutline(n,labelBox(n.label));
|
|
1026
2670
|
}
|
|
1027
2671
|
// ranks: DFS from document-order sources classifies back-edges; a
|
|
1028
2672
|
// longest-path layering runs on the remaining DAG so sources sit at
|
|
1029
2673
|
// layer 0. `rank a b c` merges its nodes into ONE vertex for the
|
|
1030
2674
|
// layering (same layer) without dragging unrelated branches along.
|
|
1031
|
-
|
|
2675
|
+
// "Pinned" means POSITIONED. Since `ELEMENT-GEOMETRY-DIRECTIVE` a `pin` entry may carry an extent
|
|
2676
|
+
// and no `at=`, and such an element still takes its auto-layout slot — so
|
|
2677
|
+
// the test is on the point, not on the entry's existence.
|
|
2678
|
+
const pinned=id=>doc.pins[id]!==undefined&&doc.pins[id].fx!==null;
|
|
1032
2679
|
nodes.forEach((n,i)=>n.di=i); // document order (sort tiebreak)
|
|
1033
2680
|
const rep={}; nodes.forEach(n=>rep[n.id]=n.id); // rank-group union-find
|
|
1034
2681
|
const find=u=>{ while(rep[u]!==u) u=rep[u]=rep[rep[u]]; return u; };
|
|
@@ -1079,7 +2726,7 @@ function renderScene(doc,y0){
|
|
|
1079
2726
|
const chains=new Map(); // edge -> [A, ...waypoints, B]
|
|
1080
2727
|
for(const e of doc.edges){
|
|
1081
2728
|
const A=byId[e.a], B=byId[e.b];
|
|
1082
|
-
if(!A||!B||isBack.has(e)
|
|
2729
|
+
if(!A||!B||isBack.has(e)) continue;
|
|
1083
2730
|
if(B.rank-A.rank<=1 || pinned(e.a) || pinned(e.b)) continue;
|
|
1084
2731
|
const chain=[A];
|
|
1085
2732
|
const span=B.rank-A.rank;
|
|
@@ -1207,20 +2854,21 @@ function renderScene(doc,y0){
|
|
|
1207
2854
|
const M=main; for(const n of lay){
|
|
1208
2855
|
if(horiz) n.x=M-n.x-n.w; else n.y=y0+20+(M-(n.y-y0-20))-n.h; }
|
|
1209
2856
|
}
|
|
1210
|
-
// Two-level coordinates (
|
|
2857
|
+
// Two-level coordinates (`PIN-COORDINATE-SCOPE`): a pinned GROUP anchors its local origin in
|
|
1211
2858
|
// canvas px; a pinned MEMBER is group-local (relative to that origin);
|
|
1212
2859
|
// ungrouped pins are canvas px. Moving a group = editing one pin line.
|
|
1213
2860
|
const gOrigin={};
|
|
1214
2861
|
for(const g of doc.groups){
|
|
1215
2862
|
const p=doc.pins[g.id];
|
|
1216
|
-
|
|
2863
|
+
// `ELEMENT-GEOMETRY-DIRECTIVE`: only a pin that carries `at=` anchors an origin.
|
|
2864
|
+
if(p&&p.fx!==null){ gOrigin[g.id]={x:p.fx, y:y0+20+p.fy}; }
|
|
1217
2865
|
else{
|
|
1218
2866
|
const mem=nodes.filter(n=>n.group===g.id);
|
|
1219
2867
|
if(mem.length) gOrigin[g.id]={x:Math.min(...mem.map(n=>n.x)),
|
|
1220
2868
|
y:Math.min(...mem.map(n=>n.y))};
|
|
1221
2869
|
}
|
|
1222
2870
|
}
|
|
1223
|
-
for(const n of nodes){ const p=doc.pins[n.id]; if(!p) continue;
|
|
2871
|
+
for(const n of nodes){ const p=doc.pins[n.id]; if(!p||p.fx===null) continue;
|
|
1224
2872
|
const o=n.group?gOrigin[n.group]:null;
|
|
1225
2873
|
if(o){ n.x=o.x+p.fx; n.y=o.y+p.fy; }
|
|
1226
2874
|
else { n.x=p.fx; n.y=y0+20+p.fy; }
|
|
@@ -1232,8 +2880,8 @@ function renderScene(doc,y0){
|
|
|
1232
2880
|
// (or the scene is substantially pinned), place the boundary just outside
|
|
1233
2881
|
// that node's border in the flow direction — upstream for `boundary -> node`,
|
|
1234
2882
|
// downstream for `node -> boundary` — aligned on the node's cross-axis, at a
|
|
1235
|
-
// small fixed gap. Explicitly pinned
|
|
1236
|
-
// their author-given placement. Strip pin
|
|
2883
|
+
// small fixed gap. Explicitly pinned boundaries keep
|
|
2884
|
+
// their author-given placement. Strip every `pin` and the AST is unchanged.
|
|
1237
2885
|
{
|
|
1238
2886
|
const real=nodes.filter(n=>!n.boundary);
|
|
1239
2887
|
const pinnedReal=real.filter(n=>pinned(n.id)).length;
|
|
@@ -1241,10 +2889,9 @@ function renderScene(doc,y0){
|
|
|
1241
2889
|
const BGAP=30; // border-to-anchor gap (spec range 24–40)
|
|
1242
2890
|
for(const b of nodes){
|
|
1243
2891
|
if(!b.boundary||pinned(b.id)) continue;
|
|
1244
|
-
// sole incident edge (degree-1); skip
|
|
2892
|
+
// sole incident edge (degree-1); skip self-refs
|
|
1245
2893
|
let node=null, downstream=true;
|
|
1246
2894
|
for(const e of doc.edges){
|
|
1247
|
-
if(e.route) continue;
|
|
1248
2895
|
if(e.a===b.id&&byId[e.b]&&byId[e.b]!==b){ node=byId[e.b]; downstream=false; break; } // boundary -> node
|
|
1249
2896
|
if(e.b===b.id&&byId[e.a]&&byId[e.a]!==b){ node=byId[e.a]; downstream=true; break; } // node -> boundary
|
|
1250
2897
|
}
|
|
@@ -1262,7 +2909,7 @@ function renderScene(doc,y0){
|
|
|
1262
2909
|
}
|
|
1263
2910
|
}
|
|
1264
2911
|
}
|
|
1265
|
-
// Boundary labels sit beyond the open end, away from the figure (
|
|
2912
|
+
// Boundary labels sit beyond the open end, away from the figure (`EXTERNAL-EDGE-ENDPOINTS`).
|
|
1266
2913
|
// bDir = outward direction of an anchor: away from its first incident
|
|
1267
2914
|
// edge's other endpoint (document order; [0,1] = below when unwired).
|
|
1268
2915
|
const bDir=n=>{
|
|
@@ -1300,7 +2947,7 @@ function renderScene(doc,y0){
|
|
|
1300
2947
|
// channel side — so fan-in hubs (N states -> IDLE reset) have no crossings.
|
|
1301
2948
|
const chPlan=new Map(); let chTop=0, chShift=0;
|
|
1302
2949
|
{
|
|
1303
|
-
const chList=doc.edges.filter(e=>byId[e.a]&&byId[e.b]&&isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)
|
|
2950
|
+
const chList=doc.edges.filter(e=>byId[e.a]&&byId[e.b]&&isBack.has(e)&&!pinned(e.a)&&!pinned(e.b));
|
|
1304
2951
|
const near=e=>{ const A=byId[e.a]; return horiz?A.y+A.h/2:A.x+A.w/2; };
|
|
1305
2952
|
const order=chList.map((e,i)=>({e,i}));
|
|
1306
2953
|
order.sort((p,q)=>near(q.e)-near(p.e)||p.i-q.i);
|
|
@@ -1348,7 +2995,7 @@ function renderScene(doc,y0){
|
|
|
1348
2995
|
// opposite sides; endpoints and labels shift together.
|
|
1349
2996
|
const apOff=new Map();
|
|
1350
2997
|
{
|
|
1351
|
-
const straight=e=>byId[e.a]&&byId[e.b]&&!(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b))&&!chains.get(e)
|
|
2998
|
+
const straight=e=>byId[e.a]&&byId[e.b]&&!(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b))&&!chains.get(e);
|
|
1352
2999
|
const pk=e=>e.a<e.b?e.a+'\t'+e.b:e.b+'\t'+e.a;
|
|
1353
3000
|
const pairN={}, seen={};
|
|
1354
3001
|
for(const e of doc.edges) if(straight(e)){ const k=pk(e); pairN[k]=(pairN[k]||0)+1; }
|
|
@@ -1377,8 +3024,8 @@ function renderScene(doc,y0){
|
|
|
1377
3024
|
gBox[g.id]={x0,x1,yA,yB};
|
|
1378
3025
|
const gdash=g.style==='dashed'?' stroke-dasharray="6 4"':(g.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
1379
3026
|
gsvg.push('<g data-group="'+g.id+'" data-gx="'+o.x+'" data-gy="'+o.y+'" style="cursor:move">'
|
|
1380
|
-
+'<rect x="'+x0+'" y="'+yA+'" width="'+(x1-x0)+'" height="'+(yB-yA)+'" rx="10" fill="'+(g.
|
|
1381
|
-
+'<text x="'+(x0+10)+'" y="'+(yA+16)+'" font-size="11.5" fill="'+(g.
|
|
3027
|
+
+'<rect x="'+x0+'" y="'+yA+'" width="'+(x1-x0)+'" height="'+(yB-yA)+'" rx="10" fill="'+(g.fill||'#f6f5ef')+'" stroke="'+(g.stroke||'#d6d4cc')+'"'+gdash+'/>'
|
|
3028
|
+
+'<text x="'+(x0+10)+'" y="'+(yA+16)+'" font-size="11.5" fill="'+labelInk(g.fill||'#f6f5ef','#6f6e69')+'">'+esc(g.label)+'</text></g>');
|
|
1382
3029
|
W=Math.max(W,x1); Hh=Math.max(Hh,yB-y0-20);
|
|
1383
3030
|
}
|
|
1384
3031
|
// zone bands on groups: above the group background, below the nodes.
|
|
@@ -1391,14 +3038,13 @@ function renderScene(doc,y0){
|
|
|
1391
3038
|
if(f.dir==='right') return [x0+w*f.from/100, yA, w*(f.to-f.from)/100, h];
|
|
1392
3039
|
return [x1-w*f.to/100, yA, w*(f.to-f.from)/100, h]; // left
|
|
1393
3040
|
};
|
|
1394
|
-
for(const f of doc.
|
|
3041
|
+
for(const f of zsort(doc.bands)){
|
|
1395
3042
|
const B=gBox[f.target]; if(!B) continue;
|
|
1396
3043
|
const [bx,by,bw,bh]=bandRect(f,B.x0,B.yA,B.x1,B.yB);
|
|
1397
|
-
gsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.
|
|
3044
|
+
gsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.fill+'" opacity="0.9"'+bandEdge(f)+'/>');
|
|
1398
3045
|
}
|
|
1399
|
-
// edges (sorted by
|
|
1400
|
-
const
|
|
1401
|
-
const edges=[...doc.edges].sort((p,q)=>zOf(p.layer)-zOf(q.layer));
|
|
3046
|
+
// edges (sorted by plane z, then doc order)
|
|
3047
|
+
const edges=zsort(doc.edges);
|
|
1402
3048
|
const esvg=[], lblsvg=[]; // labels paint last = closest to the viewer
|
|
1403
3049
|
// ── deferred edge-label placement ───────────────────────────────────────
|
|
1404
3050
|
// An edge label is not written where it is emitted. Each emission reserves
|
|
@@ -1440,54 +3086,23 @@ function renderScene(doc,y0){
|
|
|
1440
3086
|
});
|
|
1441
3087
|
for(const e of edges){
|
|
1442
3088
|
const A=byId[e.a], B=byId[e.b]; if(!A||!B) continue;
|
|
1443
|
-
|
|
3089
|
+
// an edge is pure stroke: `stroke=` and `fill=` name the same channel
|
|
3090
|
+
// (stroke= wins), `text=` colours the [tail]/[mid]/[head] labels.
|
|
3091
|
+
// 0.1 (§8.4): `fill=` on an edge is a line error, so the silent
|
|
3092
|
+
// precedence this line encoded no longer has anything to resolve.
|
|
3093
|
+
const col=e.stroke||'#555';
|
|
3094
|
+
// 0.1 (`LABEL-COLOUR-SOURCE`): an edge has no interior, so its labels cannot be
|
|
3095
|
+
// derived from a fill — they follow their OWNER, the line. Before this
|
|
3096
|
+
// release the mid label did (`e.color||col`) and the [tail]/[head] labels
|
|
3097
|
+
// did NOT (`e.color||'#555'`): one construct, two undocumented defaults,
|
|
3098
|
+
// so a stroke=#0f766e edge drew a teal mid label and two grey endpoint
|
|
3099
|
+
// labels. All three now take the line's colour.
|
|
3100
|
+
const lcol=col, ecol=col;
|
|
1444
3101
|
const dash=e.style==='dashed'?' stroke-dasharray="6 4"':(e.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
1445
3102
|
const wantsStart=e.op==='<->'||e.op==='<-', wantsEnd=e.op==='<->'||e.op==='->';
|
|
1446
3103
|
const m1='', m2=''; // markers removed — arrowTri() paints triangles above nodes in lblsvg
|
|
1447
3104
|
const halo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
|
|
1448
|
-
const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:
|
|
1449
|
-
if(e.route){
|
|
1450
|
-
// declared waypoints (route … via=, 0.1-dev.13) are RIGID: the edge
|
|
1451
|
-
// draws source → via1 → … → viaN → target and bypasses the automatic
|
|
1452
|
-
// machinery (chains, channels, obstacle detours). Waypoints are
|
|
1453
|
-
// canvas px in the same space as ungrouped pins (and follow the
|
|
1454
|
-
// ring-channel scene shift like pins do). Under orthogonal routing
|
|
1455
|
-
// (route-level routing= wins over the document directive) consecutive
|
|
1456
|
-
// points are joined by deterministic manhattan elbows — horizontal-
|
|
1457
|
-
// then-vertical for flow right|left, vertical-then-horizontal for
|
|
1458
|
-
// down|up — reusing the standard borderPoint anchoring at both ends.
|
|
1459
|
-
const orth=(e.route.routing||doc.routing||'straight')==='orthogonal';
|
|
1460
|
-
const via=e.route.via.map(p=>[p[0], y0+20+chShift+p[1]]);
|
|
1461
|
-
let pts=[borderPoint(A,via[0][0],via[0][1])]
|
|
1462
|
-
.concat(via,[borderPoint(B,via[via.length-1][0],via[via.length-1][1])]);
|
|
1463
|
-
if(orth){
|
|
1464
|
-
const out=[pts[0]];
|
|
1465
|
-
for(let i=1;i<pts.length;i++){
|
|
1466
|
-
const p=out[out.length-1], q=pts[i];
|
|
1467
|
-
if(p[0]!==q[0]&&p[1]!==q[1]) out.push(horiz?[q[0],p[1]]:[p[0],q[1]]);
|
|
1468
|
-
out.push(q);
|
|
1469
|
-
}
|
|
1470
|
-
pts=out;
|
|
1471
|
-
}
|
|
1472
|
-
for(let i=1;i+1<pts.length;i++) // drop zero-length interior steps
|
|
1473
|
-
if(Math.hypot(pts[i][0]-pts[i-1][0],pts[i][1]-pts[i-1][1])<0.5){ pts.splice(i,1); i--; }
|
|
1474
|
-
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
1475
|
-
noteSegs(e,pts);
|
|
1476
|
-
if(e.mid){ // the longest segment carries the mid label
|
|
1477
|
-
let bi=0,bl=-1;
|
|
1478
|
-
for(let i=0;i+1<pts.length;i++){
|
|
1479
|
-
const l=Math.hypot(pts[i+1][0]-pts[i][0],pts[i+1][1]-pts[i][1]);
|
|
1480
|
-
if(l>bl){ bl=l; bi=i; }
|
|
1481
|
-
}
|
|
1482
|
-
reqLabel({p:pts[bi],q:pts[bi+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:bi===0});
|
|
1483
|
-
}
|
|
1484
|
-
if(e.tail) seg(pts[0],pts[1],0.25,e.tail,10);
|
|
1485
|
-
if(e.head) seg(pts[pts.length-1],pts[pts.length-2],0.25,e.head,10);
|
|
1486
|
-
if(wantsStart) arrowTri(pts[0],pts[1],col);
|
|
1487
|
-
if(wantsEnd) arrowTri(pts[pts.length-1],pts[pts.length-2],col);
|
|
1488
|
-
for(const pP of pts){ W=Math.max(W,pP[0]+4); Hh=Math.max(Hh,pP[1]+16-y0-20); }
|
|
1489
|
-
continue;
|
|
1490
|
-
}
|
|
3105
|
+
const seg=(p,q,t,lbl,fs)=>reqLabel({p,q,t0:t,text:lbl,fs,col:ecol,halo,e,A,B,kind:'end'});
|
|
1491
3106
|
if(isBack.has(e)&&!pinned(e.a)&&!pinned(e.b)){
|
|
1492
3107
|
// back-edge (retry loop): polyline through a side channel beyond the
|
|
1493
3108
|
// occupied lanes instead of a straight line hidden under the spine,
|
|
@@ -1513,7 +3128,7 @@ function renderScene(doc,y0){
|
|
|
1513
3128
|
} else pts.push([tx,chY],[tx,outSide(B,'b')]);
|
|
1514
3129
|
if(e.mid){
|
|
1515
3130
|
const c1=pts.findIndex(p=>p[1]===chY);
|
|
1516
|
-
reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
|
|
3131
|
+
reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
|
|
1517
3132
|
}
|
|
1518
3133
|
} else if(P.ringOK){ // concentric ring: under, around, over, in
|
|
1519
3134
|
const sx=A.x+A.w/2;
|
|
@@ -1521,7 +3136,7 @@ function renderScene(doc,y0){
|
|
|
1521
3136
|
pts.push([sx,outSide(A,'b')],[sx,gy],[chX,gy],[chX,topY],[P.ex,topY],[P.ex,outSide(B,'t')]);
|
|
1522
3137
|
if(e.mid){
|
|
1523
3138
|
const c1=pts.findIndex(p=>p[0]===chX);
|
|
1524
|
-
reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
|
|
3139
|
+
reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
|
|
1525
3140
|
}
|
|
1526
3141
|
} else { // channel runs right of the lanes
|
|
1527
3142
|
const chX=occR+28+P.slot;
|
|
@@ -1538,7 +3153,7 @@ function renderScene(doc,y0){
|
|
|
1538
3153
|
} else pts.push([chX,ty],[outSide(B,'r'),ty]);
|
|
1539
3154
|
if(e.mid){
|
|
1540
3155
|
const c1=pts.findIndex(p=>p[0]===chX);
|
|
1541
|
-
reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
|
|
3156
|
+
reqLabel({p:pts[c1],q:pts[c1+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
|
|
1542
3157
|
}
|
|
1543
3158
|
}
|
|
1544
3159
|
// non-incident nodes are obstacles for the channel runs too: a run
|
|
@@ -1617,7 +3232,7 @@ function renderScene(doc,y0){
|
|
|
1617
3232
|
simplifyPts(pts);
|
|
1618
3233
|
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(pts)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
1619
3234
|
noteSegs(e,pts);
|
|
1620
|
-
if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:false});
|
|
3235
|
+
if(midSeg) reqLabel({p:midSeg[0],q:midSeg[1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:false});
|
|
1621
3236
|
if(e.tail) seg(p0,pts[1],0.4,e.tail,10);
|
|
1622
3237
|
if(e.head) seg(p1,pts[pts.length-2],0.4,e.head,10);
|
|
1623
3238
|
if(wantsStart) arrowTri(pts[0],pts[1],col);
|
|
@@ -1647,21 +3262,6 @@ function renderScene(doc,y0){
|
|
|
1647
3262
|
route[route.length-1]=borderPoint(B,route[route.length-2][0],route[route.length-2][1]);
|
|
1648
3263
|
}
|
|
1649
3264
|
}
|
|
1650
|
-
if(!route && A!==B && doc.routing==='orthogonal' && ax!==bx && ay!==by){
|
|
1651
|
-
// document-level orthogonal routing (0.1-dev.13): plain straight
|
|
1652
|
-
// edges become deterministic manhattan elbows — horizontal-then-
|
|
1653
|
-
// vertical under flow right|left, vertical-then-horizontal under
|
|
1654
|
-
// down|up — anchored by the same borderPoint rule (the corner sits
|
|
1655
|
-
// on the far node's main-axis line, so exits are clean right-angle
|
|
1656
|
-
// stubs). Obstacle detours and channel/chain polylines keep their
|
|
1657
|
-
// existing shapes; axis-aligned pairs stay plain straight lines.
|
|
1658
|
-
const corner=horiz?[bx,ay]:[ax,by];
|
|
1659
|
-
let sP=borderPoint(A,corner[0],corner[1]), tP=borderPoint(B,corner[0],corner[1]);
|
|
1660
|
-
if(ap){ sP=[sP[0]+ap[0],sP[1]+ap[1]]; tP=[tP[0]+ap[0],tP[1]+ap[1]]; }
|
|
1661
|
-
const mid=horiz?[tP[0],sP[1]]:[sP[0],tP[1]];
|
|
1662
|
-
const deg=(pp,qq)=>Math.hypot(pp[0]-qq[0],pp[1]-qq[1])<0.5;
|
|
1663
|
-
if(!deg(mid,sP)&&!deg(mid,tP)) route=[sP,mid,tP];
|
|
1664
|
-
}
|
|
1665
3265
|
if(route){
|
|
1666
3266
|
esvg.push('<path data-edge="'+e.line+'" d="'+roundPath(route)+'" fill="none" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
1667
3267
|
noteSegs(e,route);
|
|
@@ -1671,7 +3271,7 @@ function renderScene(doc,y0){
|
|
|
1671
3271
|
const l=Math.hypot(route[i+1][0]-route[i][0],route[i+1][1]-route[i][1]);
|
|
1672
3272
|
if(l>bl){ bl=l; bi=i; }
|
|
1673
3273
|
}
|
|
1674
|
-
reqLabel({p:route[bi],q:route[bi+1],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:bi===0});
|
|
3274
|
+
reqLabel({p:route[bi],q:route[bi+1],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:bi===0});
|
|
1675
3275
|
}
|
|
1676
3276
|
if(e.tail) seg(route[0],route[1],0.25,e.tail,10);
|
|
1677
3277
|
if(e.head) seg(route[route.length-1],route[route.length-2],0.25,e.head,10);
|
|
@@ -1683,8 +3283,8 @@ function renderScene(doc,y0){
|
|
|
1683
3283
|
esvg.push('<line data-edge="'+e.line+'" x1="'+x1+'" y1="'+yy1+'" x2="'+x2+'" y2="'+yy2+'" stroke="'+col+'" stroke-width="1.6"'+dash+'/>');
|
|
1684
3284
|
noteSegs(e,[[x1,yy1],[x2,yy2]]);
|
|
1685
3285
|
if(e.mid)
|
|
1686
|
-
reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col,halo,e,A,B,kind:'mid',first:true});
|
|
1687
|
-
// endpoint labels at the tail/head positions (three-position model,
|
|
3286
|
+
reqLabel({p:[x1,yy1],q:[x2,yy2],text:e.mid,fs:11,col:lcol,halo,e,A,B,kind:'mid',first:true});
|
|
3287
|
+
// endpoint labels at the tail/head positions (three-position model, `EDGE-LABEL-PLACEMENT`)
|
|
1688
3288
|
if(e.tail) seg([x1,yy1],[x2,yy2],0.18,e.tail,10);
|
|
1689
3289
|
if(e.head) seg([x1,yy1],[x2,yy2],0.82,e.head,10);
|
|
1690
3290
|
if(wantsStart) arrowTri([x1,yy1],[x2,yy2],col);
|
|
@@ -1748,8 +3348,13 @@ function renderScene(doc,y0){
|
|
|
1748
3348
|
} else {
|
|
1749
3349
|
sides=across?['above','below']:['right','left'];
|
|
1750
3350
|
// flowchart convention: a short branch marker leaving a decision node
|
|
1751
|
-
// reads as that branch's name only if it sits next to the decision
|
|
1752
|
-
|
|
3351
|
+
// reads as that branch's name only if it sits next to the decision.
|
|
3352
|
+
// `FLOWCHART-ROLE-KEYWORDS`: the test is the ROLE, not the geometry. Until
|
|
3353
|
+
// this release it read `r.A.shape==='diamond'`, which is the renderer
|
|
3354
|
+
// consuming a `shape=` value AS a role — exactly what `SHAPE-ENUM-VOCABULARY`/`EXTERNAL-EDGE-ENDPOINTS` forbids,
|
|
3355
|
+
// and a live violation the backlog had recorded (entry 12). Now that
|
|
3356
|
+
// `decision` exists as a word, the heuristic can ask the model.
|
|
3357
|
+
const branch=r.first && r.A && r.A.role==='decision' &&
|
|
1753
3358
|
String(r.text).length<=3 && !String(r.text).includes('\n');
|
|
1754
3359
|
tPref=branch?0.22:0.5;
|
|
1755
3360
|
ts=branch?[0.22,0.3,0.16,0.4,0.5,0.62]:[0.5,0.38,0.62,0.28,0.72];
|
|
@@ -1776,32 +3381,34 @@ function renderScene(doc,y0){
|
|
|
1776
3381
|
const nsvg=[];
|
|
1777
3382
|
for(const n of nodes){
|
|
1778
3383
|
if(n.boundary){
|
|
1779
|
-
// never drawn as a shape (
|
|
3384
|
+
// never drawn as a shape (`EXTERNAL-EDGE-ENDPOINTS`): the edge already ended open at the
|
|
1780
3385
|
// anchor; a declared label sits just beyond the open end, on the
|
|
1781
3386
|
// side away from the figure — small muted text with a white halo
|
|
1782
3387
|
if(!n.label) continue;
|
|
1783
3388
|
const cx=n.x+n.w/2, cy=n.y+n.h/2, [bdx,bdy]=bDir(n);
|
|
1784
3389
|
const bhalo=' paint-order="stroke" stroke="#fff" stroke-width="3"';
|
|
3390
|
+
// an `external` is never drawn (`EXTERNAL-EDGE-ENDPOINTS`): no fill, no line of its own, so
|
|
3391
|
+
// there is nothing to derive from. Its label keeps the muted canvas ink
|
|
3392
|
+
// (`LABEL-COLOUR-SOURCE` — stated in §5 rather than left to the engine).
|
|
3393
|
+
const bcol='#555';
|
|
1785
3394
|
if(Math.abs(bdx)>=Math.abs(bdy)){
|
|
1786
|
-
if(bdx>=0){ lblsvg.push(textEl(cx+10,cy+3.5,10,'start',
|
|
3395
|
+
if(bdx>=0){ lblsvg.push(textEl(cx+10,cy+3.5,10,'start',bcol,n.label,bhalo));
|
|
1787
3396
|
W=Math.max(W,cx+12+lblPx(n.label)); }
|
|
1788
|
-
else lblsvg.push(textEl(cx-10,cy+3.5,10,'end',
|
|
3397
|
+
else lblsvg.push(textEl(cx-10,cy+3.5,10,'end',bcol,n.label,bhalo));
|
|
1789
3398
|
} else if(bdy>=0){
|
|
1790
|
-
lblsvg.push(textEl(cx,cy+17,10,'middle',
|
|
3399
|
+
lblsvg.push(textEl(cx,cy+17,10,'middle',bcol,n.label,bhalo));
|
|
1791
3400
|
Hh=Math.max(Hh,cy+21-y0-20);
|
|
1792
|
-
} else lblsvg.push(textEl(cx,cy-10,10,'middle',
|
|
3401
|
+
} else lblsvg.push(textEl(cx,cy-10,10,'middle',bcol,n.label,bhalo));
|
|
1793
3402
|
continue;
|
|
1794
3403
|
}
|
|
1795
3404
|
nsvg.push('<g data-node="'+n.id+'" data-x="'+n.x+'" data-y="'+n.y+'" style="cursor:move">');
|
|
1796
|
-
const fill=n.
|
|
3405
|
+
const fill=n.fill||'#fff', stroke=n.stroke||'#8a8880', txt=labelInk(fill,'#1d1d1b');
|
|
1797
3406
|
const ndash=n.style==='dashed'?' stroke-dasharray="6 4"':(n.style==='dotted'?' stroke-dasharray="2 4"':'');
|
|
1798
3407
|
if(n.shape==='diamond'){
|
|
1799
3408
|
const cx=n.x+n.w/2, cy=n.y+n.h/2;
|
|
1800
3409
|
nsvg.push('<polygon points="'+cx+','+n.y+' '+(n.x+n.w)+','+cy+' '+cx+','+(n.y+n.h)+' '+n.x+','+cy+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
|
|
1801
3410
|
} else if(n.shape==='rounded'){
|
|
1802
3411
|
nsvg.push('<rect x="'+n.x+'" y="'+n.y+'" width="'+n.w+'" height="'+n.h+'" rx="'+Math.min(14,n.h/2)+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+' stroke-width="1.8"/>');
|
|
1803
|
-
} else if(n.shape==='cloud'){
|
|
1804
|
-
nsvg.push('<ellipse cx="'+(n.x+n.w/2)+'" cy="'+(n.y+n.h/2)+'" rx="'+(n.w/2+10)+'" ry="'+(n.h/2+8)+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
|
|
1805
3412
|
} else if(n.shape==='ellipse'||n.shape==='circle'){
|
|
1806
3413
|
nsvg.push('<ellipse cx="'+(n.x+n.w/2)+'" cy="'+(n.y+n.h/2)+'" rx="'+(n.w/2)+'" ry="'+(n.shape==='circle'?n.w/2:n.h/2)+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
|
|
1807
3414
|
} else if(n.shape==='cylinder'){
|
|
@@ -1813,12 +3420,12 @@ function renderScene(doc,y0){
|
|
|
1813
3420
|
nsvg.push('<rect x="'+n.x+'" y="'+n.y+'" width="'+n.w+'" height="'+n.h+'" fill="'+fill+'" stroke="'+stroke+'"'+ndash+'/>');
|
|
1814
3421
|
}
|
|
1815
3422
|
// zone bands on this node (dir: up=bottom-based, down, left, right)
|
|
1816
|
-
for(const f of doc.
|
|
3423
|
+
for(const f of zsort(doc.bands)){
|
|
1817
3424
|
if(f.target!==n.id) continue;
|
|
1818
3425
|
const [bx,by,bw,bh]=bandRect(f,n.x,n.y,n.x+n.w,n.y+n.h);
|
|
1819
|
-
nsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.
|
|
3426
|
+
nsvg.push('<rect x="'+bx+'" y="'+by+'" width="'+bw+'" height="'+bh+'" fill="'+f.fill+'" opacity="0.9"'+bandEdge(f)+'/>');
|
|
1820
3427
|
}
|
|
1821
|
-
// label with shrink-to-fit when size is rigid (
|
|
3428
|
+
// label with shrink-to-fit when size is rigid (`UNDECLARED-ATTRIBUTE-BEHAVIOUR`); multi-line via "\n"
|
|
1822
3429
|
let fs=FONT;
|
|
1823
3430
|
const nl=String(n.label).split('\n');
|
|
1824
3431
|
const need=Math.max(...nl.map(l=>l.length))*CH;
|
|
@@ -1834,7 +3441,7 @@ function renderScene(doc,y0){
|
|
|
1834
3441
|
// trunk rings (semantic LAG/ES bundles): the ellipse is DERIVED from the
|
|
1835
3442
|
// member links' midpoints — drag a node and the ring follows
|
|
1836
3443
|
const tsvg=[];
|
|
1837
|
-
for(const t of doc.trunks){
|
|
3444
|
+
for(const t of zsort(doc.trunks)){
|
|
1838
3445
|
const mids=[];
|
|
1839
3446
|
for(const [a,b] of t.pairs){
|
|
1840
3447
|
const A=byId[a], B=byId[b]; if(!A||!B) continue;
|
|
@@ -1847,22 +3454,46 @@ function renderScene(doc,y0){
|
|
|
1847
3454
|
const cy=mids.reduce((s,m)=>s+m[1],0)/mids.length;
|
|
1848
3455
|
const rx=Math.max(46, Math.max(...mids.map(m=>Math.abs(m[0]-cx)))+38);
|
|
1849
3456
|
const ry=Math.max(26, Math.max(...mids.map(m=>Math.abs(m[1]-cy)))+22);
|
|
1850
|
-
|
|
1851
|
-
|
|
3457
|
+
// 0.1 (§8.4): the ring has no interior — `stroke=` is the one
|
|
3458
|
+
// name for its channel; `fill=` on a bundle is now a line error, so the
|
|
3459
|
+
// model field is always absent and the old `t.fill` fallback is gone.
|
|
3460
|
+
const col=t.stroke||'#64748b';
|
|
3461
|
+
tsvg.push('<ellipse cx="'+cx+'" cy="'+cy+'" rx="'+rx+'" ry="'+ry+'" fill="transparent" stroke="'+(t.stroke||col)+'"'+dashOf(t.style,'6 4')+' stroke-width="1.6"/>');
|
|
1852
3462
|
tsvg.push(textEl(cx, cy+4, 11.5, 'middle', col, t.label,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
|
|
1853
3463
|
W=Math.max(W,cx+rx); Hh=Math.max(Hh,cy+ry-y0-20);
|
|
1854
3464
|
}
|
|
1855
|
-
//
|
|
1856
|
-
|
|
1857
|
-
|
|
3465
|
+
// threshold markers + labels (top layer). The target is a GROUP box or,
|
|
3466
|
+
// a single NODE box — the same two scopes `band` has always
|
|
3467
|
+
// taken (§2.6, `AUTHORING-INTENT-OVER-RENDERING`); a node's box is spelled in the gBox coordinate shape.
|
|
3468
|
+
for(const gl of zsort(doc.thresholds)){
|
|
3469
|
+
const gn=byId[gl.target];
|
|
3470
|
+
const B=gBox[gl.target] || (gn ? {x0:gn.x, x1:gn.x+gn.w, yA:gn.y, yB:gn.y+gn.h} : null);
|
|
3471
|
+
if(!B) continue;
|
|
1858
3472
|
const ly=B.yB-(B.yB-B.yA)*gl.pct/100;
|
|
1859
|
-
|
|
3473
|
+
// 0.1 (§8.4): same as the bundle ring — `stroke=` only.
|
|
3474
|
+
const col=gl.stroke||'#ef4444';
|
|
1860
3475
|
tsvg.push('<g data-gline="'+gl.line+'" data-gtop="'+B.yA+'" data-gbot="'+B.yB+'" style="cursor:ns-resize">'
|
|
1861
|
-
+'<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="'+col+'" stroke-width="'+(gl.pct>=100?4:2)+'"
|
|
3476
|
+
+'<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="'+(gl.stroke||col)+'" stroke-width="'+(gl.pct>=100?4:2)+'"'+dashOf(gl.style,'7 4')+'/>'
|
|
1862
3477
|
+'<line x1="'+B.x0+'" y1="'+ly+'" x2="'+B.x1+'" y2="'+ly+'" stroke="transparent" stroke-width="12"/>'
|
|
1863
3478
|
+textEl(B.x1+8, ly+4, 11, 'start', col, gl.label,' paint-order="stroke" stroke="#fff" stroke-width="3"')+'</g>');
|
|
1864
3479
|
W=Math.max(W, B.x1+8+tw(gl.label)); Hh=Math.max(Hh, B.yB-y0-20);
|
|
1865
3480
|
}
|
|
3481
|
+
// band labels (`BAND-LABEL-STATUS`). The RECT is painted with its target — under
|
|
3482
|
+
// the nodes for a group-scope band, with the node for a node-scope one — so
|
|
3483
|
+
// the label cannot ride along there: it would be occluded by whatever sits on
|
|
3484
|
+
// top. It is written here instead, in the same top layer as the threshold
|
|
3485
|
+
// labels, CENTRED INSIDE the band's own rectangle. Inside, not beside: the
|
|
3486
|
+
// band already owns an area, so the name needs no gutter and the canvas box
|
|
3487
|
+
// does not grow — W and Hh are untouched, which is why adding the label moves
|
|
3488
|
+
// no existing geometry in any figure.
|
|
3489
|
+
for(const f of zsort(doc.bands)){
|
|
3490
|
+
const bn=byId[f.target];
|
|
3491
|
+
const B=gBox[f.target] || (bn ? {x0:bn.x, x1:bn.x+bn.w, yA:bn.y, yB:bn.y+bn.h} : null);
|
|
3492
|
+
if(!B) continue;
|
|
3493
|
+
const [bx,by,bw,bh]=bandRect(f,B.x0,B.yA,B.x1,B.yB);
|
|
3494
|
+
tsvg.push(textEl(bx+bw/2, by+bh/2+4, 11, 'middle', labelInk(f.fill,'#334155'), f.label,
|
|
3495
|
+
' paint-order="stroke" stroke="#fff" stroke-width="3"'));
|
|
3496
|
+
}
|
|
1866
3497
|
const yEnd=y0+20+Hh+10;
|
|
1867
3498
|
return {svg:gsvg.join('')+esvg.join('')+nsvg.join('')+tsvg.join('')+lblsvg.join(''), y:yEnd, w:W+2,
|
|
1868
3499
|
meta:{W:W, top:y0+20+chShift, Hh:Hh, left:bShift}};
|
|
@@ -1870,8 +3501,7 @@ function renderScene(doc,y0){
|
|
|
1870
3501
|
// borderPoint: where the ray from n's centre toward (tx,ty) leaves the shape.
|
|
1871
3502
|
// It must leave the DRAWN outline: a rectangle clip on a diamond or an ellipse
|
|
1872
3503
|
// stops on the bounding box, which for a rhombus can be a corner where the
|
|
1873
|
-
// shape is not — the endpoint then floats in empty space
|
|
1874
|
-
// ellipse is drawn outside the box, hides under the fill).
|
|
3504
|
+
// shape is not — the endpoint then floats in empty space next to the shape.
|
|
1875
3505
|
function borderPoint(n,tx,ty){
|
|
1876
3506
|
const cx=n.x+n.w/2, cy=n.y+n.h/2, dx=tx-cx, dy=ty-cy;
|
|
1877
3507
|
if(dx===0&&dy===0) return [cx,cy];
|
|
@@ -1949,44 +3579,391 @@ function routeAround(p,q,obs){
|
|
|
1949
3579
|
|
|
1950
3580
|
// ---- bitfield ----
|
|
1951
3581
|
function renderBitfield(b,y0){
|
|
1952
|
-
const cell=Math.max(18,Math.min(28,Math.floor(760/b.
|
|
3582
|
+
const cell=Math.max(18,Math.min(28,Math.floor(760/b.word))), rh=30, ruler=16;
|
|
1953
3583
|
const svg=[]; let y=y0+18;
|
|
3584
|
+
// §5 on the block: text= is the caption colour, fill=/stroke= are the
|
|
3585
|
+
// per-cell defaults a field can override.
|
|
1954
3586
|
svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(b.label)+'</text>');
|
|
1955
3587
|
// ruler: lsb0 (register style, default) = N-1..0; msb0 (RFC style) = 0..N-1
|
|
1956
|
-
for(let i=0;i<b.
|
|
1957
|
-
const bit=(b.numbering==='msb0') ? i : b.
|
|
3588
|
+
for(let i=0;i<b.word;i++){
|
|
3589
|
+
const bit=(b.numbering==='msb0') ? i : b.word-1-i;
|
|
1958
3590
|
svg.push('<text x="'+(i*cell+cell/2)+'" y="'+(y+11)+'" font-size="8.5" text-anchor="middle" fill="#6f6e69">'+bit+'</text>');
|
|
1959
3591
|
}
|
|
1960
3592
|
y+=ruler;
|
|
3593
|
+
// `BITFIELD-REPETITION-CONSTRUCT`: the DERIVED elision row. A field carrying `index=` is
|
|
3594
|
+
// ONE ELEMENT of a repeated run, and the engine draws what that means. Same
|
|
3595
|
+
// division of labour as `present=`: the author names the meaning, the engine
|
|
3596
|
+
// owns the convention (`DOMAIN-CONVENTION-DIRECTIVES`), and there is no option key for any of it.
|
|
3597
|
+
//
|
|
3598
|
+
// `REPEATED-RUN-DRAWING`: WHAT that means is the RFC's own drawing — FIRST
|
|
3599
|
+
// element, elision, LAST element. RFC 8754 §2, the document examples/srh.fd
|
|
3600
|
+
// transcribes, prints
|
|
3601
|
+
//
|
|
3602
|
+
// | Segment List[0] (128-bit IPv6 address) |
|
|
3603
|
+
// ...
|
|
3604
|
+
// | Segment List[n] (128-bit IPv6 address) |
|
|
3605
|
+
//
|
|
3606
|
+
// and 0.1 already ruled that a spanning field follows the RFC's
|
|
3607
|
+
// drawing rather than a FigDown one; this is the same ruling applied to the
|
|
3608
|
+
// other construct in the same figure. Until this release the engine drew ONE
|
|
3609
|
+
// occurrence and hung `[first] … [last]` on the strip — a FigDown invention
|
|
3610
|
+
// where a convention already existed.
|
|
3611
|
+
//
|
|
3612
|
+
// THE INDEX IS APPENDED TO THE AUTHOR'S LABEL, never inserted into it:
|
|
3613
|
+
// `Segment List (128-bit IPv6 address) [0]`, not the RFC's
|
|
3614
|
+
// `Segment List[0] (128-bit IPv6 address)`. Placing it the RFC's way would
|
|
3615
|
+
// mean PARSING the label to find where a bracket belongs, and a label is
|
|
3616
|
+
// opaque text (§12.7) — the engine may not read it. This is a deliberate
|
|
3617
|
+
// departure from the RFC's typography and the trade is the right one: the
|
|
3618
|
+
// reader still learns which occurrence is which, and the cost is zero.
|
|
3619
|
+
//
|
|
3620
|
+
// THE ELISION MARK IS DERIVED, never special-cased. The elements not drawn
|
|
3621
|
+
// number `|last - first| - 1`, and the mark is drawn when that is not ZERO:
|
|
3622
|
+
// index=0..7 6 undrawn [0], elision, [7]
|
|
3623
|
+
// index=0..n indeterminate (a prose last index) [0], elision, [n]
|
|
3624
|
+
// index=0..1 0 undrawn [0], [1], and NO elision mark — the mark means
|
|
3625
|
+
// "there are more, not drawn", and drawing it over nothing
|
|
3626
|
+
// would be a false statement. It falls out of the arithmetic.
|
|
3627
|
+
// index=0..0 a line error before it reaches here (not repetition)
|
|
3628
|
+
// index=53..0 descending, 52 undrawn — the absolute value is why
|
|
3629
|
+
//
|
|
3630
|
+
// `index=""` (the model records `{}`) keeps the ONE-occurrence drawing plus
|
|
3631
|
+
// a bare strip: the author claimed repetition and declined to state any
|
|
3632
|
+
// index, so there is no `[first]` and no `[last]` to write, and two boxes
|
|
3633
|
+
// would assert a first and a last the author did not state. Same ruling
|
|
3634
|
+
// `present=""` gets — the author does not say, the engine does not speak for
|
|
3635
|
+
// them.
|
|
3636
|
+
//
|
|
3637
|
+
// The strip itself carries NO text but the elision mark. With the indices on
|
|
3638
|
+
// the occurrences, `[first] … [last]` on the strip would repeat them.
|
|
3639
|
+
//
|
|
3640
|
+
// THE STRIP OCCUPIES NO BIT POSITIONS. The bit cursor is untouched by it —
|
|
3641
|
+
// it is vertical space only, in the same family as the blank cells a
|
|
3642
|
+
// `break` leaves behind — so the ruler keeps meaning what it means and a
|
|
3643
|
+
// reader that counts drawn rows is reading the drawing (`MEANING-RECOVERY-SOURCE`), which it must
|
|
3644
|
+
// not do. Its width is the element's OWN columns, never the whole word: a
|
|
3645
|
+
// full-width strip would claim columns the element does not occupy.
|
|
3646
|
+
const EL_H=16;
|
|
3647
|
+
const elis=[]; // {row, x, w, text} — one per repeated element, in row order
|
|
3648
|
+
// Two repeated elements ending on the SAME drawn row share one strip BAND,
|
|
3649
|
+
// so the shift counts distinct rows, never elements.
|
|
3650
|
+
const elisBands=(below)=>{ const s=new Set();
|
|
3651
|
+
for(const e of elis) if(below===null||e.row<below) s.add(e.row); return s.size; };
|
|
3652
|
+
const shiftFor=(row)=>elisBands(row)*EL_H;
|
|
3653
|
+
// `FIELD-WIDER-THAN-WORD`: ONE FIELD IS ONE BOX.
|
|
3654
|
+
//
|
|
3655
|
+
// A field wider than `word=` occupies several rows. Until this release each
|
|
3656
|
+
// row was a separate fully-bordered <rect> carrying the full label, so a
|
|
3657
|
+
// 128-bit address at word=32 drew as FOUR captioned boxes and a reader saw
|
|
3658
|
+
// four fields where the model has one. In examples/srh.fd it was worse: the
|
|
3659
|
+
// four rows sat directly above that element's elision strip, so the figure
|
|
3660
|
+
// read as "elements 0,1,2,3 are drawn, more are elided" —
|
|
3661
|
+
// asserting a count of 4 that is 128/32, an artifact of the word width,
|
|
3662
|
+
// while the document records that run as INDETERMINATE. The drawing stated
|
|
3663
|
+
// a number the source refuses to state. `MEANING-RECOVERY-SOURCE` forbids a READER to take a
|
|
3664
|
+
// quantity off the drawing; this is the other half of that duty, which is
|
|
3665
|
+
// the engine's: do not draw a quantity the model does not carry.
|
|
3666
|
+
//
|
|
3667
|
+
// The convention is the RFCs' own. RFC 8200 §3 and RFC 8754 §2 draw a
|
|
3668
|
+
// multi-row field with `+ +` at its internal boundaries — the side
|
|
3669
|
+
// verticals continue, the horizontal rule is ABSENT — and write the name
|
|
3670
|
+
// once, centred on the whole extent.
|
|
3671
|
+
//
|
|
3672
|
+
// But a spanning field is not always a rectangle: a 48-bit MAC at word=32
|
|
3673
|
+
// fills all of row N and columns 0..15 of row N+1, an L (the case that was
|
|
3674
|
+
// examples/ethernet-ii.fd, cut — conformance case 424 now
|
|
3675
|
+
// carries all three geometries). So the rule is not "drop the horizontal
|
|
3676
|
+
// line". It is:
|
|
3677
|
+
//
|
|
3678
|
+
// the boundary between two consecutive segments of the SAME field is
|
|
3679
|
+
// unstroked over the horizontal extent the two segments SHARE, and
|
|
3680
|
+
// stroked everywhere else.
|
|
3681
|
+
//
|
|
3682
|
+
// That is the RFCs' too, and not a generalisation of theirs: RFC 2892
|
|
3683
|
+
// §4.5/§4.6 draws exactly this L for a 48-bit MAC — the rule absent across
|
|
3684
|
+
// the field's own columns, a bare `+` at each end, the name once in the
|
|
3685
|
+
// wider piece. LaTeX `bytefield` §2.4 spells the same drawing as
|
|
3686
|
+
// `\bitbox[lrt]` over `\wordbox[lrb]`.
|
|
3687
|
+
//
|
|
3688
|
+
// Where that shared extent is EMPTY nothing can be suppressed and the field
|
|
3689
|
+
// has to draw as two boxes: `field "Sender Protocol Address" 32` in what
|
|
3690
|
+
// was examples/arp.fd starts at column 16 and ends at column 15 of the next
|
|
3691
|
+
// row, so its halves meet at one CORNER and share no column, and a corner
|
|
3692
|
+
// is not an edge a renderer can leave out. That is the ONLY geometry with
|
|
3693
|
+
// two boxes — every middle segment of a spanning field is a full row, so it
|
|
3694
|
+
// overlaps both neighbours, and disjointness can only ever happen between
|
|
3695
|
+
// the first segment and the second.
|
|
3696
|
+
//
|
|
3697
|
+
// The first cut of this rule captioned once per DRAWN BOX, which put the
|
|
3698
|
+
// name on both halves and reproduced the very misreading the rest of the
|
|
3699
|
+
// change removes — two captioned boxes read as two fields, and same-name
|
|
3700
|
+
// does not disambiguate in a language where eight fields may all be named
|
|
3701
|
+
// `U` (examples/srh.fd). So: THE NAME APPEARS ONCE, on the first box in
|
|
3702
|
+
// reading order, and every later box of the same field carries the
|
|
3703
|
+
// CONTINUATION MARK below instead. No box is left anonymous and no box
|
|
3704
|
+
// claims to be a field of its own.
|
|
3705
|
+
//
|
|
3706
|
+
// CONT — the continuation mark. RULE 4.1 says a vocabulary is borrowed
|
|
3707
|
+
// whole from ONE source and that invention is the last resort, so the
|
|
3708
|
+
// survey came first and it came back EMPTY: no packet- or register-diagram
|
|
3709
|
+
// tradition has a mark for this geometry. What was checked, and what each
|
|
3710
|
+
// does, so the next reader does not repeat it:
|
|
3711
|
+
//
|
|
3712
|
+
// RFC ASCII art — RFC 2892 §4.5/§4.6 is the one place the IETF
|
|
3713
|
+
// draws a 48-bit field wrapping MID-row: the
|
|
3714
|
+
// horizontal rule is absent across the field's
|
|
3715
|
+
// columns with a bare `+` at each end, and the
|
|
3716
|
+
// name is written ONCE, in the WIDER piece, the
|
|
3717
|
+
// narrow piece left blank. That is this engine's
|
|
3718
|
+
// L, and it corroborates the placement below —
|
|
3719
|
+
// but its pieces SHARE columns, so an unstroked
|
|
3720
|
+
// edge is available. RFC 826 never draws ARP's
|
|
3721
|
+
// addresses at all (a prose byte list), and RFC
|
|
3722
|
+
// 903 defers to it, so the corner-touching case
|
|
3723
|
+
// has no IETF drawing anywhere. LaTeX
|
|
3724
|
+
// `bytefield` §2.4 encodes the same habit
|
|
3725
|
+
// (`\bitbox[lrt]` over `\wordbox[lrb]`).
|
|
3726
|
+
// WaveDrom `bitfield` — clips the field per lane and writes the FULL
|
|
3727
|
+
// name into every lane, each fully boxed. That
|
|
3728
|
+
// is precisely the drawing this release removes.
|
|
3729
|
+
// IP-XACT / IEEE 1685 — a metadata schema with no rendering at all;
|
|
3730
|
+
// `fieldSegment` (1685-2022) slices a PORT onto
|
|
3731
|
+
// a field, not a field across a row. Nothing.
|
|
3732
|
+
// Kaitai / DFDL — neither draws a bit grid (Kaitai's graphviz
|
|
3733
|
+
// target is a pos/size/type table), so the case
|
|
3734
|
+
// cannot arise. Nothing.
|
|
3735
|
+
// ISO 5807 / ISO 128 — the off-page connector is control flow, and a
|
|
3736
|
+
// break line means material REMOVED, the
|
|
3737
|
+
// opposite claim. Wrong semantics, not adopted.
|
|
3738
|
+
//
|
|
3739
|
+
// So this is a coinage, and it is the minimal one: the ordinary
|
|
3740
|
+
// typographic abbreviation for a thing continued below, in italic to mark
|
|
3741
|
+
// it as the ENGINE's word and not the author's — the same division of
|
|
3742
|
+
// labour as the derived `present=` caption and the derived index suffix
|
|
3743
|
+
// (`DOMAIN-CONVENTION-DIRECTIVES`), and like those it has no option key and cannot be turned off.
|
|
3744
|
+
// It is deliberately TEXT. The two graphical marks this genre already
|
|
3745
|
+
// spends are the dashed stroke, which IS `present=` and which nothing else
|
|
3746
|
+
// may set or clear (`STYLE-KEY-SCOPE`), and the dotted elision strip `index=` draws
|
|
3747
|
+
// (whose `…` is why the mark is not an ellipsis) — a third stroke pattern
|
|
3748
|
+
// could not be read unambiguously against those two.
|
|
3749
|
+
//
|
|
3750
|
+
// REJECTED, so it is not re-litigated blind: the hardware-datasheet
|
|
3751
|
+
// convention, which is the one real prior art for the geometry — repeat
|
|
3752
|
+
// the name on both pieces with a bit-range suffix, `Base 31:24` /
|
|
3753
|
+
// `Base Address 15:00` (Intel SDM Vol. 3A Fig. 3-8), `COUNT[15:8]` /
|
|
3754
|
+
// `COUNT[7:0]` (Microchip DS40001882). It loses twice. It repeats the
|
|
3755
|
+
// name, which is the misreading being removed; and the suffix is a
|
|
3756
|
+
// COMPUTED bit range, which core §12.7 keeps out of the model on purpose
|
|
3757
|
+
// and `MEANING-RECOVERY-SOURCE` forbids a reader to take off the drawing — drawing one would
|
|
3758
|
+
// publish the number the model declines to hold. WHAT WOULD REOPEN IT: a
|
|
3759
|
+
// decision to materialize bit ranges in the model, which would make the
|
|
3760
|
+
// suffix a fact the drawing merely displays rather than one it invents.
|
|
3761
|
+
const CONT='(cont.)';
|
|
3762
|
+
//
|
|
3763
|
+
// boxOutline: the true outline of a box that is not a rectangle. Merge
|
|
3764
|
+
// vertically adjacent segments of equal extent into BANDS, then walk the
|
|
3765
|
+
// right side down and the left side back up. Every horizontal edge the walk
|
|
3766
|
+
// emits is a real boundary of the field; the shared part of an internal
|
|
3767
|
+
// boundary is never reached, which is the `+ +` above. A <path> is used
|
|
3768
|
+
// rather than rects-plus-edge-suppression because the shared edge is the
|
|
3769
|
+
// one edge a per-row rect cannot leave out: a rect strokes four sides or
|
|
3770
|
+
// none, and "none" would also lose the two verticals that must continue.
|
|
3771
|
+
// Fill stays a single closed region, so `fill=` paints the field once with
|
|
3772
|
+
// no seam, and the stroke carries `present=`'s dash around the whole
|
|
3773
|
+
// outline instead of around each row (`STYLE-KEY-SCOPE`: the dash IS conditional
|
|
3774
|
+
// presence, and nothing else may set or clear it).
|
|
3775
|
+
const boxOutline=(bx)=>{
|
|
3776
|
+
const bands=[];
|
|
3777
|
+
for(const s of bx){
|
|
3778
|
+
const t=bands[bands.length-1];
|
|
3779
|
+
if(t && t.x===s.x && t.w===s.w) t.y1=s.y+rh;
|
|
3780
|
+
else bands.push({x:s.x, w:s.w, y0:s.y, y1:s.y+rh});
|
|
3781
|
+
}
|
|
3782
|
+
const pts=[], add=(px,py)=>{ const t=pts[pts.length-1];
|
|
3783
|
+
if(!t || t[0]!==px || t[1]!==py) pts.push([px,py]); };
|
|
3784
|
+
add(bands[0].x+bands[0].w, bands[0].y0);
|
|
3785
|
+
for(let j=0;j<bands.length;j++){ // right side, downward
|
|
3786
|
+
add(bands[j].x+bands[j].w, bands[j].y1);
|
|
3787
|
+
if(j+1<bands.length) add(bands[j+1].x+bands[j+1].w, bands[j].y1);
|
|
3788
|
+
}
|
|
3789
|
+
add(bands[bands.length-1].x, bands[bands.length-1].y1); // the bottom
|
|
3790
|
+
for(let j=bands.length-1;j>=0;j--){ // left side, upward
|
|
3791
|
+
add(bands[j].x, bands[j].y0);
|
|
3792
|
+
if(j>0) add(bands[j-1].x, bands[j].y0);
|
|
3793
|
+
}
|
|
3794
|
+
return 'M'+pts.map(p=>p[0]+' '+p[1]).join(' L')+' Z';
|
|
3795
|
+
};
|
|
1961
3796
|
let pos=0; // bit cursor
|
|
1962
3797
|
for(const f of b.fields){
|
|
1963
|
-
if(f.wrap){ pos=Math.ceil((pos||1)/b.
|
|
3798
|
+
if(f.wrap){ pos=Math.ceil((pos||1)/b.word)*b.word; continue; }
|
|
1964
3799
|
// '*' = variable-length: fill the remainder of the current row
|
|
1965
|
-
const w0 = f.w==='*' ? (b.
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
const
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
svg.push('<text x="'+(x+span*cell/2)+'" y="'+(yy+rh/2+fs*0.35)+'" font-size="'+fs+'" text-anchor="middle">'+esc(f.name)+'</text>');
|
|
3800
|
+
const w0 = f.w==='*' ? (b.word - (pos % b.word)) : f.w;
|
|
3801
|
+
// The row segments ONE OCCURRENCE of this field occupies, in order — one
|
|
3802
|
+
// row's worth each. The bit cursor advances here and nowhere else, so
|
|
3803
|
+
// `break` and the elision strip keep meaning exactly what they meant.
|
|
3804
|
+
const cut=()=>{
|
|
3805
|
+
const segs=[]; let rem=w0;
|
|
3806
|
+
while(rem>0){
|
|
3807
|
+
const row=Math.floor(pos/b.word), col=pos%b.word;
|
|
3808
|
+
const span=Math.min(rem, b.word-col);
|
|
3809
|
+
segs.push({row:row, x:col*cell, w:span*cell, y:y+row*rh+shiftFor(row)});
|
|
3810
|
+
pos+=span; rem-=span;
|
|
1977
3811
|
}
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
3812
|
+
return segs;
|
|
3813
|
+
};
|
|
3814
|
+
// `STYLE-KEY-SCOPE`: the dash IS conditional presence, and nothing else
|
|
3815
|
+
// can set or clear it. `style=` used to win here under `LAYOUT-STABILITY` (the explicit
|
|
3816
|
+
// beats the convention), which meant `style=solid` erased the only mark
|
|
3817
|
+
// that carried the flag while the model kept it — `PRESENTATION-AS-MEANING-CARRIER`'s prohibition.
|
|
3818
|
+
// `PRESENCE-CONDITION-EXPRESSION`: the carrier is now `present=`, and BOTH of its
|
|
3819
|
+
// written forms dash — `present=""` claims conditional presence just as
|
|
3820
|
+
// `present="C = 1"` does; only the caption below distinguishes them.
|
|
3821
|
+
const dash=f.present!==undefined?' stroke-dasharray="5 3"':'';
|
|
3822
|
+
const cfill=f.fill||b.fill||'#fff';
|
|
3823
|
+
const paint=' fill="'+cfill+'" stroke="'+(f.stroke||b.stroke||'#555')+'"'+dash;
|
|
3824
|
+
// Draw one occurrence. `suffix` is the derived index label — '' for a
|
|
3825
|
+
// field that is not one element of a run, ' [0]' / ' [n]' for the two
|
|
3826
|
+
// occurrences `REPEATED-RUN-DRAWING` draws. It is APPENDED to the author's label, and the
|
|
3827
|
+
// label is never read (see the `REPEATED-RUN-DRAWING` note above).
|
|
3828
|
+
const drawOcc=(segs,suffix)=>{
|
|
3829
|
+
// Group the segments into DRAWN BOXES. Two consecutive segments join when
|
|
3830
|
+
// they are vertically adjacent AND share a positive horizontal extent.
|
|
3831
|
+
// The vertical test is not decoration: an elision strip can open a 16px
|
|
3832
|
+
// band between two rows, and a box must never be drawn across one.
|
|
3833
|
+
const boxes=[[segs[0]]];
|
|
3834
|
+
for(let i=1;i<segs.length;i++){
|
|
3835
|
+
const p=segs[i-1], q=segs[i];
|
|
3836
|
+
if(q.y===p.y+rh && Math.min(p.x+p.w,q.x+q.w) > Math.max(p.x,q.x))
|
|
3837
|
+
boxes[boxes.length-1].push(q);
|
|
3838
|
+
else boxes.push([q]);
|
|
1981
3839
|
}
|
|
3840
|
+
boxes.forEach(function(bx,bi){
|
|
3841
|
+
// `DESCRIPTION-KEY-SPELLING`: the `<title>` is a CHILD of the shape it names, not
|
|
3842
|
+
// a sibling. Until this release it was pushed into the block's stream
|
|
3843
|
+
// after the rect and the label, so it landed under the figure's single
|
|
3844
|
+
// <g> — and SVG says a <title> names its PARENT, so every description in
|
|
3845
|
+
// a figure named the same <g> and a conforming UA showed one arbitrary
|
|
3846
|
+
// tooltip for the whole figure. Written on the FIRST box of a spanning
|
|
3847
|
+
// field only: one field, one description.
|
|
3848
|
+
const desc=(f.description!==undefined && bi===0)?'<title>'+esc(f.description)+'</title>':'';
|
|
3849
|
+
// A box whose segments all have the same horizontal extent is a plain
|
|
3850
|
+
// rectangle — the RFC's `+ +` case, and the only shape a
|
|
3851
|
+
// single-segment field can have, which is why an unspanned field's
|
|
3852
|
+
// bytes are unchanged by all of this.
|
|
3853
|
+
const flat=bx.every(s=>s.x===bx[0].x&&s.w===bx[0].w);
|
|
3854
|
+
const shape=flat
|
|
3855
|
+
? '<rect x="'+bx[0].x+'" y="'+bx[0].y+'" width="'+bx[0].w+'" height="'+(bx.length*rh)+'"'+paint
|
|
3856
|
+
: '<path d="'+boxOutline(bx)+'"'+paint;
|
|
3857
|
+
const tag=flat?'rect':'path';
|
|
3858
|
+
svg.push(desc?shape+'>'+desc+'</'+tag+'>':shape+'/>');
|
|
3859
|
+
// ONE caption per box: the NAME on the first box, CONT on every later
|
|
3860
|
+
// one. The first box, not the widest — reading order is the order the
|
|
3861
|
+
// bits are in, so the name sits where the field starts, and a reader who
|
|
3862
|
+
// meets `(cont.)` has already met the name. Choosing the widest box
|
|
3863
|
+
// instead would put the name after the mark whenever the tail is the
|
|
3864
|
+
// roomier half, which is the ARP case (16 columns against 16 — a tie
|
|
3865
|
+
// that only reading order breaks anyway).
|
|
3866
|
+
//
|
|
3867
|
+
// A flat box is one open area, so it takes the RFC's placement: centred
|
|
3868
|
+
// on the box's whole vertical extent. A non-flat box has no row spanning
|
|
3869
|
+
// that extent, and centring there would put ink across a stroked
|
|
3870
|
+
// boundary or across columns that belong to the next field — so the
|
|
3871
|
+
// caption goes in the box's WIDEST segment, its largest contiguous area,
|
|
3872
|
+
// ties keeping the topmost.
|
|
3873
|
+
const cap=bi===0?String(f.name)+suffix:CONT;
|
|
3874
|
+
let lead=bx[0]; for(const s of bx) if(s.w>lead.w) lead=s;
|
|
3875
|
+
const ly=flat ? bx[0].y+bx.length*rh/2 : lead.y+rh/2;
|
|
3876
|
+
let fs=11; const need=cap.length*6.2;
|
|
3877
|
+
// The font shrinks to fit the segment that carries it, floor 7px — the
|
|
3878
|
+
// rule a too-narrow one-row field has always had. Below the floor the
|
|
3879
|
+
// caption overflows its box rather than vanishing: a name half outside
|
|
3880
|
+
// its box is legible, an undrawn one leaves a box no reader can
|
|
3881
|
+
// identify — and that applies to `(cont.)` for the same reason, since a
|
|
3882
|
+
// one-column continuation (`field "x" 2` starting at the last column)
|
|
3883
|
+
// is narrower than the mark at any size.
|
|
3884
|
+
if(need>lead.w-6) fs=Math.max(7,11*(lead.w-6)/need);
|
|
3885
|
+
svg.push('<text x="'+(lead.x+lead.w/2)+'" y="'+(ly+fs*0.35)+'" font-size="'+fs+'" text-anchor="middle"'+(bi?' font-style="italic"':'')+inkAttr(cfill)+'>'+esc(cap)+'</text>');
|
|
3886
|
+
});
|
|
3887
|
+
};
|
|
3888
|
+
const ix=f.index;
|
|
3889
|
+
// `REPEATED-RUN-DRAWING`: the FIRST element carries `[first]` — but only when the author
|
|
3890
|
+
// stated one. `index=""` names no index, so nothing is appended and only
|
|
3891
|
+
// this one occurrence is drawn.
|
|
3892
|
+
const stated = ix!==undefined && ix.first!==undefined;
|
|
3893
|
+
const first=cut();
|
|
3894
|
+
if(!first.length) continue; // a zero-width field draws nothing (and cannot parse)
|
|
3895
|
+
drawOcc(first, stated?' ['+ix.first+']':'');
|
|
3896
|
+
if(ix!==undefined){
|
|
3897
|
+
// The elements between the two drawn ones. `null` = indeterminate: a
|
|
3898
|
+
// prose last index, or `index=""`, which states no index to subtract.
|
|
3899
|
+
const undrawn = (typeof ix.last==='number') ? Math.abs(ix.last-ix.first)-1 : null;
|
|
3900
|
+
// The elision strip, directly under the FIRST element's last drawn row —
|
|
3901
|
+
// between the two occurrences, which is where RFC 8754 §2 prints its
|
|
3902
|
+
// `...`. Not drawn when nothing is elided; see the `REPEATED-RUN-DRAWING` note above.
|
|
3903
|
+
if(undrawn!==0){
|
|
3904
|
+
const ls=first[first.length-1];
|
|
3905
|
+
elis.push({row:ls.row, x:ls.x, w:ls.w, y:ls.y+rh, text:'…'});
|
|
3906
|
+
}
|
|
3907
|
+
if(stated){
|
|
3908
|
+
// The LAST element is the first one translated straight down by its
|
|
3909
|
+
// own height, so the two occurrences stand in the same columns and
|
|
3910
|
+
// read as the same shape repeated. The cursor moves by whole ROWS to
|
|
3911
|
+
// get there — the skipped cells are blank drawing, in the same family
|
|
3912
|
+
// as the ones a `break` leaves, and are NOT bits.
|
|
3913
|
+
pos += first.length*b.word - w0;
|
|
3914
|
+
drawOcc(cut(), ' ['+ix.last+']');
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
}
|
|
3918
|
+
for(const e of elis){
|
|
3919
|
+
const st=(b.stroke||'#555');
|
|
3920
|
+
// `ELISION-MARK-EXTENT`: THE SIDE DOTS SAY WHICH COLUMNS ARE ELIDED, so a
|
|
3921
|
+
// strip that already spans the whole word does not draw them. RFC 8754 §2
|
|
3922
|
+
// prints its `...` row with NO `|` at either end — the box is simply open
|
|
3923
|
+
// there — and an element occupying the full word needs no mark to say
|
|
3924
|
+
// which columns it occupied, because it occupied all of them. The dots
|
|
3925
|
+
// still carry information for a narrower element (reference/bitfield.fd's
|
|
3926
|
+
// `Queue Depth` runs columns 16-31), where without them the strip's extent
|
|
3927
|
+
// is guesswork. Derived from the geometry, never an option key: the same
|
|
3928
|
+
// shape as `REPEATED-RUN-DRAWING`'s rule for the mark itself, which draws iff something is
|
|
3929
|
+
// in fact undrawn.
|
|
3930
|
+
if(e.w < b.word*cell){
|
|
3931
|
+
svg.push('<line x1="'+e.x+'" y1="'+e.y+'" x2="'+e.x+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
|
|
3932
|
+
svg.push('<line x1="'+(e.x+e.w)+'" y1="'+e.y+'" x2="'+(e.x+e.w)+'" y2="'+(e.y+EL_H)+'" stroke="'+st+'" stroke-dasharray="2 3"/>');
|
|
3933
|
+
}
|
|
3934
|
+
let fs=10.5; const need=e.text.length*6.2;
|
|
3935
|
+
if(need>e.w-6) fs=Math.max(7,10.5*(e.w-6)/need);
|
|
3936
|
+
svg.push('<text x="'+(e.x+e.w/2)+'" y="'+(e.y+EL_H/2+fs*0.35)+'" font-size="'+fs+'" text-anchor="middle" fill="#6f6e69">'+esc(e.text)+'</text>');
|
|
3937
|
+
}
|
|
3938
|
+
const rows=Math.max(1,Math.ceil(pos/b.word));
|
|
3939
|
+
let yb=y+rows*rh+elisBands(null)*EL_H+6, wb=b.word*cell+2;
|
|
3940
|
+
// `PRESENCE-CONDITION-EXPRESSION`: the DERIVED presence caption. `present=` must DRAW, or
|
|
3941
|
+
// the condition would be visible to a reading agent and invisible to the
|
|
3942
|
+
// human looking at the same figure — half an inversion, and the shape `CLASS-EMPTY-MEANING`
|
|
3943
|
+
// rejected `legend=hide` for. The author names the meaning; the engine owns
|
|
3944
|
+
// the drawing convention (`DOMAIN-CONVENTION-DIRECTIVES`), exactly as with the derived legend and the
|
|
3945
|
+
// derived bundle ring — there is no option key for this.
|
|
3946
|
+
// `present=""` draws NO caption line: the author claimed nothing, and
|
|
3947
|
+
// "condition unknown" would be the engine speaking for the author.
|
|
3948
|
+
const capt=b.fields.filter(f=>f.present);
|
|
3949
|
+
if(capt.length){
|
|
3950
|
+
yb+=4;
|
|
3951
|
+
for(const f of capt){
|
|
3952
|
+
const s=f.name+' — present: '+f.present;
|
|
3953
|
+
svg.push('<text x="0" y="'+(yb+9)+'" font-size="10.5" fill="#6f6e69">'+esc(s)+'</text>');
|
|
3954
|
+
wb=Math.max(wb, s.length*6.3+2); yb+=14;
|
|
3955
|
+
}
|
|
3956
|
+
yb+=2;
|
|
1982
3957
|
}
|
|
1983
|
-
|
|
1984
|
-
return {svg:svg.join(''), y:y+rows*rh+6, w:b.unit*cell+2};
|
|
3958
|
+
return {svg:svg.join(''), y:yb, w:wb};
|
|
1985
3959
|
}
|
|
1986
3960
|
|
|
1987
3961
|
// ---- table (with ^ rowspan / < colspan merging and per-cell marks) ----
|
|
1988
3962
|
function renderTable(t,y0){
|
|
1989
|
-
|
|
3963
|
+
// Per-row height grows with multi-line cells (<br> → U+000A at parse).
|
|
3964
|
+
// Single-line rows keep the historical 26px so existing figures stay put.
|
|
3965
|
+
const CFS=12, CLH=CFS*1.3, CMIN=26;
|
|
3966
|
+
const svg=[]; let y=y0+18;
|
|
1990
3967
|
svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(t.label)+'</text>');
|
|
1991
3968
|
// grid: header tiers (from `head`/`cols` lines) then data rows
|
|
1992
3969
|
const H=t.heads.length;
|
|
@@ -1994,20 +3971,29 @@ function renderTable(t,y0){
|
|
|
1994
3971
|
.concat(t.rows.map(r=>r.cells.map(c=>({v:c.v,m:c.m}))));
|
|
1995
3972
|
const hlRow=r=>r>=H&&(t.rowmarks||[]).some(mk=>mk.r===r-H+1);
|
|
1996
3973
|
const alignOf=c=>(t.aligns&&t.aligns[c])||null;
|
|
3974
|
+
const nLines=v=>Math.max(1,String(v==null?'':v).split('\n').length);
|
|
1997
3975
|
const widths=t.cols.map((c,i)=>{
|
|
1998
3976
|
let w=30;
|
|
1999
3977
|
for(const hr of t.heads) if(!hr[i].m) w=Math.max(w,tw(hr[i].v));
|
|
2000
3978
|
for(const r of t.rows) if(!r.cells[i].m) w=Math.max(w,tw(r.cells[i].v));
|
|
2001
3979
|
return w;
|
|
2002
3980
|
});
|
|
2003
|
-
if(t.
|
|
3981
|
+
if(t.width){ // width: auto = natural, px fixed, % of natural total
|
|
2004
3982
|
const base=widths.reduce((a,b2)=>a+b2,0);
|
|
2005
|
-
t.
|
|
3983
|
+
t.width.vals.forEach((v,i)=>{
|
|
2006
3984
|
if(v.t==='px') widths[i]=v.v;
|
|
2007
3985
|
else if(v.t==='pct') widths[i]=v.v/100*base;
|
|
2008
3986
|
});
|
|
2009
3987
|
}
|
|
2010
3988
|
const totalW=widths.reduce((a,b)=>a+b,0);
|
|
3989
|
+
// row heights from max line-count among anchors that start on that row
|
|
3990
|
+
const rowH=grid.map(row=>{
|
|
3991
|
+
let ml=1;
|
|
3992
|
+
for(let c=0;c<row.length;c++) if(!row[c].m) ml=Math.max(ml,nLines(row[c].v));
|
|
3993
|
+
return Math.max(CMIN, Math.round(8+ml*CLH));
|
|
3994
|
+
});
|
|
3995
|
+
const yAt=[0];
|
|
3996
|
+
for(let r=0;r<grid.length;r++) yAt.push(yAt[r]+rowH[r]);
|
|
2011
3997
|
// cell marks: h1..hN address header tiers top-down, r>=1 the data rows
|
|
2012
3998
|
const markOf=(r,c)=>(t.marks||[]).find(mk=>(mk.hdr?mk.r-1:H+mk.r-1)===r&&mk.c===c+1);
|
|
2013
3999
|
const yTop=y+4;
|
|
@@ -2019,42 +4005,51 @@ function renderTable(t,y0){
|
|
|
2019
4005
|
let rs=1; while(r+rs<grid.length && grid[r+rs][c].m==='up') rs++;
|
|
2020
4006
|
const x=widths.slice(0,c).reduce((a,b)=>a+b,0);
|
|
2021
4007
|
const wsum=widths.slice(c,c+cs).reduce((a,b)=>a+b,0);
|
|
2022
|
-
const yy=yTop+r
|
|
4008
|
+
const yy=yTop+yAt[r], h=yAt[r+rs]-yAt[r];
|
|
2023
4009
|
const mk=markOf(r,c);
|
|
2024
|
-
|
|
4010
|
+
// block fill= is the default DATA-cell fill (header tint is structural)
|
|
4011
|
+
const fill=(mk&&mk.fill)||(cell.hdr?'#eeede6':(hlRow(r)?'#fef3c7':(t.fill||'#fff')));
|
|
2025
4012
|
// addressable cells carry table-id:row:col (row 0 = bottom header tier)
|
|
2026
4013
|
const addrR = r>=H ? (r-H+1) : (r===H-1 ? 0 : null);
|
|
2027
4014
|
const addr = addrR===null ? '' : ' data-cell="'+t.id+':'+addrR+':'+(c+1)+'" style="cursor:pointer"';
|
|
2028
|
-
svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="#c9c7bf"'+addr+'/>');
|
|
4015
|
+
svg.push('<rect x="'+x+'" y="'+yy+'" width="'+wsum+'" height="'+h+'" fill="'+fill+'" stroke="'+((mk&&mk.stroke)||t.stroke||'#c9c7bf')+'"'+addr+'/>');
|
|
2029
4016
|
// alignment: headers centered; data follows GFM colon alignment (default left)
|
|
2030
4017
|
const al=cell.hdr?'center':(alignOf(c)||'left');
|
|
2031
4018
|
const tx=al==='center'?x+wsum/2:(al==='right'?x+wsum-7:x+7);
|
|
2032
4019
|
const anchor=al==='center'?'middle':(al==='right'?'end':'start');
|
|
2033
|
-
|
|
4020
|
+
// multi-line via U+000A (from <br> at parse). Single-line path keeps
|
|
4021
|
+
// the historical <text> shape (no default fill=) so figures without
|
|
4022
|
+
// <br> stay byte-stable; multi-line uses textEl like node labels.
|
|
4023
|
+
const cv=cell.v==null?'':String(cell.v);
|
|
4024
|
+
if(cv.indexOf('\n')<0){
|
|
4025
|
+
svg.push('<text x="'+tx+'" y="'+(yy+h/2+4.3)+'" font-size="12" text-anchor="'+anchor+'"'+(cell.hdr?' font-weight="600"':'')+inkAttr(fill)+'>'+esc(cv)+'</text>');
|
|
4026
|
+
} else {
|
|
4027
|
+
svg.push(textEl(tx, yy+h/2+4, CFS, anchor, labelInk(fill,'#1d1d1b'), cv, cell.hdr?' font-weight="600"':''));
|
|
4028
|
+
}
|
|
2034
4029
|
}
|
|
2035
4030
|
}
|
|
2036
|
-
const yEnd=yTop+grid.length
|
|
4031
|
+
const yEnd=yTop+yAt[grid.length];
|
|
2037
4032
|
return {svg:svg.join(''), y:yEnd+6, w:totalW+2};
|
|
2038
4033
|
}
|
|
2039
4034
|
|
|
2040
|
-
// ----
|
|
4035
|
+
// ---- chart bar3d: deterministic isometric projection of a table ----
|
|
2041
4036
|
function shade(hex,f){
|
|
2042
4037
|
const v=parseInt(hex.slice(1),16);
|
|
2043
4038
|
const c=x=>Math.round(Math.max(0,Math.min(255,x))).toString(16).padStart(2,'0');
|
|
2044
4039
|
return '#'+c(((v>>16)&255)*f)+c(((v>>8)&255)*f)+c((v&255)*f);
|
|
2045
4040
|
}
|
|
2046
|
-
const
|
|
2047
|
-
function
|
|
4041
|
+
const CHART_PALETTE=['#3b82f6','#22c55e','#f59e0b','#ef4444','#a855f7','#14b8a6','#eab308','#64748b'];
|
|
4042
|
+
function renderChart(b,y0,doc){
|
|
2048
4043
|
const t=doc.blocks.find(x=>x.type==='table'&&x.id===b.tid);
|
|
2049
4044
|
const rows=t.rows.map(r=>r.cells.slice(1).map(c=>parseFloat(c.v)||0));
|
|
2050
4045
|
const rLab=t.rows.map(r=>r.cells[0].v), cLab=t.cols.slice(1);
|
|
2051
4046
|
const R=rows.length, C=cLab.length;
|
|
2052
|
-
const zmax=Math.max(
|
|
4047
|
+
const zmax=Math.max(...rows.flat(), 1);
|
|
2053
4048
|
const W2=20,H2=10,ZS=130/zmax,BAR=0.72;
|
|
2054
4049
|
const ox=R*W2+8, oy=y0+18+ZS*zmax+6;
|
|
2055
4050
|
const P=(r,c,z)=>[ox+(c-r)*W2, oy+(c+r)*H2-z*ZS];
|
|
2056
4051
|
const svg=[];
|
|
2057
|
-
svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' —
|
|
4052
|
+
svg.push('<text x="0" y="'+(y0+14)+'" font-size="13" font-weight="600">'+esc(t.label)+' — bar3d</text>');
|
|
2058
4053
|
// floor grid edges
|
|
2059
4054
|
const F=[P(0,0,0),P(R,0,0),P(R,C,0),P(0,C,0)];
|
|
2060
4055
|
svg.push('<polygon points="'+F.map(p=>p.join(',')).join(' ')+'" fill="#f6f5ef" stroke="#d6d4cc"/>');
|
|
@@ -2064,7 +4059,7 @@ function renderPlot(b,y0,doc){
|
|
|
2064
4059
|
order.sort((a,b2)=>(a[0]+a[1])-(b2[0]+b2[1]));
|
|
2065
4060
|
for(const [r,c] of order){
|
|
2066
4061
|
const h=rows[r][c]; if(h<=0) continue;
|
|
2067
|
-
const col=
|
|
4062
|
+
const col=CHART_PALETTE[c%CHART_PALETTE.length];
|
|
2068
4063
|
const i0=r+(1-BAR)/2, i1=r+(1+BAR)/2, j0=c+(1-BAR)/2, j1=c+(1+BAR)/2;
|
|
2069
4064
|
const A=P(i0,j0,h),Bp=P(i1,j0,h),Cp=P(i1,j1,h),D=P(i0,j1,h);
|
|
2070
4065
|
const B0=P(i1,j0,0),C0=P(i1,j1,0),D0=P(i0,j1,0);
|
|
@@ -2072,12 +4067,6 @@ function renderPlot(b,y0,doc){
|
|
|
2072
4067
|
svg.push('<polygon points="'+[Cp,D,D0,C0].map(p=>p.join(',')).join(' ')+'" fill="'+shade(col,0.55)+'"/>');
|
|
2073
4068
|
svg.push('<polygon points="'+[A,Bp,Cp,D].map(p=>p.join(',')).join(' ')+'" fill="'+col+'"/>');
|
|
2074
4069
|
}
|
|
2075
|
-
// threshold plane (translucent, drawn over bars like the convention)
|
|
2076
|
-
if(b.level!==null&&b.level!==undefined){
|
|
2077
|
-
const L=[P(0,0,b.level),P(R,0,b.level),P(R,C,b.level),P(0,C,b.level)];
|
|
2078
|
-
svg.push('<polygon points="'+L.map(p=>p.join(',')).join(' ')+'" fill="#ef4444" opacity="0.18" stroke="#ef4444" stroke-dasharray="6 4"/>');
|
|
2079
|
-
svg.push(textEl(L[3][0]+8, L[3][1]+4, 11, 'start', '#ef4444', 'level '+b.level,' paint-order="stroke" stroke="#fff" stroke-width="3"'));
|
|
2080
|
-
}
|
|
2081
4070
|
// axis labels
|
|
2082
4071
|
rLab.forEach((l,r)=>{ const p=P(r+0.5,-0.15,0); svg.push(textEl(p[0]-4,p[1]+10,10,'end','#6f6e69',l)); });
|
|
2083
4072
|
cLab.forEach((l,c)=>{ const p=P(R+0.15,c+0.5,0); svg.push(textEl(p[0]+4,p[1]+10,10,'start','#6f6e69',l)); });
|
|
@@ -2092,57 +4081,63 @@ function renderPlot(b,y0,doc){
|
|
|
2092
4081
|
return {svg:svg.join(''), y:y0+hgt, w:w};
|
|
2093
4082
|
}
|
|
2094
4083
|
|
|
2095
|
-
// ----
|
|
2096
|
-
function
|
|
2097
|
-
const
|
|
4084
|
+
// ---- timing ----
|
|
4085
|
+
function renderTiming(w,y0){
|
|
4086
|
+
const cycleW=26, laneH=30, laneGap=12, nameW=Math.max(...w.signals.map(s=>tw(s.name)),60);
|
|
2098
4087
|
const svg=[]; let y=y0+18;
|
|
2099
4088
|
svg.push('<text x="0" y="'+(y-4)+'" font-size="13" font-weight="600">'+esc(w.label)+'</text>');
|
|
2100
|
-
const
|
|
4089
|
+
const cycles=Math.max(...w.signals.map(s=>s.lane.length));
|
|
2101
4090
|
w.signals.forEach((s,si)=>{
|
|
2102
4091
|
const top=y+8+si*(laneH+laneGap), bot=top+laneH-8;
|
|
4092
|
+
// §5 per signal: fill= data-box fill, stroke= the waveform colour,
|
|
4093
|
+
// style= the path dash. 0.1 (`LABEL-COLOUR-SOURCE`): the signal NAME sits in the
|
|
4094
|
+
// left gutter, on the canvas, so it takes the canvas ink; only the data
|
|
4095
|
+
// labels sit on `sbox` and derive from it.
|
|
4096
|
+
const sbox=s.fill||w.fill||'#eef6ff', spath=s.stroke||w.stroke||'#1d4ed8';
|
|
2103
4097
|
svg.push('<text x="'+(nameW-10)+'" y="'+(top+(laneH-8)/2+4)+'" font-size="11.5" text-anchor="end" font-family="monospace">'+esc(s.name)+'</text>');
|
|
2104
4098
|
let d='', prev=null, li=0, dataIdx=0;
|
|
2105
4099
|
for(let i=0;i<s.lane.length;i++){
|
|
2106
4100
|
let ch=s.lane[i];
|
|
2107
4101
|
if(ch==='.') ch=prev||'0';
|
|
2108
|
-
const x=nameW+i*
|
|
4102
|
+
const x=nameW+i*cycleW;
|
|
2109
4103
|
if(ch==='p'||ch==='n'){
|
|
2110
4104
|
const hiFirst=(ch==='p');
|
|
2111
4105
|
const a=hiFirst?top:bot, b=hiFirst?bot:top;
|
|
2112
|
-
d+='M'+x+','+a+' L'+(x+
|
|
2113
|
-
// draw transition edge at
|
|
4106
|
+
d+='M'+x+','+a+' L'+(x+cycleW/2)+','+a+' L'+(x+cycleW/2)+','+b+' L'+(x+cycleW)+','+b+' ';
|
|
4107
|
+
// draw transition edge at cycle start
|
|
2114
4108
|
if(prev) d+='M'+x+','+top+' L'+x+','+bot+' ';
|
|
2115
4109
|
} else if(ch==='0'||ch==='1'){
|
|
2116
4110
|
const yy=(ch==='1')?top:bot;
|
|
2117
4111
|
const pv=(prev==='1')?top:(prev==='0'?bot:null);
|
|
2118
4112
|
if(pv!==null&&pv!==yy) d+='M'+x+','+pv+' L'+x+','+yy+' ';
|
|
2119
|
-
d+='M'+x+','+yy+' L'+(x+
|
|
4113
|
+
d+='M'+x+','+yy+' L'+(x+cycleW)+','+yy+' ';
|
|
2120
4114
|
} else if(ch==='x'){
|
|
2121
|
-
svg.push('<rect x="'+x+'" y="'+top+'" width="'+
|
|
2122
|
-
} else if(
|
|
4115
|
+
svg.push('<rect x="'+x+'" y="'+top+'" width="'+cycleW+'" height="'+(bot-top)+'" fill="url(#hatch)" stroke="#999"/>');
|
|
4116
|
+
} else if(ch==='='){
|
|
2123
4117
|
// merge consecutive identical data chars
|
|
2124
4118
|
let j=i; while(j+1<s.lane.length && s.lane[j+1]==='.') j++;
|
|
2125
4119
|
const span=j-i+1;
|
|
2126
|
-
svg.push('<rect x="'+x+'" y="'+top+'" width="'+(
|
|
2127
|
-
const lbl =
|
|
2128
|
-
if(lbl) svg.push('<text x="'+(x+
|
|
4120
|
+
svg.push('<rect x="'+x+'" y="'+top+'" width="'+(cycleW*span)+'" height="'+(bot-top)+'" fill="'+sbox+'" stroke="'+(s.stroke||w.stroke||'#7aa7d9')+'"/>');
|
|
4121
|
+
const lbl = (s.labels&&s.labels[dataIdx++])||'';
|
|
4122
|
+
if(lbl) svg.push('<text x="'+(x+cycleW*span/2)+'" y="'+((top+bot)/2+4)+'" font-size="10.5" text-anchor="middle"'+inkAttr(sbox)+'>'+esc(lbl)+'</text>');
|
|
2129
4123
|
i=j;
|
|
2130
4124
|
}
|
|
2131
4125
|
prev=ch;
|
|
2132
4126
|
}
|
|
2133
|
-
if(d) svg.push('<path d="'+d+'" fill="none" stroke="
|
|
4127
|
+
if(d) svg.push('<path d="'+d+'" fill="none" stroke="'+spath+'" stroke-width="1.6"/>');
|
|
2134
4128
|
});
|
|
2135
4129
|
for(const g of w.gaps){
|
|
2136
|
-
const
|
|
4130
|
+
const cycle=(g&&typeof g==='object')?g.t:g;
|
|
4131
|
+
const x=nameW+cycle*cycleW;
|
|
2137
4132
|
const hTotal=w.signals.length*(laneH+laneGap);
|
|
2138
4133
|
svg.push('<path d="M'+x+','+(y+4)+' q4,'+(hTotal/4)+' 0,'+(hTotal/2)+' q-4,'+(hTotal/4)+' 0,'+(hTotal/2)+'" fill="none" stroke="#999" stroke-width="2"/>');
|
|
2139
4134
|
}
|
|
2140
4135
|
const H=y+8+w.signals.length*(laneH+laneGap);
|
|
2141
|
-
return {svg:svg.join(''), y:H, w:nameW+
|
|
4136
|
+
return {svg:svg.join(''), y:H, w:nameW+cycles*cycleW+2};
|
|
2142
4137
|
}
|
|
2143
4138
|
|
|
2144
4139
|
// ============================================================
|
|
2145
|
-
return { parse: parse, render: render };
|
|
4140
|
+
return { parse: parse, render: render, stackSectionSvgs: stackSectionSvgs };
|
|
2146
4141
|
})();
|
|
2147
4142
|
|
|
2148
4143
|
// ---- minimal synchronous SHA-256 (FIPS 180-4), hex output ----
|
|
@@ -2193,39 +4188,65 @@ function __sha256hex(text) {
|
|
|
2193
4188
|
}
|
|
2194
4189
|
|
|
2195
4190
|
// ---- public API ----
|
|
2196
|
-
// parse(text) -> { doc, errors }
|
|
4191
|
+
// parse(text) -> { doc, errors, docs }
|
|
4192
|
+
// errors: "Line N: message"
|
|
4193
|
+
// docs: one entry per figdown section (length 1 for ordinary files)
|
|
4194
|
+
// doc: docs[0] (compat for single-section callers)
|
|
2197
4195
|
function parse(text) {
|
|
2198
4196
|
var p = __engine.parse(String(text));
|
|
2199
|
-
|
|
4197
|
+
var docs = p.docs && p.docs.length ? p.docs : (p.doc ? [p.doc] : []);
|
|
4198
|
+
return { doc: p.doc, errors: p.errs, docs: docs };
|
|
4199
|
+
}
|
|
4200
|
+
// Stack multi-section SVGs (MULTI-FIGURE-DOCUMENTS): one .fd → one .svg, sections top-to-bottom.
|
|
4201
|
+
function __stackSectionSvgs(results) {
|
|
4202
|
+
if (typeof __engine.stackSectionSvgs === 'function')
|
|
4203
|
+
return __engine.stackSectionSvgs(results);
|
|
4204
|
+
// fallback if engine build is older
|
|
4205
|
+
return results[0] && results[0].svg;
|
|
2200
4206
|
}
|
|
2201
4207
|
// render(text, opts) -> { svg, errors } svg is null when there are errors
|
|
2202
4208
|
// (determinism over convenience: no partial renders of invalid input).
|
|
2203
4209
|
// opts (presentation, renderer tier): { title: true } draws the title;
|
|
2204
4210
|
// the default does NOT (embedded figures almost always sit under the
|
|
2205
4211
|
// host document's caption — the majority case).
|
|
4212
|
+
// Multi-section sources are stacked vertically into a single SVG (MULTI-FIGURE-DOCUMENTS).
|
|
2206
4213
|
function render(text, opts) {
|
|
2207
4214
|
var p = parse(text);
|
|
2208
4215
|
if (p.errors.length) return { svg: null, errors: p.errors };
|
|
4216
|
+
if (p.docs.length > 1) {
|
|
4217
|
+
var rs = p.docs.map(function (d) { return __engine.render(d, opts); });
|
|
4218
|
+
return { svg: __engine.stackSectionSvgs(rs), errors: [] };
|
|
4219
|
+
}
|
|
2209
4220
|
return { svg: __engine.render(p.doc, opts).svg, errors: [] };
|
|
2210
4221
|
}
|
|
2211
4222
|
// renderDoc(doc, opts) -> svg string, for an already-validated doc from parse().
|
|
4223
|
+
// For multi-section, pass parse().docs to renderDocs instead.
|
|
2212
4224
|
function renderDoc(doc, opts) {
|
|
2213
4225
|
return __engine.render(doc, opts).svg;
|
|
2214
4226
|
}
|
|
4227
|
+
function renderDocs(docs, opts) {
|
|
4228
|
+
if (!docs || !docs.length) return '';
|
|
4229
|
+
if (docs.length === 1) return __engine.render(docs[0], opts).svg;
|
|
4230
|
+
return __engine.stackSectionSvgs(docs.map(function (d) { return __engine.render(d, opts); }));
|
|
4231
|
+
}
|
|
2215
4232
|
// artifact(text) -> { svg, errors } svg is the full self-carrying SVG:
|
|
2216
4233
|
// the render plus a <metadata id="figdown-source"> block embedding the
|
|
2217
|
-
// source text
|
|
4234
|
+
// source text, the SHA-256 OF THAT SOURCE, and the engine version that
|
|
4235
|
+
// rendered it (same convention as tools/build-svg.js; spec core §7).
|
|
2218
4236
|
// svg is null when there are errors.
|
|
2219
4237
|
function artifact(text, opts) {
|
|
2220
4238
|
var src = String(text);
|
|
2221
4239
|
var p = render(src, opts);
|
|
2222
4240
|
if (p.errors.length) return { svg: null, errors: p.errors };
|
|
2223
|
-
//
|
|
4241
|
+
// The artifact records the SHA-256 OF THE SOURCE, the ENGINE VERSION that
|
|
4242
|
+
// rendered it, and any non-default render option (core §7) — together they
|
|
4243
|
+
// keep third-party rebuilds bit-identical and give a diff somewhere to point
|
|
2224
4244
|
var optAttr = (opts && opts.title === true) ? ' data-render-options="with-title"' : '';
|
|
2225
|
-
var meta = '<metadata id="figdown-source" data-sha256="' + __sha256hex(src) + '"'
|
|
4245
|
+
var meta = '<metadata id="figdown-source" data-sha256="' + __sha256hex(src) + '"'
|
|
4246
|
+
+ ' data-engine-version="' + VERSION + '"' + optAttr + '><![CDATA[\n'
|
|
2226
4247
|
+ src.replace(/]]>/g, ']]]]><![CDATA[>') + '\n]]></metadata>';
|
|
2227
4248
|
return { svg: p.svg.replace(/<\/svg>$/, meta + '</svg>'), errors: [] };
|
|
2228
4249
|
}
|
|
2229
4250
|
|
|
2230
4251
|
var version = VERSION;
|
|
2231
|
-
export { parse, render, renderDoc, artifact, version };
|
|
4252
|
+
export { parse, render, renderDoc, renderDocs, artifact, version };
|