justskill-design 0.0.1 → 3.2.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.en.md +235 -0
- package/README.md +184 -11
- package/SKILL.md +435 -0
- package/canvas/app.mjs +2870 -0
- package/canvas/groups.mjs +204 -0
- package/canvas/index.html +141 -0
- package/canvas/styles.css +1032 -0
- package/canvas/tree.mjs +249 -0
- package/cordis.patch.yml +8 -0
- package/design.mjs +172 -0
- package/docs/dsh-workbench.png +0 -0
- package/dsh/client.js +864 -0
- package/dsh/index.js +354 -0
- package/dsh/scan.js +139 -0
- package/dsh/skill.js +41 -0
- package/lib/base-css.mjs +92 -0
- package/lib/detect.mjs +123 -0
- package/lib/diagnostics.mjs +382 -0
- package/lib/export-html.mjs +309 -0
- package/lib/export-svg.mjs +111 -0
- package/lib/fragment.mjs +702 -0
- package/lib/geometry.mjs +827 -0
- package/lib/icons.mjs +106 -0
- package/lib/levenshtein.mjs +133 -0
- package/lib/node/cli.mjs +387 -0
- package/lib/node/export-svg.mjs +75 -0
- package/lib/node/fs.mjs +309 -0
- package/lib/node/op-json.mjs +75 -0
- package/lib/node/raster-run.mjs +115 -0
- package/lib/node/server.mjs +1264 -0
- package/lib/node/static.mjs +206 -0
- package/lib/node/verbs/add.mjs +220 -0
- package/lib/node/verbs/export.mjs +161 -0
- package/lib/node/verbs/format.mjs +106 -0
- package/lib/node/verbs/import.mjs +126 -0
- package/lib/node/verbs/ls.mjs +98 -0
- package/lib/node/verbs/migrate.mjs +236 -0
- package/lib/node/verbs/move.mjs +76 -0
- package/lib/node/verbs/new.mjs +284 -0
- package/lib/node/verbs/remove.mjs +41 -0
- package/lib/node/verbs/rename.mjs +87 -0
- package/lib/node/verbs/serve.mjs +164 -0
- package/lib/node/verbs/set.mjs +150 -0
- package/lib/node/verbs/show.mjs +122 -0
- package/lib/node/verbs/tokens.mjs +167 -0
- package/lib/node/verbs/validate.mjs +130 -0
- package/lib/node/write-verbs.mjs +215 -0
- package/lib/normalize.mjs +288 -0
- package/lib/ops.mjs +2063 -0
- package/lib/parse.mjs +997 -0
- package/lib/raster.mjs +186 -0
- package/lib/readers/pen.mjs +1684 -0
- package/lib/receipt.mjs +203 -0
- package/lib/render.mjs +503 -0
- package/lib/serialize.mjs +441 -0
- package/lib/spans.mjs +177 -0
- package/lib/svg-path.mjs +483 -0
- package/lib/table.mjs +1047 -0
- package/lib/tokens.mjs +295 -0
- package/lib/validate.mjs +929 -0
- package/package.json +68 -6
- package/references/cookbook.md +156 -0
- package/references/images.md +171 -0
- package/references/product-flow.md +408 -0
- package/references/schema.md +90 -0
- package/references/styling.md +181 -0
- package/scripts/install.mjs +95 -0
package/lib/svg-path.mjs
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/svg-path.mjs — a `d` string, measured and rewritten.
|
|
3
|
+
*
|
|
4
|
+
* pen stores a `path` node's outline as geometry, and this format stores geometry
|
|
5
|
+
* too, so an import has to answer two questions the string does not answer by
|
|
6
|
+
* itself:
|
|
7
|
+
*
|
|
8
|
+
* where the drawing is its ink box, which becomes the glyph's `viewBox`
|
|
9
|
+
* where it lands when turned the same outline after a rotation, because the
|
|
10
|
+
* format has no `transform` to express one
|
|
11
|
+
*
|
|
12
|
+
* Both are the same scan of the same grammar, so both live here. Split between
|
|
13
|
+
* two callers they would become two half-parsers that disagree about the one
|
|
14
|
+
* command neither exercised until a real file used it.
|
|
15
|
+
*
|
|
16
|
+
* The grammar is SVG's, arc flags included. In `a1 1 0 011 1` the `011` is three
|
|
17
|
+
* tokens — large-arc 0, sweep 1, x 1 — and not the number 11. A scanner that
|
|
18
|
+
* reads numbers first is wrong on the first file that writes its flags without
|
|
19
|
+
* spaces, which is legal and which export tools do write.
|
|
20
|
+
*
|
|
21
|
+
* Every number here is a coordinate in the path's own coordinate system. What
|
|
22
|
+
* that system means to the page is the caller's business: the reader decides it
|
|
23
|
+
* with the node's own width and height.
|
|
24
|
+
*
|
|
25
|
+
* PURE ESM — no `node:` imports.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Raised for a string this module will not guess about. */
|
|
29
|
+
export class PathError extends Error {
|
|
30
|
+
constructor(message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "PathError";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const COMMANDS = "MmLlHhVvCcSsQqTtAaZz";
|
|
37
|
+
const ARGUMENT_COUNT = { M: 2, L: 2, H: 1, V: 1, C: 6, S: 4, Q: 4, T: 2, A: 7, Z: 0 };
|
|
38
|
+
|
|
39
|
+
// -- scanning -----------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A cursor over one `d` string.
|
|
43
|
+
*
|
|
44
|
+
* A scanner rather than a `matchAll`: the arc flags are single characters that
|
|
45
|
+
* may sit against the number after them, so which token comes next depends on
|
|
46
|
+
* the command being read, and a regular expression cannot know that.
|
|
47
|
+
*/
|
|
48
|
+
function scannerFor(text) {
|
|
49
|
+
const number = /[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/y;
|
|
50
|
+
let at = 0;
|
|
51
|
+
|
|
52
|
+
const skipSeparators = () => {
|
|
53
|
+
while (at < text.length && (text[at] === "," || /\s/.test(text[at]))) at += 1;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
end() {
|
|
58
|
+
skipSeparators();
|
|
59
|
+
return at >= text.length;
|
|
60
|
+
},
|
|
61
|
+
/** The next command letter, or "" at the end of the string. */
|
|
62
|
+
peekCommand() {
|
|
63
|
+
skipSeparators();
|
|
64
|
+
return COMMANDS.includes(text[at] ?? "") ? text[at] : "";
|
|
65
|
+
},
|
|
66
|
+
takeCommand() {
|
|
67
|
+
const letter = this.peekCommand();
|
|
68
|
+
if (letter === "") throw new PathError(`expected a command at offset ${at}`);
|
|
69
|
+
at += 1;
|
|
70
|
+
return letter;
|
|
71
|
+
},
|
|
72
|
+
/** Whether another argument follows, which is how implicit repeats are read. */
|
|
73
|
+
hasNumber() {
|
|
74
|
+
skipSeparators();
|
|
75
|
+
return at < text.length && /[-+.\d]/.test(text[at]);
|
|
76
|
+
},
|
|
77
|
+
takeNumber() {
|
|
78
|
+
skipSeparators();
|
|
79
|
+
number.lastIndex = at;
|
|
80
|
+
const match = number.exec(text);
|
|
81
|
+
if (!match) throw new PathError(`expected a number at offset ${at}`);
|
|
82
|
+
at = number.lastIndex;
|
|
83
|
+
return Number(match[0]);
|
|
84
|
+
},
|
|
85
|
+
/** An arc flag: exactly one `0` or `1`, even when it touches what follows. */
|
|
86
|
+
takeFlag() {
|
|
87
|
+
skipSeparators();
|
|
88
|
+
const character = text[at];
|
|
89
|
+
if (character !== "0" && character !== "1") throw new PathError(`expected an arc flag at offset ${at}`);
|
|
90
|
+
at += 1;
|
|
91
|
+
return Number(character);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// -- parsing ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One command, in absolute coordinates.
|
|
100
|
+
*
|
|
101
|
+
* Relative commands are resolved as they are read, because both callers want
|
|
102
|
+
* points and neither wants to walk a current-point cursor twice. `H` and `V`
|
|
103
|
+
* are already `L`, `S` and `T` are already the `C` and `Q` they mean — pen
|
|
104
|
+
* writes both spellings and the reflection rules are the parser's to apply, not
|
|
105
|
+
* its callers'.
|
|
106
|
+
*/
|
|
107
|
+
function parsePath(d) {
|
|
108
|
+
const text = String(d ?? "");
|
|
109
|
+
if (text === "") throw new PathError("empty path");
|
|
110
|
+
const scan = scannerFor(text);
|
|
111
|
+
const segments = [];
|
|
112
|
+
|
|
113
|
+
let x = 0;
|
|
114
|
+
let y = 0;
|
|
115
|
+
let startX = 0;
|
|
116
|
+
let startY = 0;
|
|
117
|
+
let lastCubic = null; // reflected for `S`
|
|
118
|
+
let lastQuad = null; // reflected for `T`
|
|
119
|
+
let command = "";
|
|
120
|
+
|
|
121
|
+
while (!scan.end()) {
|
|
122
|
+
const letter = scan.peekCommand();
|
|
123
|
+
if (letter !== "") command = scan.takeCommand();
|
|
124
|
+
else if (command === "" || command === "Z" || command === "z") {
|
|
125
|
+
throw new PathError("a path must start with a moveto");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const upper = command.toUpperCase();
|
|
129
|
+
const relative = command !== upper;
|
|
130
|
+
const base = { x: relative ? x : 0, y: relative ? y : 0 };
|
|
131
|
+
|
|
132
|
+
if (upper === "Z") {
|
|
133
|
+
segments.push({ command: "Z" });
|
|
134
|
+
x = startX;
|
|
135
|
+
y = startY;
|
|
136
|
+
lastCubic = null;
|
|
137
|
+
lastQuad = null;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Every other command takes its arguments; a command with no further numbers
|
|
142
|
+
// has been read, and the loop moves on to the next letter.
|
|
143
|
+
if (!scan.hasNumber()) {
|
|
144
|
+
if (ARGUMENT_COUNT[upper] === 0) continue;
|
|
145
|
+
throw new PathError(`${command} without arguments`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const point = (ax, ay) => ({ x: base.x + ax, y: base.y + ay });
|
|
149
|
+
|
|
150
|
+
if (upper === "M") {
|
|
151
|
+
const p = point(scan.takeNumber(), scan.takeNumber());
|
|
152
|
+
segments.push({ command: "M", points: [p] });
|
|
153
|
+
x = p.x;
|
|
154
|
+
y = p.y;
|
|
155
|
+
startX = x;
|
|
156
|
+
startY = y;
|
|
157
|
+
// A second pair after a moveto is a lineto. The spec says so, and every
|
|
158
|
+
// font export relies on it.
|
|
159
|
+
command = relative ? "l" : "L";
|
|
160
|
+
lastCubic = null;
|
|
161
|
+
lastQuad = null;
|
|
162
|
+
} else if (upper === "L") {
|
|
163
|
+
const p = point(scan.takeNumber(), scan.takeNumber());
|
|
164
|
+
segments.push({ command: "L", points: [p] });
|
|
165
|
+
x = p.x;
|
|
166
|
+
y = p.y;
|
|
167
|
+
lastCubic = null;
|
|
168
|
+
lastQuad = null;
|
|
169
|
+
} else if (upper === "H") {
|
|
170
|
+
const p = { x: base.x + scan.takeNumber(), y };
|
|
171
|
+
segments.push({ command: "L", points: [p] });
|
|
172
|
+
x = p.x;
|
|
173
|
+
lastCubic = null;
|
|
174
|
+
lastQuad = null;
|
|
175
|
+
} else if (upper === "V") {
|
|
176
|
+
const p = { x, y: base.y + scan.takeNumber() };
|
|
177
|
+
segments.push({ command: "L", points: [p] });
|
|
178
|
+
y = p.y;
|
|
179
|
+
lastCubic = null;
|
|
180
|
+
lastQuad = null;
|
|
181
|
+
} else if (upper === "C" || upper === "S") {
|
|
182
|
+
let control1;
|
|
183
|
+
if (upper === "C") {
|
|
184
|
+
control1 = point(scan.takeNumber(), scan.takeNumber());
|
|
185
|
+
} else {
|
|
186
|
+
// The first control point of a smooth curve is the previous one mirrored
|
|
187
|
+
// through the current point; with no previous curve it IS the current
|
|
188
|
+
// point.
|
|
189
|
+
control1 = lastCubic ? { x: 2 * x - lastCubic.x, y: 2 * y - lastCubic.y } : { x, y };
|
|
190
|
+
}
|
|
191
|
+
const control2 = point(scan.takeNumber(), scan.takeNumber());
|
|
192
|
+
const end = point(scan.takeNumber(), scan.takeNumber());
|
|
193
|
+
segments.push({ command: "C", points: [control1, control2, end] });
|
|
194
|
+
x = end.x;
|
|
195
|
+
y = end.y;
|
|
196
|
+
lastCubic = control2;
|
|
197
|
+
lastQuad = null;
|
|
198
|
+
} else if (upper === "Q" || upper === "T") {
|
|
199
|
+
let control;
|
|
200
|
+
if (upper === "Q") control = point(scan.takeNumber(), scan.takeNumber());
|
|
201
|
+
else control = lastQuad ? { x: 2 * x - lastQuad.x, y: 2 * y - lastQuad.y } : { x, y };
|
|
202
|
+
const end = point(scan.takeNumber(), scan.takeNumber());
|
|
203
|
+
segments.push({ command: "Q", points: [control, end] });
|
|
204
|
+
x = end.x;
|
|
205
|
+
y = end.y;
|
|
206
|
+
lastQuad = control;
|
|
207
|
+
lastCubic = null;
|
|
208
|
+
} else if (upper === "A") {
|
|
209
|
+
const rx = scan.takeNumber();
|
|
210
|
+
const ry = scan.takeNumber();
|
|
211
|
+
const rotation = scan.takeNumber();
|
|
212
|
+
const largeArc = scan.takeFlag();
|
|
213
|
+
const sweep = scan.takeFlag();
|
|
214
|
+
const end = point(scan.takeNumber(), scan.takeNumber());
|
|
215
|
+
segments.push({ command: "A", rx, ry, rotation, largeArc, sweep, points: [end], from: { x, y } });
|
|
216
|
+
x = end.x;
|
|
217
|
+
y = end.y;
|
|
218
|
+
lastCubic = null;
|
|
219
|
+
lastQuad = null;
|
|
220
|
+
} else {
|
|
221
|
+
throw new PathError(`unsupported command ${command}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (segments.length === 0) throw new PathError("no drawing commands");
|
|
226
|
+
return segments;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// -- measuring ----------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The extrema of one cubic segment, per axis.
|
|
233
|
+
*
|
|
234
|
+
* Solving the derivative rather than sampling the curve: a sampled box is
|
|
235
|
+
* always slightly too small, and a box that is too small is a glyph scaled up
|
|
236
|
+
* by an amount nobody can see and nobody can explain.
|
|
237
|
+
*/
|
|
238
|
+
function cubicExtremes(p0, p1, p2, p3, out) {
|
|
239
|
+
for (const axis of ["x", "y"]) {
|
|
240
|
+
const a = -p0[axis] + 3 * p1[axis] - 3 * p2[axis] + p3[axis];
|
|
241
|
+
const b = 2 * (p0[axis] - 2 * p1[axis] + p2[axis]);
|
|
242
|
+
const c = p1[axis] - p0[axis];
|
|
243
|
+
const roots = [];
|
|
244
|
+
if (Math.abs(a) < 1e-12) {
|
|
245
|
+
if (Math.abs(b) > 1e-12) roots.push(-c / b);
|
|
246
|
+
} else {
|
|
247
|
+
const discriminant = b * b - 4 * a * c;
|
|
248
|
+
if (discriminant >= 0) {
|
|
249
|
+
const root = Math.sqrt(discriminant);
|
|
250
|
+
roots.push((-b + root) / (2 * a), (-b - root) / (2 * a));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const t of roots) {
|
|
254
|
+
if (!(t > 0 && t < 1)) continue;
|
|
255
|
+
const mt = 1 - t;
|
|
256
|
+
out.push({
|
|
257
|
+
x: mt * mt * mt * p0.x + 3 * mt * mt * t * p1.x + 3 * mt * t * t * p2.x + t * t * t * p3.x,
|
|
258
|
+
y: mt * mt * mt * p0.y + 3 * mt * mt * t * p1.y + 3 * mt * t * t * p2.y + t * t * t * p3.y,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* The extrema of one quadratic segment, per axis.
|
|
266
|
+
*
|
|
267
|
+
* The derivative is linear, so the turning point is one division away. It is NOT
|
|
268
|
+
* the cubic whose two control points are the same point — that is a different
|
|
269
|
+
* curve, and reading it as one moves every quadratic's extremum by a fifth.
|
|
270
|
+
*/
|
|
271
|
+
function quadExtremes(p0, p1, p2, out) {
|
|
272
|
+
for (const axis of ["x", "y"]) {
|
|
273
|
+
const denominator = p0[axis] - 2 * p1[axis] + p2[axis];
|
|
274
|
+
if (Math.abs(denominator) < 1e-12) continue;
|
|
275
|
+
const t = (p0[axis] - p1[axis]) / denominator;
|
|
276
|
+
if (!(t > 0 && t < 1)) continue;
|
|
277
|
+
const mt = 1 - t;
|
|
278
|
+
out.push({
|
|
279
|
+
x: mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x,
|
|
280
|
+
y: mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* An arc's extremes, on its own ellipse.
|
|
287
|
+
*
|
|
288
|
+
* Endpoints alone are not enough: an arc that bulges out and comes back has its
|
|
289
|
+
* extremes strictly between them, and a box built from the endpoints clips the
|
|
290
|
+
* bulge. The parameters below are SVG's own (appendix F.6.5), out-of-range radii
|
|
291
|
+
* included — a radius too small for its chord is scaled up, and the scaled one is
|
|
292
|
+
* what gets drawn.
|
|
293
|
+
*/
|
|
294
|
+
function arcPoints(segment, out) {
|
|
295
|
+
const { rx: rxIn, ry: ryIn, rotation, largeArc, sweep, points, from } = segment;
|
|
296
|
+
const to = points[0];
|
|
297
|
+
out.push(from, to);
|
|
298
|
+
|
|
299
|
+
let rx = Math.abs(rxIn);
|
|
300
|
+
let ry = Math.abs(ryIn);
|
|
301
|
+
if (rx === 0 || ry === 0) return; // the spec draws a straight line instead
|
|
302
|
+
if (from.x === to.x && from.y === to.y) return; // a zero-length arc draws nothing
|
|
303
|
+
|
|
304
|
+
const phi = (rotation * Math.PI) / 180;
|
|
305
|
+
const cos = Math.cos(phi);
|
|
306
|
+
const sin = Math.sin(phi);
|
|
307
|
+
const dx = (from.x - to.x) / 2;
|
|
308
|
+
const dy = (from.y - to.y) / 2;
|
|
309
|
+
const x1 = cos * dx + sin * dy;
|
|
310
|
+
const y1 = -sin * dx + cos * dy;
|
|
311
|
+
|
|
312
|
+
const lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry);
|
|
313
|
+
if (lambda > 1) {
|
|
314
|
+
const scale = Math.sqrt(lambda);
|
|
315
|
+
rx *= scale;
|
|
316
|
+
ry *= scale;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const sign = largeArc === sweep ? -1 : 1;
|
|
320
|
+
const numerator = Math.max(0, rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1);
|
|
321
|
+
const denominator = rx * rx * y1 * y1 + ry * ry * x1 * x1;
|
|
322
|
+
const factor = denominator === 0 ? 0 : sign * Math.sqrt(numerator / denominator);
|
|
323
|
+
const cx1 = (factor * rx * y1) / ry;
|
|
324
|
+
const cy1 = (-factor * ry * x1) / rx;
|
|
325
|
+
const cx = cos * cx1 - sin * cy1 + (from.x + to.x) / 2;
|
|
326
|
+
const cy = sin * cx1 + cos * cy1 + (from.y + to.y) / 2;
|
|
327
|
+
|
|
328
|
+
const start = Math.atan2((y1 - cy1) / ry, (x1 - cx1) / rx);
|
|
329
|
+
const end = Math.atan2((-y1 - cy1) / ry, (-x1 - cx1) / rx);
|
|
330
|
+
let delta = end - start;
|
|
331
|
+
if (!sweep && delta > 0) delta -= 2 * Math.PI;
|
|
332
|
+
if (sweep && delta < 0) delta += 2 * Math.PI;
|
|
333
|
+
|
|
334
|
+
const at = (theta) => ({
|
|
335
|
+
x: cx + rx * Math.cos(theta) * cos - ry * Math.sin(theta) * sin,
|
|
336
|
+
y: cy + rx * Math.cos(theta) * sin + ry * Math.sin(theta) * cos,
|
|
337
|
+
});
|
|
338
|
+
const within = (theta) => {
|
|
339
|
+
let t = theta - start;
|
|
340
|
+
if (delta >= 0) {
|
|
341
|
+
while (t < 0) t += 2 * Math.PI;
|
|
342
|
+
while (t > 2 * Math.PI) t -= 2 * Math.PI;
|
|
343
|
+
return t <= delta;
|
|
344
|
+
}
|
|
345
|
+
while (t > 0) t -= 2 * Math.PI;
|
|
346
|
+
while (t < -2 * Math.PI) t += 2 * Math.PI;
|
|
347
|
+
return t >= delta;
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Where the ellipse turns around: the parameter at which dx/dtheta and
|
|
351
|
+
// dy/dtheta are each zero, plus the half turn that mirrors each of them.
|
|
352
|
+
const thetaX = Math.atan2(-ry * sin, rx * cos);
|
|
353
|
+
const thetaY = Math.atan2(ry * cos, rx * sin);
|
|
354
|
+
for (const theta of [thetaX, thetaX + Math.PI, thetaY, thetaY + Math.PI]) {
|
|
355
|
+
if (within(theta)) out.push(at(theta));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* The box the outline actually covers, in its own coordinates.
|
|
361
|
+
*
|
|
362
|
+
* `null` means the string cannot be trusted to describe a box: it does not
|
|
363
|
+
* parse, it draws nothing, or its box has no area. A box with no area is not a
|
|
364
|
+
* coordinate system — a caller that used one would divide by zero and scale the
|
|
365
|
+
* drawing into nothing — so it is reported as an absence rather than as zeros.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} d
|
|
368
|
+
* @returns {{minX: number, minY: number, maxX: number, maxY: number}|null}
|
|
369
|
+
*/
|
|
370
|
+
export function pathBounds(d) {
|
|
371
|
+
let segments;
|
|
372
|
+
try {
|
|
373
|
+
segments = parsePath(d);
|
|
374
|
+
} catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const points = [];
|
|
379
|
+
// The curve commands need the point they start from, and that point is not
|
|
380
|
+
// always the last one measured: after a `Z` the pen is back at the subpath's
|
|
381
|
+
// start, which is a different point.
|
|
382
|
+
let current = null;
|
|
383
|
+
let startX = 0;
|
|
384
|
+
let startY = 0;
|
|
385
|
+
for (const segment of segments) {
|
|
386
|
+
if (segment.command === "Z") {
|
|
387
|
+
current = { x: startX, y: startY };
|
|
388
|
+
} else if (segment.command === "A") {
|
|
389
|
+
arcPoints(segment, points);
|
|
390
|
+
current = segment.points[0];
|
|
391
|
+
} else if (segment.command === "C") {
|
|
392
|
+
const [p1, p2, p3] = segment.points;
|
|
393
|
+
const p0 = current ?? p3;
|
|
394
|
+
// The control points are deliberately NOT measured. They are not on the
|
|
395
|
+
// curve, and a box that includes them is bigger than the ink — the wifi
|
|
396
|
+
// glyph's box grew by a whole unit that way, and the glyph, scaled to its
|
|
397
|
+
// node box, came out smaller than pen drew it.
|
|
398
|
+
points.push(p3);
|
|
399
|
+
cubicExtremes(p0, p1, p2, p3, points);
|
|
400
|
+
current = p3;
|
|
401
|
+
} else if (segment.command === "Q") {
|
|
402
|
+
const [p1, p2] = segment.points;
|
|
403
|
+
const p0 = current ?? p2;
|
|
404
|
+
points.push(p2);
|
|
405
|
+
quadExtremes(p0, p1, p2, points);
|
|
406
|
+
current = p2;
|
|
407
|
+
} else {
|
|
408
|
+
points.push(...segment.points);
|
|
409
|
+
current = segment.points[segment.points.length - 1];
|
|
410
|
+
if (segment.command === "M") {
|
|
411
|
+
startX = current.x;
|
|
412
|
+
startY = current.y;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let minX = Infinity;
|
|
418
|
+
let minY = Infinity;
|
|
419
|
+
let maxX = -Infinity;
|
|
420
|
+
let maxY = -Infinity;
|
|
421
|
+
for (const point of points) {
|
|
422
|
+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
|
|
423
|
+
minX = Math.min(minX, point.x);
|
|
424
|
+
maxX = Math.max(maxX, point.x);
|
|
425
|
+
minY = Math.min(minY, point.y);
|
|
426
|
+
maxY = Math.max(maxY, point.y);
|
|
427
|
+
}
|
|
428
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return null;
|
|
429
|
+
if (maxX - minX <= 0 || maxY - minY <= 0) return null;
|
|
430
|
+
return { minX, minY, maxX, maxY };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// -- rewriting ----------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
/** Four decimals: finer than any coordinate pen writes, and short enough to read. */
|
|
436
|
+
const round4 = (value) => {
|
|
437
|
+
const rounded = Math.round(value * 1e4) / 1e4;
|
|
438
|
+
return Object.is(rounded, -0) ? 0 : rounded;
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* The same outline, turned by `degrees` about `(cx, cy)`.
|
|
443
|
+
*
|
|
444
|
+
* The output is absolute commands, because a rotated relative command is a
|
|
445
|
+
* different command and rewriting one into the other is where a hand-written
|
|
446
|
+
* transformer goes wrong. `A` keeps its radii and its flags — a rotation is
|
|
447
|
+
* orientation-preserving, so the sweep does not flip — and adds `degrees` to its
|
|
448
|
+
* own x-axis rotation, which is how SVG says "this ellipse is turned".
|
|
449
|
+
*
|
|
450
|
+
* @param {string} d
|
|
451
|
+
* @param {number} degrees clockwise, as CSS measures it
|
|
452
|
+
* @param {number} cx
|
|
453
|
+
* @param {number} cy
|
|
454
|
+
* @returns {string}
|
|
455
|
+
*/
|
|
456
|
+
export function rotatePath(d, degrees, cx, cy) {
|
|
457
|
+
const segments = parsePath(d);
|
|
458
|
+
const radians = (degrees * Math.PI) / 180;
|
|
459
|
+
const cos = Math.cos(radians);
|
|
460
|
+
const sin = Math.sin(radians);
|
|
461
|
+
const turn = (point) => {
|
|
462
|
+
const dx = point.x - cx;
|
|
463
|
+
const dy = point.y - cy;
|
|
464
|
+
return { x: cx + dx * cos - dy * sin, y: cy + dx * sin + dy * cos };
|
|
465
|
+
};
|
|
466
|
+
const pair = (point) => `${round4(point.x)} ${round4(point.y)}`;
|
|
467
|
+
|
|
468
|
+
const parts = [];
|
|
469
|
+
for (const segment of segments) {
|
|
470
|
+
if (segment.command === "Z") {
|
|
471
|
+
parts.push("Z");
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
if (segment.command === "A") {
|
|
475
|
+
// `rotation + degrees` follows the coordinate system the ellipse is drawn
|
|
476
|
+
// in: the arc's own angle is measured inside it.
|
|
477
|
+
parts.push(`A${round4(segment.rx)} ${round4(segment.ry)} ${round4(segment.rotation + degrees)} ${segment.largeArc} ${segment.sweep} ${pair(turn(segment.points[0]))}`);
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
parts.push(`${segment.command}${segment.points.map((point) => pair(turn(point))).join(" ")}`);
|
|
481
|
+
}
|
|
482
|
+
return parts.join("");
|
|
483
|
+
}
|