sdocs-dev 1.15.0 → 1.18.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/bin/sdocs-dev.js +35 -11
- package/lib/agent-block.js +253 -50
- package/lib/agent-files.js +308 -74
- package/lib/cells-verify.js +1 -0
- package/lib/cloud-bindings.js +85 -0
- package/lib/cloud-commands.js +741 -0
- package/lib/cloud-credentials.js +380 -0
- package/lib/commands.js +44 -4
- package/lib/help-text.js +272 -70
- package/lib/io.js +44 -3
- package/lib/library-commands.js +6 -15
- package/lib/library-scan.js +16 -6
- package/lib/library-server.js +4 -12
- package/lib/setup.js +145 -189
- package/lib/slides-verify.js +109 -0
- package/package.json +1 -1
- package/shared/sdocs-cells-formula.js +547 -73
- package/shared/sdocs-cells.js +164 -18
- package/shared/sdocs-shapes.js +1044 -0
- package/shared/sdocs-slide-resolve.js +258 -0
- package/shared/sdocs-slide-stdlib.js +180 -0
- package/shared/sdocs-styles.js +1 -1
|
@@ -0,0 +1,1044 @@
|
|
|
1
|
+
// sdocs-shapes.js — shape DSL parser + reference resolver (UMD)
|
|
2
|
+
// Shared by browser playground and Node tests.
|
|
3
|
+
//
|
|
4
|
+
// Grid (optional, must be first non-comment line):
|
|
5
|
+
// grid W H sets coordinate system to W × H cells
|
|
6
|
+
// (aspect ratio = W/H; defaults to 100 × 56.25)
|
|
7
|
+
//
|
|
8
|
+
// DSL primitives:
|
|
9
|
+
// r x y w h rectangle
|
|
10
|
+
// c <point> r circle (point = `cx cy` or `@ref`)
|
|
11
|
+
// e <point> rx ry ellipse
|
|
12
|
+
// l <point> <point> line
|
|
13
|
+
// a <point> <point> arrow (line with head at end)
|
|
14
|
+
// p <point> <point> ... polygon; segment operators between points:
|
|
15
|
+
// ~ soft bow (sagitta = 10% of chord)
|
|
16
|
+
// ^h arc / bow by sagitta h
|
|
17
|
+
// >P quadratic Bezier through P
|
|
18
|
+
// * P1 P2 cubic Bezier through P1, P2
|
|
19
|
+
// point modifiers:
|
|
20
|
+
// (r round the corner at this point
|
|
21
|
+
// (polygon point = `x,y` or `@ref`; control P can
|
|
22
|
+
// be either; arrow `a` also accepts `^h` between
|
|
23
|
+
// its endpoints)
|
|
24
|
+
//
|
|
25
|
+
// References:
|
|
26
|
+
// @id center of the shape with that id
|
|
27
|
+
// @id.center|top|bottom|left|right|topleft|topright|bottomleft|bottomright
|
|
28
|
+
//
|
|
29
|
+
// Trailing tokens (any order):
|
|
30
|
+
// #id attach an identifier to the current shape
|
|
31
|
+
// key=value style attribute
|
|
32
|
+
//
|
|
33
|
+
// Content:
|
|
34
|
+
// ... | content goes here inline text; `\n` inserts a line break
|
|
35
|
+
// ... | indented following lines continue content
|
|
36
|
+
//
|
|
37
|
+
// Lines starting with `//` are comments. Blank lines are ignored.
|
|
38
|
+
// Coordinates are grid units (typically 0-100 horizontally).
|
|
39
|
+
|
|
40
|
+
(function (exports) {
|
|
41
|
+
'use strict';
|
|
42
|
+
|
|
43
|
+
var POINT_RE = /^-?\d+(?:\.\d+)?,-?\d+(?:\.\d+)?$/;
|
|
44
|
+
var REF_RE = /^@([A-Za-z_][\w-]*)(?:\.([a-z]+))?$/;
|
|
45
|
+
|
|
46
|
+
var ANCHOR_TABLE = {
|
|
47
|
+
center: [0.5, 0.5],
|
|
48
|
+
top: [0.5, 0.0],
|
|
49
|
+
bottom: [0.5, 1.0],
|
|
50
|
+
left: [0.0, 0.5],
|
|
51
|
+
right: [1.0, 0.5],
|
|
52
|
+
topleft: [0.0, 0.0],
|
|
53
|
+
topright: [1.0, 0.0],
|
|
54
|
+
bottomleft: [0.0, 1.0],
|
|
55
|
+
bottomright: [1.0, 1.0],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function isPointToken(s) { return POINT_RE.test(s); }
|
|
59
|
+
function isRefToken(s) { return REF_RE.test(s); }
|
|
60
|
+
|
|
61
|
+
function tryParseRef(s) {
|
|
62
|
+
var m = s == null ? null : s.match(REF_RE);
|
|
63
|
+
if (!m) return null;
|
|
64
|
+
return { id: m[1], anchor: m[2] || 'center' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseNumber(s, ctx) {
|
|
68
|
+
var n = Number(s);
|
|
69
|
+
if (isNaN(n)) throw new Error('Expected number' + (ctx ? ' for ' + ctx : '') + ', got "' + s + '"');
|
|
70
|
+
return n;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parsePointLiteral(s) {
|
|
74
|
+
var parts = s.split(',');
|
|
75
|
+
return { x: parseNumber(parts[0], 'point x'), y: parseNumber(parts[1], 'point y') };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Parse one token as a control-point coord: either `x,y` literal or `@ref`.
|
|
79
|
+
// Returns { x, y } or { ref: { id, anchor } }. Throws if neither.
|
|
80
|
+
function parseCtrlPoint(token, opName) {
|
|
81
|
+
if (token == null) throw new Error('polygon: ' + opName + ' control: missing token');
|
|
82
|
+
var ref = tryParseRef(token);
|
|
83
|
+
if (ref) return { ref: ref };
|
|
84
|
+
if (!isPointToken(token)) throw new Error('polygon: ' + opName + ' control: expected x,y or @ref, got "' + token + '"');
|
|
85
|
+
return parsePointLiteral(token);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Consume one "point slot" from rest[idx]. A point is either:
|
|
89
|
+
// - a @ref token (1 token)
|
|
90
|
+
// - a pair of numeric tokens (2 tokens)
|
|
91
|
+
function consumePoint(rest, idx, kind, slotName) {
|
|
92
|
+
var t = rest[idx];
|
|
93
|
+
if (t == null) throw new Error(kind + ': missing ' + slotName);
|
|
94
|
+
var ref = tryParseRef(t);
|
|
95
|
+
if (ref) return { ref: ref, next: idx + 1 };
|
|
96
|
+
if (rest[idx + 1] == null) throw new Error(kind + ': ' + slotName + ' needs two numbers or @ref');
|
|
97
|
+
return {
|
|
98
|
+
x: parseNumber(t, kind + ' ' + slotName + '.x'),
|
|
99
|
+
y: parseNumber(rest[idx + 1], kind + ' ' + slotName + '.y'),
|
|
100
|
+
next: idx + 2,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseLine(raw, lineNumber) {
|
|
105
|
+
// Split off content after | (first occurrence).
|
|
106
|
+
var pipeIdx = raw.indexOf('|');
|
|
107
|
+
var spec = pipeIdx >= 0 ? raw.slice(0, pipeIdx) : raw;
|
|
108
|
+
var content = pipeIdx >= 0 ? decodeContentLineBreaks(raw.slice(pipeIdx + 1).trim()) : null;
|
|
109
|
+
|
|
110
|
+
var tokens = spec.trim().split(/\s+/).filter(Boolean);
|
|
111
|
+
if (tokens.length === 0) return null;
|
|
112
|
+
|
|
113
|
+
var kind = tokens[0];
|
|
114
|
+
var rest = tokens.slice(1);
|
|
115
|
+
var shape = { kind: kind, id: null, attrs: {}, content: content, lineNumber: lineNumber };
|
|
116
|
+
var i = 0;
|
|
117
|
+
|
|
118
|
+
if (kind === 'r') {
|
|
119
|
+
if (rest.length < 4) throw new Error('r: needs 4 numeric args (got ' + rest.length + ')');
|
|
120
|
+
shape.x = parseNumber(rest[0], 'r x');
|
|
121
|
+
shape.y = parseNumber(rest[1], 'r y');
|
|
122
|
+
shape.w = parseNumber(rest[2], 'r w');
|
|
123
|
+
shape.h = parseNumber(rest[3], 'r h');
|
|
124
|
+
i = 4;
|
|
125
|
+
} else if (kind === 'i') {
|
|
126
|
+
// Parser sugar: `i x y w h src=URL` is a rect-with-image. The shape lives
|
|
127
|
+
// its life as kind='r'; trailing `src=` (or `image=`) feeds the renderer's
|
|
128
|
+
// image-fill path so every rect/circle/polygon can hold a bitmap the
|
|
129
|
+
// same way. Keeping the `i` keystroke is pure ergonomics: "image in
|
|
130
|
+
// grid slot" is a common enough ask to deserve the shorthand.
|
|
131
|
+
if (rest.length < 4) throw new Error('i: needs 4 numeric args (got ' + rest.length + ')');
|
|
132
|
+
shape.kind = 'r';
|
|
133
|
+
shape.x = parseNumber(rest[0], 'i x');
|
|
134
|
+
shape.y = parseNumber(rest[1], 'i y');
|
|
135
|
+
shape.w = parseNumber(rest[2], 'i w');
|
|
136
|
+
shape.h = parseNumber(rest[3], 'i h');
|
|
137
|
+
i = 4;
|
|
138
|
+
} else if (kind === 'c') {
|
|
139
|
+
var cPt = consumePoint(rest, 0, 'c', 'center');
|
|
140
|
+
i = cPt.next;
|
|
141
|
+
if (cPt.ref) { shape.cx = null; shape.cy = null; shape.refs = { center: cPt.ref }; }
|
|
142
|
+
else { shape.cx = cPt.x; shape.cy = cPt.y; }
|
|
143
|
+
if (rest[i] == null) throw new Error('c: needs radius');
|
|
144
|
+
shape.r = parseNumber(rest[i], 'c r');
|
|
145
|
+
i++;
|
|
146
|
+
} else if (kind === 'e') {
|
|
147
|
+
var ePt = consumePoint(rest, 0, 'e', 'center');
|
|
148
|
+
i = ePt.next;
|
|
149
|
+
if (ePt.ref) { shape.cx = null; shape.cy = null; shape.refs = { center: ePt.ref }; }
|
|
150
|
+
else { shape.cx = ePt.x; shape.cy = ePt.y; }
|
|
151
|
+
if (rest[i] == null || rest[i + 1] == null) throw new Error('e: needs rx ry');
|
|
152
|
+
shape.rx = parseNumber(rest[i], 'e rx');
|
|
153
|
+
shape.ry = parseNumber(rest[i + 1], 'e ry');
|
|
154
|
+
i += 2;
|
|
155
|
+
} else if (kind === 'l' || kind === 'a') {
|
|
156
|
+
var p1 = consumePoint(rest, 0, kind, 'from');
|
|
157
|
+
// Optional `^h` between endpoints: bow the line/arrow by sagitta h.
|
|
158
|
+
// Same convention as polygon ^h: positive bows to the left of the
|
|
159
|
+
// direction of travel (upward for a rightward chord in SVG y-down).
|
|
160
|
+
var aft1 = p1.next;
|
|
161
|
+
if (rest[aft1] && rest[aft1].charAt(0) === '^') {
|
|
162
|
+
var bt = rest[aft1];
|
|
163
|
+
var bsv;
|
|
164
|
+
if (bt.length > 1) { bsv = bt.slice(1); aft1++; }
|
|
165
|
+
else { bsv = rest[aft1 + 1]; aft1 += 2; }
|
|
166
|
+
if (bsv == null) throw new Error(kind + ': ^ needs a sagitta value');
|
|
167
|
+
shape.bow = parseNumber(bsv, kind + ': ^h sagitta');
|
|
168
|
+
}
|
|
169
|
+
var p2 = consumePoint(rest, aft1, kind, 'to');
|
|
170
|
+
i = p2.next;
|
|
171
|
+
var refs = {};
|
|
172
|
+
if (p1.ref) { shape.x1 = null; shape.y1 = null; refs.from = p1.ref; }
|
|
173
|
+
else { shape.x1 = p1.x; shape.y1 = p1.y; }
|
|
174
|
+
if (p2.ref) { shape.x2 = null; shape.y2 = null; refs.to = p2.ref; }
|
|
175
|
+
else { shape.x2 = p2.x; shape.y2 = p2.y; }
|
|
176
|
+
if (Object.keys(refs).length > 0) shape.refs = refs;
|
|
177
|
+
} else if (kind === 'p') {
|
|
178
|
+
shape.points = [];
|
|
179
|
+
// `pendingSeg` is the segment description (line / smooth / arc / quad /
|
|
180
|
+
// cubic) that will be attached to the *next* point pushed. It carries the
|
|
181
|
+
// metadata for the edge from the previous point to the next one.
|
|
182
|
+
// `pendingRound` is the corner-rounding radius for the next point; the
|
|
183
|
+
// point itself owns this (the rounding sits AT the vertex, not on an
|
|
184
|
+
// adjacent edge).
|
|
185
|
+
var pendingSeg = null;
|
|
186
|
+
var pendingRound = null;
|
|
187
|
+
while (i < rest.length) {
|
|
188
|
+
var t = rest[i];
|
|
189
|
+
|
|
190
|
+
// ~ — smooth quadratic to midpoint (existing behavior).
|
|
191
|
+
if (t === '~') {
|
|
192
|
+
if (shape.points.length === 0) throw new Error('polygon: ~ cannot precede the first point');
|
|
193
|
+
pendingSeg = { type: 'smooth' };
|
|
194
|
+
i++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ^h — arc / bow by sagitta h, perpendicular to the chord.
|
|
199
|
+
// Accepts `^0.8` (attached) or `^ 0.8` (separate token).
|
|
200
|
+
if (t.charAt(0) === '^') {
|
|
201
|
+
if (shape.points.length === 0) throw new Error('polygon: ^ cannot precede the first point');
|
|
202
|
+
var sagToken;
|
|
203
|
+
if (t.length > 1) { sagToken = t.slice(1); i++; }
|
|
204
|
+
else { sagToken = rest[i + 1]; i += 2; }
|
|
205
|
+
if (sagToken == null) throw new Error('polygon: ^ needs a sagitta value');
|
|
206
|
+
var sag = parseNumber(sagToken, 'polygon: ^h sagitta');
|
|
207
|
+
pendingSeg = { type: 'arc', sagitta: sag };
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// >P — quadratic Bezier with one explicit control point.
|
|
212
|
+
// Accepts `>5.5,3.4` / `>@card.top` (attached) or `> 5.5,3.4` (separate).
|
|
213
|
+
if (t.charAt(0) === '>') {
|
|
214
|
+
if (shape.points.length === 0) throw new Error('polygon: > cannot precede the first point');
|
|
215
|
+
var ctrlToken;
|
|
216
|
+
if (t.length > 1) { ctrlToken = t.slice(1); i++; }
|
|
217
|
+
else { ctrlToken = rest[i + 1]; i += 2; }
|
|
218
|
+
var ctrl = parseCtrlPoint(ctrlToken, '>');
|
|
219
|
+
pendingSeg = { type: 'quad', c: ctrl };
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// * — cubic Bezier with two control points (next two tokens).
|
|
224
|
+
if (t === '*') {
|
|
225
|
+
if (shape.points.length === 0) throw new Error('polygon: * cannot precede the first point');
|
|
226
|
+
var c1 = parseCtrlPoint(rest[i + 1], '*');
|
|
227
|
+
var c2 = parseCtrlPoint(rest[i + 2], '*');
|
|
228
|
+
pendingSeg = { type: 'cubic', c1: c1, c2: c2 };
|
|
229
|
+
i += 3;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// (r — round the corner at the next point with radius r. Unlike the
|
|
234
|
+
// segment operators above, this targets the vertex itself, so it is
|
|
235
|
+
// valid even before the first point. Rounding only takes effect when
|
|
236
|
+
// both adjacent segments are straight; the renderer silently no-ops
|
|
237
|
+
// otherwise (curved-edge corners have no clean rounding semantics).
|
|
238
|
+
if (t.charAt(0) === '(') {
|
|
239
|
+
var rToken;
|
|
240
|
+
if (t.length > 1) { rToken = t.slice(1); i++; }
|
|
241
|
+
else { rToken = rest[i + 1]; i += 2; }
|
|
242
|
+
if (rToken == null) throw new Error('polygon: ( needs a radius value');
|
|
243
|
+
var rVal = parseNumber(rToken, 'polygon: (r radius');
|
|
244
|
+
if (rVal <= 0) throw new Error('polygon: (r radius must be > 0');
|
|
245
|
+
pendingRound = rVal;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (isRefToken(t)) {
|
|
250
|
+
var rpt = { ref: tryParseRef(t) };
|
|
251
|
+
rpt.seg = pendingSeg || { type: 'line' };
|
|
252
|
+
rpt.curve = rpt.seg.type === 'smooth';
|
|
253
|
+
if (pendingRound != null) rpt.round = pendingRound;
|
|
254
|
+
shape.points.push(rpt);
|
|
255
|
+
pendingSeg = null;
|
|
256
|
+
pendingRound = null;
|
|
257
|
+
i++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (!isPointToken(t)) break;
|
|
261
|
+
var pt = parsePointLiteral(t);
|
|
262
|
+
pt.seg = pendingSeg || { type: 'line' };
|
|
263
|
+
pt.curve = pt.seg.type === 'smooth';
|
|
264
|
+
if (pendingRound != null) pt.round = pendingRound;
|
|
265
|
+
shape.points.push(pt);
|
|
266
|
+
pendingSeg = null;
|
|
267
|
+
pendingRound = null;
|
|
268
|
+
i++;
|
|
269
|
+
}
|
|
270
|
+
if (pendingSeg) {
|
|
271
|
+
var opName = pendingSeg.type === 'smooth' ? '~'
|
|
272
|
+
: pendingSeg.type === 'arc' ? '^'
|
|
273
|
+
: pendingSeg.type === 'quad' ? '>'
|
|
274
|
+
: pendingSeg.type === 'cubic' ? '*'
|
|
275
|
+
: pendingSeg.type;
|
|
276
|
+
throw new Error('polygon: trailing ' + opName + ' with no following point');
|
|
277
|
+
}
|
|
278
|
+
if (pendingRound != null) throw new Error('polygon: trailing ( with no following point');
|
|
279
|
+
if (shape.points.length < 2) {
|
|
280
|
+
// Hint when the author wrote space-separated coords (e.g. `p 7 1 9 1 8 3`)
|
|
281
|
+
// by analogy with `r x y w h`. Polygon's variable-length point list needs
|
|
282
|
+
// a delimiter, so each point is `x,y` (one token) or `@ref`.
|
|
283
|
+
var looksLikeRawNums = false;
|
|
284
|
+
for (var rk = i; rk < rest.length; rk++) {
|
|
285
|
+
var tk = rest[rk];
|
|
286
|
+
if (tk.indexOf('=') >= 0 || tk.charAt(0) === '#') break;
|
|
287
|
+
if (/^-?\d+(?:\.\d+)?$/.test(tk)) { looksLikeRawNums = true; break; }
|
|
288
|
+
}
|
|
289
|
+
if (looksLikeRawNums) {
|
|
290
|
+
throw new Error('polygon: points use "x,y" (one token per point), not space-separated coords. e.g. `p 10,10 50,10 30,40`');
|
|
291
|
+
}
|
|
292
|
+
throw new Error('polygon: needs at least 2 points');
|
|
293
|
+
}
|
|
294
|
+
} else if (kind === 'chev') {
|
|
295
|
+
// Chevron / arrow-block. Same coords as rect, plus a pointed tip on
|
|
296
|
+
// the right. Optional `notch=N` carves the left edge into a matching
|
|
297
|
+
// V so a row of chevrons interlocks (`> text >` style).
|
|
298
|
+
if (rest.length < 4) throw new Error('chev: needs 4 numeric args (got ' + rest.length + ')');
|
|
299
|
+
shape.x = parseNumber(rest[0], 'chev x');
|
|
300
|
+
shape.y = parseNumber(rest[1], 'chev y');
|
|
301
|
+
shape.w = parseNumber(rest[2], 'chev w');
|
|
302
|
+
shape.h = parseNumber(rest[3], 'chev h');
|
|
303
|
+
i = 4;
|
|
304
|
+
} else if (kind === 'bub') {
|
|
305
|
+
// Speech-bubble / callout. Body is a rounded rect at (x, y, w, h); a
|
|
306
|
+
// triangular tail points from the body's nearest edge to `tail=tx,ty`.
|
|
307
|
+
if (rest.length < 4) throw new Error('bub: needs 4 numeric args (got ' + rest.length + ')');
|
|
308
|
+
shape.x = parseNumber(rest[0], 'bub x');
|
|
309
|
+
shape.y = parseNumber(rest[1], 'bub y');
|
|
310
|
+
shape.w = parseNumber(rest[2], 'bub w');
|
|
311
|
+
shape.h = parseNumber(rest[3], 'bub h');
|
|
312
|
+
i = 4;
|
|
313
|
+
} else if (kind === 'cyl') {
|
|
314
|
+
// Cylinder. Bounding box (x, y, w, h); the visible top/bottom ellipse
|
|
315
|
+
// caps each take `lip` height (default ~15% of h, capped by w).
|
|
316
|
+
if (rest.length < 4) throw new Error('cyl: needs 4 numeric args (got ' + rest.length + ')');
|
|
317
|
+
shape.x = parseNumber(rest[0], 'cyl x');
|
|
318
|
+
shape.y = parseNumber(rest[1], 'cyl y');
|
|
319
|
+
shape.w = parseNumber(rest[2], 'cyl w');
|
|
320
|
+
shape.h = parseNumber(rest[3], 'cyl h');
|
|
321
|
+
i = 4;
|
|
322
|
+
} else if (kind === 'tab') {
|
|
323
|
+
// Folder tab. (x, y, w, h) is the full bbox; a smaller rectangular
|
|
324
|
+
// tab sits on top-left, joined to the body with a slope. Text
|
|
325
|
+
// centres in the body (below the tab).
|
|
326
|
+
if (rest.length < 4) throw new Error('tab: needs 4 numeric args (got ' + rest.length + ')');
|
|
327
|
+
shape.x = parseNumber(rest[0], 'tab x');
|
|
328
|
+
shape.y = parseNumber(rest[1], 'tab y');
|
|
329
|
+
shape.w = parseNumber(rest[2], 'tab w');
|
|
330
|
+
shape.h = parseNumber(rest[3], 'tab h');
|
|
331
|
+
i = 4;
|
|
332
|
+
} else if (kind === 'doc') {
|
|
333
|
+
// Document with folded top-right corner. The fold size defaults to
|
|
334
|
+
// ~15% of min(w, h); the fold itself is drawn as a small triangle in
|
|
335
|
+
// a lighter tint so the corner reads as a 3D fold.
|
|
336
|
+
if (rest.length < 4) throw new Error('doc: needs 4 numeric args (got ' + rest.length + ')');
|
|
337
|
+
shape.x = parseNumber(rest[0], 'doc x');
|
|
338
|
+
shape.y = parseNumber(rest[1], 'doc y');
|
|
339
|
+
shape.w = parseNumber(rest[2], 'doc w');
|
|
340
|
+
shape.h = parseNumber(rest[3], 'doc h');
|
|
341
|
+
i = 4;
|
|
342
|
+
} else if (kind === 'cloud') {
|
|
343
|
+
// Cloud shape. Single SVG path with five Bezier bumps around the
|
|
344
|
+
// perimeter, no internal seams (unlike the hand-drawn "overlapping
|
|
345
|
+
// circles" workaround).
|
|
346
|
+
if (rest.length < 4) throw new Error('cloud: needs 4 numeric args (got ' + rest.length + ')');
|
|
347
|
+
shape.x = parseNumber(rest[0], 'cloud x');
|
|
348
|
+
shape.y = parseNumber(rest[1], 'cloud y');
|
|
349
|
+
shape.w = parseNumber(rest[2], 'cloud w');
|
|
350
|
+
shape.h = parseNumber(rest[3], 'cloud h');
|
|
351
|
+
i = 4;
|
|
352
|
+
} else if (kind === 'icon') {
|
|
353
|
+
// Inline icon from a bundled icon library (Lucide). Requires
|
|
354
|
+
// `name=<icon-name>` to identify which icon. The icon scales to
|
|
355
|
+
// (w, h) and inherits stroke colour from `color=` (defaults
|
|
356
|
+
// to ink).
|
|
357
|
+
if (rest.length < 4) throw new Error('icon: needs 4 numeric args (got ' + rest.length + ')');
|
|
358
|
+
shape.x = parseNumber(rest[0], 'icon x');
|
|
359
|
+
shape.y = parseNumber(rest[1], 'icon y');
|
|
360
|
+
shape.w = parseNumber(rest[2], 'icon w');
|
|
361
|
+
shape.h = parseNumber(rest[3], 'icon h');
|
|
362
|
+
i = 4;
|
|
363
|
+
} else {
|
|
364
|
+
throw new Error('Unknown shape "' + kind + '"');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Trailing tokens: #id or key=value attributes, in any order.
|
|
368
|
+
while (i < rest.length) {
|
|
369
|
+
var tok = rest[i];
|
|
370
|
+
if (tok.charAt(0) === '#') {
|
|
371
|
+
if (shape.id) throw new Error('multiple #id tokens on one line');
|
|
372
|
+
var idName = tok.slice(1);
|
|
373
|
+
// Trailing `!` marks the slot as required when the template is
|
|
374
|
+
// consumed via @extends. The resolver checks for unfilled required
|
|
375
|
+
// slots and surfaces an error per slide; for plain shapes (not
|
|
376
|
+
// inside a template) the flag is just metadata that gets ignored.
|
|
377
|
+
var required = false;
|
|
378
|
+
if (idName.length > 0 && idName.charAt(idName.length - 1) === '!') {
|
|
379
|
+
required = true;
|
|
380
|
+
idName = idName.slice(0, -1);
|
|
381
|
+
}
|
|
382
|
+
if (!/^[A-Za-z_][\w-]*$/.test(idName)) throw new Error('invalid id "' + tok + '"');
|
|
383
|
+
shape.id = idName;
|
|
384
|
+
if (required) shape.required = true;
|
|
385
|
+
} else {
|
|
386
|
+
var eq = tok.indexOf('=');
|
|
387
|
+
if (eq <= 0) throw new Error('unexpected token "' + tok + '"');
|
|
388
|
+
var key = tok.slice(0, eq);
|
|
389
|
+
var val = tok.slice(eq + 1);
|
|
390
|
+
if (!/^[A-Za-z][\w-]*$/.test(key)) throw new Error('invalid attribute key "' + key + '"');
|
|
391
|
+
shape.attrs[key] = val;
|
|
392
|
+
}
|
|
393
|
+
i++;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return shape;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
var DEFAULT_GRID = { w: 100, h: 56.25 };
|
|
400
|
+
|
|
401
|
+
function parseGridLine(trimmed) {
|
|
402
|
+
var tokens = trimmed.split(/\s+/);
|
|
403
|
+
if (tokens.length < 3) {
|
|
404
|
+
throw new Error('grid: expected "grid W H [key=val ...]", got ' + tokens.length + ' tokens');
|
|
405
|
+
}
|
|
406
|
+
var w = parseNumber(tokens[1], 'grid W');
|
|
407
|
+
var h = parseNumber(tokens[2], 'grid H');
|
|
408
|
+
if (w <= 0 || h <= 0) throw new Error('grid: W and H must be positive');
|
|
409
|
+
var attrs = {};
|
|
410
|
+
for (var i = 3; i < tokens.length; i++) {
|
|
411
|
+
var tok = tokens[i];
|
|
412
|
+
var eq = tok.indexOf('=');
|
|
413
|
+
if (eq < 0) throw new Error('grid: unexpected token "' + tok + '" — use key=value');
|
|
414
|
+
var key = tok.slice(0, eq);
|
|
415
|
+
var val = tok.slice(eq + 1);
|
|
416
|
+
if (!/^[A-Za-z][\w-]*$/.test(key)) throw new Error('grid: invalid attribute key "' + key + '"');
|
|
417
|
+
attrs[key] = val;
|
|
418
|
+
}
|
|
419
|
+
return { w: w, h: h, attrs: attrs };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Indented (2+ space) lines immediately after a shape line become
|
|
423
|
+
// continuation content for that shape — YAML block scalar style. Content only
|
|
424
|
+
// collects when the shape line had a `|` separator; otherwise the indented
|
|
425
|
+
// lines are errors, since they'd silently disappear.
|
|
426
|
+
function collectIndentedContent(lines, startIdx) {
|
|
427
|
+
var out = [];
|
|
428
|
+
var i = startIdx;
|
|
429
|
+
while (i < lines.length) {
|
|
430
|
+
var l = lines[i];
|
|
431
|
+
if (l.length === 0) {
|
|
432
|
+
// Blank line: include only if another indented line follows.
|
|
433
|
+
var j = i + 1;
|
|
434
|
+
while (j < lines.length && lines[j].length === 0) j++;
|
|
435
|
+
if (j < lines.length && /^ {2}/.test(lines[j])) {
|
|
436
|
+
out.push('');
|
|
437
|
+
i++;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
break;
|
|
441
|
+
}
|
|
442
|
+
if (!/^ {2}/.test(l)) break;
|
|
443
|
+
out.push(l.replace(/^ {2}/, ''));
|
|
444
|
+
i++;
|
|
445
|
+
}
|
|
446
|
+
return { lines: out, nextIdx: i };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Decode the one inline escape the shape DSL owns. An odd backslash before
|
|
450
|
+
// `n` inserts a line break; an even run leaves the final `\n` literal. Other
|
|
451
|
+
// backslashes pass through unchanged so paths and Markdown escapes are safe.
|
|
452
|
+
function decodeContentLineBreaks(content) {
|
|
453
|
+
if (content == null || content.indexOf('\\n') < 0) return content;
|
|
454
|
+
var out = '';
|
|
455
|
+
var i = 0;
|
|
456
|
+
while (i < content.length) {
|
|
457
|
+
if (content.charAt(i) !== '\\') {
|
|
458
|
+
out += content.charAt(i++);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
var start = i;
|
|
462
|
+
while (i < content.length && content.charAt(i) === '\\') i++;
|
|
463
|
+
var count = i - start;
|
|
464
|
+
if (i < content.length && content.charAt(i) === 'n') {
|
|
465
|
+
out += new Array(Math.floor(count / 2) + 1).join('\\');
|
|
466
|
+
out += count % 2 === 1 ? '\n' : 'n';
|
|
467
|
+
i++;
|
|
468
|
+
} else {
|
|
469
|
+
out += new Array(count + 1).join('\\');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return out;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function parse(src) {
|
|
476
|
+
var lines = (src == null ? '' : String(src)).split('\n');
|
|
477
|
+
var shapes = [];
|
|
478
|
+
var errors = [];
|
|
479
|
+
var grid = null;
|
|
480
|
+
var seenShape = false;
|
|
481
|
+
var i = 0;
|
|
482
|
+
while (i < lines.length) {
|
|
483
|
+
var line = lines[i];
|
|
484
|
+
var trimmed = line.trim();
|
|
485
|
+
if (!trimmed) { i++; continue; }
|
|
486
|
+
if (trimmed.slice(0, 2) === '//') { i++; continue; }
|
|
487
|
+
|
|
488
|
+
// Detect stray indented lines at top level (continuation with no parent).
|
|
489
|
+
if (/^ {2}/.test(line)) {
|
|
490
|
+
errors.push({ line: i + 1, message: 'unexpected indented line (no preceding shape with |)', source: line });
|
|
491
|
+
i++;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Grid statement: must appear before any shape, at most once.
|
|
496
|
+
if (/^grid(\s|$)/.test(trimmed)) {
|
|
497
|
+
if (seenShape) {
|
|
498
|
+
errors.push({ line: i + 1, message: 'grid must be declared before any shapes', source: line });
|
|
499
|
+
i++;
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
if (grid) {
|
|
503
|
+
errors.push({ line: i + 1, message: 'grid declared more than once', source: line });
|
|
504
|
+
i++;
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
grid = parseGridLine(trimmed);
|
|
509
|
+
} catch (e) {
|
|
510
|
+
errors.push({ line: i + 1, message: e.message, source: line });
|
|
511
|
+
}
|
|
512
|
+
i++;
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
try {
|
|
517
|
+
var s = parseLine(line, i + 1);
|
|
518
|
+
if (s) {
|
|
519
|
+
seenShape = true;
|
|
520
|
+
// If shape declared a `|` separator, collect any following indented
|
|
521
|
+
// lines and append them to the content.
|
|
522
|
+
if (s.content != null) {
|
|
523
|
+
var cont = collectIndentedContent(lines, i + 1);
|
|
524
|
+
if (cont.lines.length > 0) {
|
|
525
|
+
var joined = cont.lines.join('\n');
|
|
526
|
+
s.content = s.content.length > 0
|
|
527
|
+
? s.content + '\n' + joined
|
|
528
|
+
: joined;
|
|
529
|
+
}
|
|
530
|
+
i = cont.nextIdx;
|
|
531
|
+
} else {
|
|
532
|
+
i++;
|
|
533
|
+
}
|
|
534
|
+
shapes.push(s);
|
|
535
|
+
} else {
|
|
536
|
+
i++;
|
|
537
|
+
}
|
|
538
|
+
} catch (e) {
|
|
539
|
+
errors.push({ line: i + 1, message: e.message, source: line });
|
|
540
|
+
i++;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
return { shapes: shapes, errors: errors, grid: grid || { w: DEFAULT_GRID.w, h: DEFAULT_GRID.h, attrs: {} } };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ─── Reference resolution ──────────────────────────────
|
|
547
|
+
|
|
548
|
+
// Tip / notch / lip / tail defaults for the high-level shapes. Kept here
|
|
549
|
+
// (rather than in the renderer) so contentBox and any geometry consumer
|
|
550
|
+
// computes the same numbers without duplicating defaults.
|
|
551
|
+
function chevTip(s) {
|
|
552
|
+
if (s.attrs && s.attrs.tip != null && s.attrs.tip !== '') {
|
|
553
|
+
var v = parseFloat(s.attrs.tip);
|
|
554
|
+
if (isFinite(v) && v >= 0) return Math.min(v, s.w);
|
|
555
|
+
}
|
|
556
|
+
return Math.min(s.h / 2, s.w * 0.25);
|
|
557
|
+
}
|
|
558
|
+
function chevNotch(s) {
|
|
559
|
+
if (s.attrs && s.attrs.notch != null && s.attrs.notch !== '') {
|
|
560
|
+
var v = parseFloat(s.attrs.notch);
|
|
561
|
+
if (isFinite(v) && v >= 0) return Math.min(v, s.w / 2);
|
|
562
|
+
}
|
|
563
|
+
return 0;
|
|
564
|
+
}
|
|
565
|
+
function cylLip(s) {
|
|
566
|
+
if (s.attrs && s.attrs.lip != null && s.attrs.lip !== '') {
|
|
567
|
+
var v = parseFloat(s.attrs.lip);
|
|
568
|
+
if (isFinite(v) && v > 0) return Math.min(v, s.h / 2);
|
|
569
|
+
}
|
|
570
|
+
return Math.min(s.h * 0.2, s.w * 0.4);
|
|
571
|
+
}
|
|
572
|
+
// Lid colours for a filled cylinder: a clearly lighter top so it reads as a
|
|
573
|
+
// database lid, plus a slightly darker seam line for the lid/body edge. Both
|
|
574
|
+
// derive from the fill so the lid reads on any colour (dark or light). Returns
|
|
575
|
+
// null when the fill is not a hex colour, so the caller can fall back.
|
|
576
|
+
function cylLidColors(fill) {
|
|
577
|
+
if (typeof fill !== 'string') return null;
|
|
578
|
+
var m = fill.trim().replace(/^#/, '');
|
|
579
|
+
if (m.length === 3) m = m[0] + m[0] + m[1] + m[1] + m[2] + m[2];
|
|
580
|
+
if (!/^[0-9a-fA-F]{6}$/.test(m)) return null;
|
|
581
|
+
var r = parseInt(m.slice(0, 2), 16),
|
|
582
|
+
g = parseInt(m.slice(2, 4), 16),
|
|
583
|
+
b = parseInt(m.slice(4, 6), 16);
|
|
584
|
+
function mix(t, amt) {
|
|
585
|
+
var to = t ? 255 : 0;
|
|
586
|
+
function c(v) { return Math.round(v + (to - v) * amt); }
|
|
587
|
+
function h(v) { return ('0' + c(v).toString(16)).slice(-2); }
|
|
588
|
+
return '#' + h(r) + h(g) + h(b);
|
|
589
|
+
}
|
|
590
|
+
return { lid: mix(1, 0.42), seam: mix(0, 0.16) };
|
|
591
|
+
}
|
|
592
|
+
function bubTail(s) {
|
|
593
|
+
if (s.attrs && s.attrs.tail) {
|
|
594
|
+
var parts = String(s.attrs.tail).split(/[\s,]+/).filter(function (p) { return p !== ''; });
|
|
595
|
+
if (parts.length === 2) {
|
|
596
|
+
var tx = parseFloat(parts[0]);
|
|
597
|
+
var ty = parseFloat(parts[1]);
|
|
598
|
+
if (isFinite(tx) && isFinite(ty)) return { x: tx, y: ty };
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Defaults for tab / doc / cloud parameters - kept here so contentBox
|
|
605
|
+
// computes the same numbers without duplicating the renderer's defaults.
|
|
606
|
+
function tabHeight(s) {
|
|
607
|
+
if (s.attrs && s.attrs.tabH != null && s.attrs.tabH !== '') {
|
|
608
|
+
var v = parseFloat(s.attrs.tabH);
|
|
609
|
+
if (isFinite(v) && v > 0) return Math.min(v, s.h * 0.5);
|
|
610
|
+
}
|
|
611
|
+
return Math.min(s.h * 0.22, s.w * 0.18);
|
|
612
|
+
}
|
|
613
|
+
function docFold(s) {
|
|
614
|
+
if (s.attrs && s.attrs.fold != null && s.attrs.fold !== '') {
|
|
615
|
+
var v = parseFloat(s.attrs.fold);
|
|
616
|
+
if (isFinite(v) && v > 0) return Math.min(v, s.w * 0.5, s.h * 0.5);
|
|
617
|
+
}
|
|
618
|
+
return Math.min(s.w, s.h) * 0.15;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function bboxOf(shape) {
|
|
622
|
+
if (shape.kind === 'r') return { x: shape.x, y: shape.y, w: shape.w, h: shape.h };
|
|
623
|
+
if (shape.kind === 'chev' || shape.kind === 'cyl' ||
|
|
624
|
+
shape.kind === 'tab' || shape.kind === 'doc' || shape.kind === 'cloud' ||
|
|
625
|
+
shape.kind === 'icon') {
|
|
626
|
+
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h };
|
|
627
|
+
}
|
|
628
|
+
if (shape.kind === 'bub') {
|
|
629
|
+
// Body bbox only - the tail can poke outside, but the bounding box
|
|
630
|
+
// we report is the rectangular body for layout / contentBox purposes.
|
|
631
|
+
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h };
|
|
632
|
+
}
|
|
633
|
+
if (shape.kind === 'c') return { x: shape.cx - shape.r, y: shape.cy - shape.r, w: shape.r * 2, h: shape.r * 2 };
|
|
634
|
+
if (shape.kind === 'e') return { x: shape.cx - shape.rx, y: shape.cy - shape.ry, w: shape.rx * 2, h: shape.ry * 2 };
|
|
635
|
+
if (shape.kind === 'l' || shape.kind === 'a') {
|
|
636
|
+
var mnx = Math.min(shape.x1, shape.x2), mxx = Math.max(shape.x1, shape.x2);
|
|
637
|
+
var mny = Math.min(shape.y1, shape.y2), mxy = Math.max(shape.y1, shape.y2);
|
|
638
|
+
return { x: mnx, y: mny, w: mxx - mnx, h: mxy - mny };
|
|
639
|
+
}
|
|
640
|
+
if (shape.kind === 'p') {
|
|
641
|
+
var mx = Infinity, Mx = -Infinity, my = Infinity, My = -Infinity;
|
|
642
|
+
for (var j = 0; j < shape.points.length; j++) {
|
|
643
|
+
var p = shape.points[j];
|
|
644
|
+
if (p.x < mx) mx = p.x;
|
|
645
|
+
if (p.x > Mx) Mx = p.x;
|
|
646
|
+
if (p.y < my) my = p.y;
|
|
647
|
+
if (p.y > My) My = p.y;
|
|
648
|
+
}
|
|
649
|
+
return { x: mx, y: my, w: Mx - mx, h: My - my };
|
|
650
|
+
}
|
|
651
|
+
throw new Error('bboxOf: unknown kind ' + shape.kind);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Content box for a shape — where text should render. Returns { x, y, w, h }
|
|
655
|
+
// in grid units, or null for decorative shapes (lines, arrows).
|
|
656
|
+
// Rectangle: full rect bounds.
|
|
657
|
+
// Circle: inscribed square (side = r * √2).
|
|
658
|
+
// Ellipse: inscribed rectangle (w = rx * √2, h = ry * √2).
|
|
659
|
+
// Polygon: bounding box.
|
|
660
|
+
function contentBox(shape) {
|
|
661
|
+
// `textBox=x,y,w,h` (or `x y w h`) overrides where text content
|
|
662
|
+
// renders. Values are in grid units, relative to the shape's bounding
|
|
663
|
+
// box top-left. Useful for asymmetric polygons (chevrons, callouts,
|
|
664
|
+
// ribbons) where centering text in the bbox drifts it off the visual
|
|
665
|
+
// mass; the author specifies a body rectangle and text centers there.
|
|
666
|
+
var tb = parseTextBox(shape.attrs && shape.attrs.textBox);
|
|
667
|
+
if (tb) {
|
|
668
|
+
var bbox = bboxOf(shape);
|
|
669
|
+
return { x: bbox.x + tb.x, y: bbox.y + tb.y, w: tb.w, h: tb.h };
|
|
670
|
+
}
|
|
671
|
+
if (shape.kind === 'r') {
|
|
672
|
+
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h };
|
|
673
|
+
}
|
|
674
|
+
if (shape.kind === 'c') {
|
|
675
|
+
var side = shape.r * Math.SQRT2;
|
|
676
|
+
return { x: shape.cx - side / 2, y: shape.cy - side / 2, w: side, h: side };
|
|
677
|
+
}
|
|
678
|
+
if (shape.kind === 'e') {
|
|
679
|
+
var w = shape.rx * Math.SQRT2;
|
|
680
|
+
var h = shape.ry * Math.SQRT2;
|
|
681
|
+
return { x: shape.cx - w / 2, y: shape.cy - h / 2, w: w, h: h };
|
|
682
|
+
}
|
|
683
|
+
if (shape.kind === 'p') {
|
|
684
|
+
return bboxOf(shape);
|
|
685
|
+
}
|
|
686
|
+
if (shape.kind === 'chev') {
|
|
687
|
+
// Text centres in the rectangular body, excluding the tip (and the
|
|
688
|
+
// notch indent if set). This is the whole point of `chev` over a
|
|
689
|
+
// hand-drawn polygon - the visual mass is the body, not the bbox.
|
|
690
|
+
var tip = chevTip(shape);
|
|
691
|
+
var notch = chevNotch(shape);
|
|
692
|
+
return { x: shape.x + notch, y: shape.y,
|
|
693
|
+
w: Math.max(0, shape.w - tip - notch), h: shape.h };
|
|
694
|
+
}
|
|
695
|
+
if (shape.kind === 'cyl') {
|
|
696
|
+
// Text centres in the cylindrical body (between the two ellipse caps).
|
|
697
|
+
var lip = cylLip(shape);
|
|
698
|
+
return { x: shape.x, y: shape.y + lip,
|
|
699
|
+
w: shape.w, h: Math.max(0, shape.h - 2 * lip) };
|
|
700
|
+
}
|
|
701
|
+
if (shape.kind === 'bub') {
|
|
702
|
+
// Text centres in the bubble body. The tail does not displace text.
|
|
703
|
+
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h };
|
|
704
|
+
}
|
|
705
|
+
if (shape.kind === 'tab') {
|
|
706
|
+
// Text centres in the body (below the tab on top-left).
|
|
707
|
+
var th = tabHeight(shape);
|
|
708
|
+
return { x: shape.x, y: shape.y + th, w: shape.w, h: Math.max(0, shape.h - th) };
|
|
709
|
+
}
|
|
710
|
+
if (shape.kind === 'doc') {
|
|
711
|
+
// Text centres in the full body. The fold trims the top-right corner;
|
|
712
|
+
// for long titles this can clip slightly, so the author may want to
|
|
713
|
+
// shrink the shape or use a manual textBox.
|
|
714
|
+
return { x: shape.x, y: shape.y, w: shape.w, h: shape.h };
|
|
715
|
+
}
|
|
716
|
+
if (shape.kind === 'cloud') {
|
|
717
|
+
// Cloud text area is the inscribed rectangle - roughly the middle
|
|
718
|
+
// 70% of the bbox, where the silhouette is dense enough to host
|
|
719
|
+
// text without it floating over the bumps' negative space.
|
|
720
|
+
var insetX = shape.w * 0.15;
|
|
721
|
+
var insetY = shape.h * 0.20;
|
|
722
|
+
return { x: shape.x + insetX, y: shape.y + insetY,
|
|
723
|
+
w: shape.w - 2 * insetX, h: shape.h - 2 * insetY };
|
|
724
|
+
}
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Parse a "x,y,w,h" or "x y w h" attribute value into four numbers.
|
|
729
|
+
// Returns null if missing or malformed. Negative w/h are rejected.
|
|
730
|
+
function parseTextBox(raw) {
|
|
731
|
+
if (raw == null || raw === '') return null;
|
|
732
|
+
var parts = String(raw).split(/[\s,]+/).filter(function (s) { return s !== ''; });
|
|
733
|
+
if (parts.length !== 4) return null;
|
|
734
|
+
var x = parseFloat(parts[0]);
|
|
735
|
+
var y = parseFloat(parts[1]);
|
|
736
|
+
var w = parseFloat(parts[2]);
|
|
737
|
+
var h = parseFloat(parts[3]);
|
|
738
|
+
if (!isFinite(x) || !isFinite(y) || !isFinite(w) || !isFinite(h)) return null;
|
|
739
|
+
if (w <= 0 || h <= 0) return null;
|
|
740
|
+
return { x: x, y: y, w: w, h: h };
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function anchorPoint(shape, anchor) {
|
|
744
|
+
var t = ANCHOR_TABLE[anchor];
|
|
745
|
+
if (!t) throw new Error('unknown anchor ".' + anchor + '"');
|
|
746
|
+
var b = bboxOf(shape);
|
|
747
|
+
return { x: b.x + b.w * t[0], y: b.y + b.h * t[1] };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function segCtrlRefs(seg) {
|
|
751
|
+
// Returns an array of ref objects ({id, anchor}) for any control points in
|
|
752
|
+
// this segment that were declared as @refs. Used by refsInShape /
|
|
753
|
+
// shapeHasRefs / resolveShape to walk the new operator metadata.
|
|
754
|
+
if (!seg) return [];
|
|
755
|
+
var out = [];
|
|
756
|
+
if (seg.c && seg.c.ref) out.push(seg.c.ref);
|
|
757
|
+
if (seg.c1 && seg.c1.ref) out.push(seg.c1.ref);
|
|
758
|
+
if (seg.c2 && seg.c2.ref) out.push(seg.c2.ref);
|
|
759
|
+
return out;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function refsInShape(shape) {
|
|
763
|
+
var ids = [];
|
|
764
|
+
if (shape.refs) {
|
|
765
|
+
var keys = Object.keys(shape.refs);
|
|
766
|
+
for (var k = 0; k < keys.length; k++) ids.push(shape.refs[keys[k]].id);
|
|
767
|
+
}
|
|
768
|
+
if (shape.kind === 'p') {
|
|
769
|
+
for (var j = 0; j < shape.points.length; j++) {
|
|
770
|
+
var pt = shape.points[j];
|
|
771
|
+
if (pt.ref) ids.push(pt.ref.id);
|
|
772
|
+
var crefs = segCtrlRefs(pt.seg);
|
|
773
|
+
for (var ci = 0; ci < crefs.length; ci++) ids.push(crefs[ci].id);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
return ids;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function shapeHasRefs(shape) {
|
|
780
|
+
if (shape.refs && Object.keys(shape.refs).length > 0) return true;
|
|
781
|
+
if (shape.kind === 'p') {
|
|
782
|
+
for (var j = 0; j < shape.points.length; j++) {
|
|
783
|
+
if (shape.points[j].ref) return true;
|
|
784
|
+
if (segCtrlRefs(shape.points[j].seg).length > 0) return true;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return false;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function resolveShape(shape, byId) {
|
|
791
|
+
if (shape.refs) {
|
|
792
|
+
var keys = Object.keys(shape.refs);
|
|
793
|
+
for (var k = 0; k < keys.length; k++) {
|
|
794
|
+
var slot = keys[k];
|
|
795
|
+
var r = shape.refs[slot];
|
|
796
|
+
var target = byId[r.id];
|
|
797
|
+
var ap = anchorPoint(target, r.anchor);
|
|
798
|
+
if (slot === 'center') { shape.cx = ap.x; shape.cy = ap.y; }
|
|
799
|
+
else if (slot === 'from') { shape.x1 = ap.x; shape.y1 = ap.y; }
|
|
800
|
+
else if (slot === 'to') { shape.x2 = ap.x; shape.y2 = ap.y; }
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (shape.kind === 'p') {
|
|
804
|
+
for (var j = 0; j < shape.points.length; j++) {
|
|
805
|
+
var pt = shape.points[j];
|
|
806
|
+
if (pt.ref) {
|
|
807
|
+
var tgt = byId[pt.ref.id];
|
|
808
|
+
var ap2 = anchorPoint(tgt, pt.ref.anchor);
|
|
809
|
+
pt.x = ap2.x;
|
|
810
|
+
pt.y = ap2.y;
|
|
811
|
+
}
|
|
812
|
+
// Resolve any @ref control points on the segment ending at this vertex.
|
|
813
|
+
if (pt.seg) {
|
|
814
|
+
var ctrlKeys = ['c', 'c1', 'c2'];
|
|
815
|
+
for (var ck = 0; ck < ctrlKeys.length; ck++) {
|
|
816
|
+
var sp = pt.seg[ctrlKeys[ck]];
|
|
817
|
+
if (sp && sp.ref) {
|
|
818
|
+
var ct = byId[sp.ref.id];
|
|
819
|
+
var cap = anchorPoint(ct, sp.ref.anchor);
|
|
820
|
+
sp.x = cap.x;
|
|
821
|
+
sp.y = cap.y;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Resolves @ref tokens into concrete coordinates. Mutates shapes in place.
|
|
830
|
+
// Returns { shapes, errors }. Refs metadata is preserved for serialization.
|
|
831
|
+
function resolve(shapes) {
|
|
832
|
+
var errors = [];
|
|
833
|
+
var byId = {};
|
|
834
|
+
for (var i = 0; i < shapes.length; i++) {
|
|
835
|
+
var s = shapes[i];
|
|
836
|
+
if (s.id) {
|
|
837
|
+
if (byId[s.id]) errors.push({ line: s.lineNumber, message: 'duplicate id "#' + s.id + '"' });
|
|
838
|
+
else byId[s.id] = s;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
var resolved = new Set();
|
|
843
|
+
for (var j = 0; j < shapes.length; j++) {
|
|
844
|
+
if (!shapeHasRefs(shapes[j])) resolved.add(shapes[j]);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
var progress = true;
|
|
848
|
+
while (progress) {
|
|
849
|
+
progress = false;
|
|
850
|
+
for (var m = 0; m < shapes.length; m++) {
|
|
851
|
+
var s2 = shapes[m];
|
|
852
|
+
if (resolved.has(s2)) continue;
|
|
853
|
+
var needs = refsInShape(s2);
|
|
854
|
+
var ready = true;
|
|
855
|
+
var missing = null;
|
|
856
|
+
for (var n = 0; n < needs.length; n++) {
|
|
857
|
+
if (!byId[needs[n]]) { missing = needs[n]; ready = false; break; }
|
|
858
|
+
if (!resolved.has(byId[needs[n]])) { ready = false; break; }
|
|
859
|
+
}
|
|
860
|
+
if (missing) {
|
|
861
|
+
errors.push({ line: s2.lineNumber, message: 'unknown id "@' + missing + '"' });
|
|
862
|
+
resolved.add(s2);
|
|
863
|
+
progress = true;
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
if (!ready) continue;
|
|
867
|
+
try {
|
|
868
|
+
resolveShape(s2, byId);
|
|
869
|
+
resolved.add(s2);
|
|
870
|
+
progress = true;
|
|
871
|
+
} catch (e) {
|
|
872
|
+
errors.push({ line: s2.lineNumber, message: e.message });
|
|
873
|
+
resolved.add(s2);
|
|
874
|
+
progress = true;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
for (var p = 0; p < shapes.length; p++) {
|
|
880
|
+
if (!resolved.has(shapes[p])) {
|
|
881
|
+
errors.push({ line: shapes[p].lineNumber, message: 'unresolvable reference (cycle)' });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return { shapes: shapes, errors: errors };
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
// Flags shapes whose bounding box extends GROSSLY outside the declared grid. A
|
|
889
|
+
// common agent mistake is writing `h 70` on a 56.25-tall grid and not realising
|
|
890
|
+
// the shape falls off the bottom. Surfaces as a parse-time error so the
|
|
891
|
+
// thumbnail badge catches it before the overflow is rendered.
|
|
892
|
+
//
|
|
893
|
+
// Intentional bleed is legitimate design, though — a setting sun clipped behind
|
|
894
|
+
// a mountain range, a decorative shape kissing the edge. So we allow a bleed
|
|
895
|
+
// tolerance of BLEED_TOL of each grid dimension before flagging. The `h 70`
|
|
896
|
+
// typo overflows by ~42% of grid height and stays flagged; a sun poking 1-2%
|
|
897
|
+
// past the bottom passes.
|
|
898
|
+
var BLEED_TOL = 0.1;
|
|
899
|
+
function checkGridBounds(shapes, grid) {
|
|
900
|
+
var errs = [];
|
|
901
|
+
var EPS = 0.001;
|
|
902
|
+
var tolX = grid.w * BLEED_TOL;
|
|
903
|
+
var tolY = grid.h * BLEED_TOL;
|
|
904
|
+
for (var i = 0; i < shapes.length; i++) {
|
|
905
|
+
var s = shapes[i];
|
|
906
|
+
// Large off-canvas shapes are a legitimate full-bleed technique. Make
|
|
907
|
+
// that intent explicit on the individual shape so unrelated overflow
|
|
908
|
+
// remains visible: `c ... bleed=allow`.
|
|
909
|
+
if (s.attrs && s.attrs.bleed === 'allow') continue;
|
|
910
|
+
// Lines and arrows are decorative, SVG clips them cleanly, and they
|
|
911
|
+
// commonly reference shape anchors that happen to sit at grid edges.
|
|
912
|
+
// Skip them to avoid false positives.
|
|
913
|
+
if (s.kind === 'l' || s.kind === 'a') continue;
|
|
914
|
+
try {
|
|
915
|
+
var bb = bboxOf(s);
|
|
916
|
+
var right = bb.x + bb.w;
|
|
917
|
+
var bottom = bb.y + bb.h;
|
|
918
|
+
if (bb.x < -tolX - EPS || bb.y < -tolY - EPS || right > grid.w + tolX + EPS || bottom > grid.h + tolY + EPS) {
|
|
919
|
+
errs.push({
|
|
920
|
+
line: s.lineNumber,
|
|
921
|
+
message: 'shape extends outside grid ' + grid.w + 'x' + grid.h
|
|
922
|
+
+ ' (bbox ' + bb.x.toFixed(1) + ',' + bb.y.toFixed(1)
|
|
923
|
+
+ ' to ' + right.toFixed(1) + ',' + bottom.toFixed(1) + ')',
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
} catch (e) { /* unresolvable shape — skip */ }
|
|
927
|
+
}
|
|
928
|
+
return errs;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Convenience: parse + resolve in one call.
|
|
932
|
+
function parseAndResolve(src) {
|
|
933
|
+
var pr = parse(src);
|
|
934
|
+
var rr = resolve(pr.shapes);
|
|
935
|
+
var bounds = checkGridBounds(rr.shapes, pr.grid);
|
|
936
|
+
return { shapes: rr.shapes, errors: pr.errors.concat(rr.errors, bounds), grid: pr.grid };
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// ─── Serialization (preserves refs for roundtrip) ──────
|
|
940
|
+
|
|
941
|
+
function refTokenStr(r) {
|
|
942
|
+
return '@' + r.id + (r.anchor && r.anchor !== 'center' ? '.' + r.anchor : '');
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// Stringify a control point that's either an x,y literal or an @ref.
|
|
946
|
+
// Used by polygon segment operators (>, *) to round-trip through serialize.
|
|
947
|
+
function ctrlTokenStr(c) {
|
|
948
|
+
if (c.ref) return refTokenStr(c.ref);
|
|
949
|
+
return c.x + ',' + c.y;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function serializeShape(s) {
|
|
953
|
+
var parts = [s.kind];
|
|
954
|
+
if (s.kind === 'r') {
|
|
955
|
+
parts.push(s.x, s.y, s.w, s.h);
|
|
956
|
+
} else if (s.kind === 'c') {
|
|
957
|
+
if (s.refs && s.refs.center) parts.push(refTokenStr(s.refs.center));
|
|
958
|
+
else parts.push(s.cx, s.cy);
|
|
959
|
+
parts.push(s.r);
|
|
960
|
+
} else if (s.kind === 'e') {
|
|
961
|
+
if (s.refs && s.refs.center) parts.push(refTokenStr(s.refs.center));
|
|
962
|
+
else parts.push(s.cx, s.cy);
|
|
963
|
+
parts.push(s.rx, s.ry);
|
|
964
|
+
} else if (s.kind === 'l' || s.kind === 'a') {
|
|
965
|
+
if (s.refs && s.refs.from) parts.push(refTokenStr(s.refs.from));
|
|
966
|
+
else parts.push(s.x1, s.y1);
|
|
967
|
+
if (s.bow != null) parts.push('^' + s.bow);
|
|
968
|
+
if (s.refs && s.refs.to) parts.push(refTokenStr(s.refs.to));
|
|
969
|
+
else parts.push(s.x2, s.y2);
|
|
970
|
+
} else if (s.kind === 'p') {
|
|
971
|
+
for (var i = 0; i < s.points.length; i++) {
|
|
972
|
+
var pt = s.points[i];
|
|
973
|
+
var seg = pt.seg;
|
|
974
|
+
if (seg && seg.type === 'arc') {
|
|
975
|
+
parts.push('^' + seg.sagitta);
|
|
976
|
+
} else if (seg && seg.type === 'quad') {
|
|
977
|
+
parts.push('>' + ctrlTokenStr(seg.c));
|
|
978
|
+
} else if (seg && seg.type === 'cubic') {
|
|
979
|
+
parts.push('*');
|
|
980
|
+
parts.push(ctrlTokenStr(seg.c1));
|
|
981
|
+
parts.push(ctrlTokenStr(seg.c2));
|
|
982
|
+
} else if ((seg && seg.type === 'smooth') || (!seg && pt.curve)) {
|
|
983
|
+
parts.push('~');
|
|
984
|
+
}
|
|
985
|
+
if (pt.round != null) parts.push('(' + pt.round);
|
|
986
|
+
if (pt.ref) parts.push(refTokenStr(pt.ref));
|
|
987
|
+
else parts.push(pt.x + ',' + pt.y);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (s.id) parts.push('#' + s.id + (s.required ? '!' : ''));
|
|
991
|
+
var keys = Object.keys(s.attrs || {});
|
|
992
|
+
for (var k = 0; k < keys.length; k++) parts.push(keys[k] + '=' + s.attrs[keys[k]]);
|
|
993
|
+
var line = parts.join(' ');
|
|
994
|
+
if (s.content != null) {
|
|
995
|
+
if (s.content.indexOf('\n') >= 0) {
|
|
996
|
+
// Multi-line content: emit block form (| alone, then 2-space-indented
|
|
997
|
+
// continuations) so serialize → parse round-trips correctly.
|
|
998
|
+
var indented = s.content.split('\n').map(function (l) { return ' ' + l; }).join('\n');
|
|
999
|
+
return line + ' |\n' + indented;
|
|
1000
|
+
}
|
|
1001
|
+
line += ' | ' + s.content;
|
|
1002
|
+
}
|
|
1003
|
+
return line;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
function serialize(shapes, grid) {
|
|
1007
|
+
var lines = [];
|
|
1008
|
+
var hasAttrs = grid && grid.attrs && Object.keys(grid.attrs).length > 0;
|
|
1009
|
+
var nonDefaultSize = grid && (grid.w !== DEFAULT_GRID.w || grid.h !== DEFAULT_GRID.h);
|
|
1010
|
+
if (nonDefaultSize || hasAttrs) {
|
|
1011
|
+
var gl = 'grid ' + grid.w + ' ' + grid.h;
|
|
1012
|
+
if (hasAttrs) {
|
|
1013
|
+
var keys = Object.keys(grid.attrs);
|
|
1014
|
+
for (var i = 0; i < keys.length; i++) gl += ' ' + keys[i] + '=' + grid.attrs[keys[i]];
|
|
1015
|
+
}
|
|
1016
|
+
lines.push(gl);
|
|
1017
|
+
}
|
|
1018
|
+
for (var j = 0; j < shapes.length; j++) lines.push(serializeShape(shapes[j]));
|
|
1019
|
+
return lines.join('\n');
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
exports.parse = parse;
|
|
1023
|
+
exports.parseLine = parseLine;
|
|
1024
|
+
exports.resolve = resolve;
|
|
1025
|
+
exports.parseAndResolve = parseAndResolve;
|
|
1026
|
+
exports.anchorPoint = anchorPoint;
|
|
1027
|
+
exports.bboxOf = bboxOf;
|
|
1028
|
+
exports.checkGridBounds = checkGridBounds;
|
|
1029
|
+
exports.contentBox = contentBox;
|
|
1030
|
+
exports.parseTextBox = parseTextBox;
|
|
1031
|
+
exports.decodeContentLineBreaks = decodeContentLineBreaks;
|
|
1032
|
+
exports.chevTip = chevTip;
|
|
1033
|
+
exports.chevNotch = chevNotch;
|
|
1034
|
+
exports.cylLip = cylLip;
|
|
1035
|
+
exports.cylLidColors = cylLidColors;
|
|
1036
|
+
exports.bubTail = bubTail;
|
|
1037
|
+
exports.tabHeight = tabHeight;
|
|
1038
|
+
exports.docFold = docFold;
|
|
1039
|
+
exports.serialize = serialize;
|
|
1040
|
+
exports.serializeShape = serializeShape;
|
|
1041
|
+
exports.ANCHOR_TABLE = ANCHOR_TABLE;
|
|
1042
|
+
exports.DEFAULT_GRID = DEFAULT_GRID;
|
|
1043
|
+
|
|
1044
|
+
})(typeof module !== 'undefined' && module.exports ? module.exports : (window.SDocShapes = {}));
|