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