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/render.mjs
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/render.mjs — the document tree to what the canvas mounts.
|
|
3
|
+
*
|
|
4
|
+
* The renderer is shared by the canvas and by `export`. That is the whole point:
|
|
5
|
+
* what the user drags is what ships. A separate "preview renderer" is how a
|
|
6
|
+
* design tool ends up with two truths.
|
|
7
|
+
*
|
|
8
|
+
* Browser-safe on purpose. The canvas parses and renders the file itself, so the
|
|
9
|
+
* service never has to send a document tree over the wire and the two sides
|
|
10
|
+
* cannot disagree about what the file means.
|
|
11
|
+
*
|
|
12
|
+
* What the renderer does:
|
|
13
|
+
* - resolves `var(--token)` from the file's token block
|
|
14
|
+
* - inlines the token block into each page's scope
|
|
15
|
+
* - positions pages with the page-frame block and `data-canvas`
|
|
16
|
+
* - keeps every element's `data-id`, so the canvas can map a click to an op
|
|
17
|
+
*
|
|
18
|
+
* What it does not do:
|
|
19
|
+
* - it does not rewrite the design file. A mount is a view.
|
|
20
|
+
* - it does not inject canvas chrome into the design markup. The canvas puts
|
|
21
|
+
* its own styles in its own document, and each page gets a shadow root.
|
|
22
|
+
*
|
|
23
|
+
* Pure. No node: imports.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { parse, parseDeclarations, getAttr, pagesOf, elementChildren, textOf, VOID_ELEMENTS, styleMapOf } from "./parse.mjs";
|
|
27
|
+
import { collectTokens } from "./validate.mjs";
|
|
28
|
+
import { CSS_ALLOWED, splitTopLevel } from "./table.mjs";
|
|
29
|
+
import { resolveTokens } from "./tokens.mjs";
|
|
30
|
+
|
|
31
|
+
// -- escaping ----------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
const TEXT_ESCAPES = { "&": "&", "<": "<", ">": ">" };
|
|
34
|
+
const ATTR_ESCAPES = { "&": "&", '"': """, "<": "<", ">": ">" };
|
|
35
|
+
|
|
36
|
+
export function escapeText(value) {
|
|
37
|
+
return String(value ?? "").replace(/[&<>]/g, (ch) => TEXT_ESCAPES[ch]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function escapeAttr(value) {
|
|
41
|
+
return String(value ?? "").replace(/[&"<>]/g, (ch) => ATTR_ESCAPES[ch]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// -- style -------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
// `styleMapOf` is defined in `lib/parse.mjs`, next to `parseDeclarations`, and
|
|
47
|
+
// re-exported here because this module is where its callers look for it. It had
|
|
48
|
+
// a second copy of the declaration splitter beside it until the comment that
|
|
49
|
+
// justified the copy turned out to be false: this module already imports `parse`
|
|
50
|
+
// and `getAttr` from `parse.mjs`, so there was never a cycle to avoid.
|
|
51
|
+
export { styleMapOf };
|
|
52
|
+
|
|
53
|
+
/** Serialize a property map back to a `style` attribute value, in table order. */
|
|
54
|
+
export function styleStringOf(map) {
|
|
55
|
+
const entries = [...map.entries()].filter(([, value]) => value !== undefined && value !== null && value !== "");
|
|
56
|
+
entries.sort((a, b) => {
|
|
57
|
+
const ai = EMIT.indexOf(a[0]);
|
|
58
|
+
const bi = EMIT.indexOf(b[0]);
|
|
59
|
+
if (ai !== bi) return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
|
|
60
|
+
return a[0] < b[0] ? -1 : 1;
|
|
61
|
+
});
|
|
62
|
+
return entries.map(([p, v]) => `${p}:${v}`).join(";");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const EMIT = Object.keys(CSS_ALLOWED);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Replace `var(--name)` with the token's value, following the chain.
|
|
69
|
+
*
|
|
70
|
+
* `seen` is the chain, not the whole document: a name may be referenced twice in one
|
|
71
|
+
* value and only the second visit of the *same* chain is a cycle. Following a cycle
|
|
72
|
+
* is what `lib/export-html.mjs` did with its own copy of this function, and the
|
|
73
|
+
* report for a cyclic file was `REFUSED Maximum call stack size exceeded` — no line,
|
|
74
|
+
* no name, no hint. `docs/plan.md` §4.2 says the chain stops at 16 and that a cycle
|
|
75
|
+
* is refused; both stops are here so neither can be lost again.
|
|
76
|
+
*/
|
|
77
|
+
// `resolveTokens` is defined in `lib/tokens.mjs`, with the depth limit and the
|
|
78
|
+
// cycle rule it obeys, and re-exported here because this module is where its
|
|
79
|
+
// callers look for it.
|
|
80
|
+
export { resolveTokens };
|
|
81
|
+
|
|
82
|
+
/** `data-canvas="x,y"`, or null when the page has no stated position. */
|
|
83
|
+
export function canvasPositionOf(node) {
|
|
84
|
+
const raw = getAttr(node, "data-canvas");
|
|
85
|
+
if (raw === null) return null;
|
|
86
|
+
const match = /^\s*(-?\d+(?:\.\d+)?)\s*,\s*(-?\d+(?:\.\d+)?)\s*$/.exec(raw);
|
|
87
|
+
if (!match) return null;
|
|
88
|
+
return { x: Number(match[1]), y: Number(match[2]) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function cloneTree(node) {
|
|
92
|
+
if (node.kind === "text") return { kind: "text", value: node.value, raw: node.raw };
|
|
93
|
+
if (node.kind === "comment" || node.kind === "doctype") return { kind: node.kind, raw: node.raw };
|
|
94
|
+
return {
|
|
95
|
+
kind: "element",
|
|
96
|
+
tag: node.tag,
|
|
97
|
+
rawTag: node.rawTag,
|
|
98
|
+
selfClosing: node.selfClosing,
|
|
99
|
+
attrs: (node.attrs || []).map((a) => ({ ...a })),
|
|
100
|
+
children: (node.children || []).map(cloneTree),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// -- the render model --------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Build the render model for a parsed document.
|
|
108
|
+
*
|
|
109
|
+
* @param {object} doc
|
|
110
|
+
* @param {object} options `{ resolveTokens: boolean }`
|
|
111
|
+
* @returns {{ pages: Array, tokens: Map, diagnostics: Array, pageFrame: string }}
|
|
112
|
+
*/
|
|
113
|
+
export function renderModel(doc, options = {}) {
|
|
114
|
+
const { tokens } = collectTokens(doc);
|
|
115
|
+
const pages = [];
|
|
116
|
+
let index = 0;
|
|
117
|
+
|
|
118
|
+
for (const node of pagesOf(doc)) {
|
|
119
|
+
const id = getAttr(node, "data-id") || `page-${index}`;
|
|
120
|
+
const map = styleMapOf(node);
|
|
121
|
+
const width = map.get("width") || "auto";
|
|
122
|
+
const height = map.get("height") || "auto";
|
|
123
|
+
|
|
124
|
+
const copy = cloneTree(node);
|
|
125
|
+
|
|
126
|
+
// The page's own declarations move to the page box, and the section keeps
|
|
127
|
+
// only what positions it on the canvas.
|
|
128
|
+
const boxStyle = new Map();
|
|
129
|
+
for (const [property, value] of map) boxStyle.set(property, value);
|
|
130
|
+
boxStyle.set("position", "absolute");
|
|
131
|
+
const position = canvasPositionOf(node);
|
|
132
|
+
if (position) {
|
|
133
|
+
boxStyle.set("left", `${position.x}px`);
|
|
134
|
+
boxStyle.set("top", `${position.y}px`);
|
|
135
|
+
}
|
|
136
|
+
if (boxStyle.get("overflow") === undefined) boxStyle.set("overflow", "hidden");
|
|
137
|
+
|
|
138
|
+
pages.push({
|
|
139
|
+
id,
|
|
140
|
+
name: getAttr(node, "data-name") || id,
|
|
141
|
+
width,
|
|
142
|
+
height,
|
|
143
|
+
position,
|
|
144
|
+
index,
|
|
145
|
+
boxStyle: styleStringOf(boxStyle),
|
|
146
|
+
declarations: map,
|
|
147
|
+
// Built from the COPY, not from `node`: a mount never shares the tree the
|
|
148
|
+
// file was parsed into, so nothing the canvas renders can be mutated back
|
|
149
|
+
// into the document.
|
|
150
|
+
markup: copy.children.map((child) => renderNodeHtml(child, tokens, options)).join(""),
|
|
151
|
+
node: copy,
|
|
152
|
+
});
|
|
153
|
+
index += 1;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
pages,
|
|
158
|
+
tokens,
|
|
159
|
+
diagnostics: doc.diagnostics || [],
|
|
160
|
+
pageFrame: pageFrameRule(doc),
|
|
161
|
+
// Every rule the file states about itself, in document order. A shadow root
|
|
162
|
+
// cannot see the document's stylesheets, so the canvas has to put them in
|
|
163
|
+
// the page's own root — and it must put in exactly these, not a reset of its
|
|
164
|
+
// own invention. A canvas that invents rules renders a document the file
|
|
165
|
+
// does not describe.
|
|
166
|
+
styleText: styleTextOf(doc),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The text of every `<style>` block in the document, in order. */
|
|
171
|
+
export function styleTextOf(doc) {
|
|
172
|
+
return (doc.styleBlocks || []).map((block) => block.text ?? "").filter(Boolean).join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The same CSS with the `:root{…}` rule removed.
|
|
177
|
+
*
|
|
178
|
+
* `:root` matches no element inside a shadow root, so keeping the rule would
|
|
179
|
+
* define the tokens nowhere. The canvas declares them on `:host` instead; see
|
|
180
|
+
* `pageCss`.
|
|
181
|
+
*/
|
|
182
|
+
export function withoutRootRule(cssText) {
|
|
183
|
+
const text = String(cssText ?? "");
|
|
184
|
+
let out = "";
|
|
185
|
+
let index = 0;
|
|
186
|
+
while (index < text.length) {
|
|
187
|
+
const at = text.indexOf(":root", index);
|
|
188
|
+
if (at === -1) {
|
|
189
|
+
out += text.slice(index);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
const open = text.indexOf("{", at);
|
|
193
|
+
if (open === -1) {
|
|
194
|
+
out += text.slice(index);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
let depth = 0;
|
|
198
|
+
let close = -1;
|
|
199
|
+
for (let i = open; i < text.length; i += 1) {
|
|
200
|
+
if (text[i] === "{") depth += 1;
|
|
201
|
+
else if (text[i] === "}") {
|
|
202
|
+
depth -= 1;
|
|
203
|
+
if (depth === 0) { close = i; break; }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (close === -1) {
|
|
207
|
+
out += text.slice(index);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
out += text.slice(index, at);
|
|
211
|
+
index = close + 1;
|
|
212
|
+
}
|
|
213
|
+
return out.replace(/\n{2,}/g, "\n").trim();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The page-frame rule, as the file declares it. */
|
|
217
|
+
export function pageFrameRule(doc) {
|
|
218
|
+
for (const block of doc.styleBlocks || []) {
|
|
219
|
+
if (block.role === "page-frame" || block.role === "tokens+page-frame") {
|
|
220
|
+
const match = /section\s*\[\s*data-type\s*=\s*["']?page["']?\s*\]\s*\{([^}]*)\}/.exec(block.text);
|
|
221
|
+
if (match) return match[1].trim();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return "margin:0 0 24px";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Render one node as HTML for a mount.
|
|
229
|
+
*
|
|
230
|
+
* The element keeps its tag, its `data-*` attributes and its inline style. That
|
|
231
|
+
* is what makes the canvas able to map a clicked element straight back to its
|
|
232
|
+
* `data-id`, and what makes "what you see" the same bytes as "what you export".
|
|
233
|
+
*/
|
|
234
|
+
export function renderNodeHtml(node, tokens, options = {}) {
|
|
235
|
+
if (node.kind === "text") return escapeText(node.value);
|
|
236
|
+
if (node.kind === "comment") return node.raw || "";
|
|
237
|
+
if (node.kind === "doctype") return "";
|
|
238
|
+
if (node.kind !== "element") return "";
|
|
239
|
+
|
|
240
|
+
const attrs = [];
|
|
241
|
+
for (const attr of node.attrs || []) {
|
|
242
|
+
if (attr.name === "style") continue;
|
|
243
|
+
if (attr.value === null || attr.value === undefined) {
|
|
244
|
+
attrs.push(attr.rawName || attr.name);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
attrs.push(`${attr.rawName || attr.name}="${escapeAttr(attr.value)}"`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const styleText = styleForMount(node, tokens, options);
|
|
251
|
+
if (styleText) attrs.push(`style="${escapeAttr(styleText)}"`);
|
|
252
|
+
|
|
253
|
+
const name = node.tag;
|
|
254
|
+
const head = attrs.length ? `<${name} ${attrs.join(" ")}>` : `<${name}>`;
|
|
255
|
+
const body = (node.children || []).map((child) => renderNodeHtml(child, tokens, options)).join("");
|
|
256
|
+
if (VOID_ELEMENTS.has(name)) {
|
|
257
|
+
// A void element cannot hold children. A hand-written file may still put
|
|
258
|
+
// them there, so they are emitted after the void element rather than
|
|
259
|
+
// dropped: a mount must show everything the file contains.
|
|
260
|
+
return `${head}${body}`;
|
|
261
|
+
}
|
|
262
|
+
return `${head}${body}</${name}>`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The inline style a mounted element carries, with tokens resolved or kept. */
|
|
266
|
+
export function styleForMount(node, tokens, options = {}) {
|
|
267
|
+
const map = styleMapOf(node);
|
|
268
|
+
if (map.size === 0) return "";
|
|
269
|
+
if (options.resolveTokens === false) return styleStringOf(map);
|
|
270
|
+
const out = new Map();
|
|
271
|
+
for (const [property, value] of map) out.set(property, resolveTokens(value, tokens));
|
|
272
|
+
return styleStringOf(out);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The full CSS for one page's shadow root.
|
|
277
|
+
*
|
|
278
|
+
* A shadow root is what lets the canvas put a page's tokens and page-frame rule
|
|
279
|
+
* in exactly one scope, without either leaking into the canvas chrome. It is
|
|
280
|
+
* also why the canvas never has to write its own styles into the design file.
|
|
281
|
+
*
|
|
282
|
+
* The rules are the FILE's rules. A canvas that injects a reset of its own makes
|
|
283
|
+
* the file mean one thing on the canvas and another thing in a browser, and the
|
|
284
|
+
* author finds out at export time. So the only rule added here is what a shadow
|
|
285
|
+
* root needs and a document does not: `:host`.
|
|
286
|
+
*/
|
|
287
|
+
export function pageCss(model, options = {}) {
|
|
288
|
+
const parts = [];
|
|
289
|
+
// `all: initial` first, and it is not decoration. An inherited property crosses
|
|
290
|
+
// a shadow boundary: the canvas chrome sets `line-height: 1.5` on the document
|
|
291
|
+
// so its own panels read well, the page's elements inherit it through the host,
|
|
292
|
+
// and every label on the canvas is a pixel taller than the same label in the
|
|
293
|
+
// file. Nothing in the page can override an inheritance it never declared.
|
|
294
|
+
//
|
|
295
|
+
// The two declarations after it are what a mount needs and a document gets for
|
|
296
|
+
// free: a block box, and a positioning context for the overlay.
|
|
297
|
+
parts.push(":host{all:initial;display:block;position:relative}");
|
|
298
|
+
// `:root` matches nothing in a shadow tree, so the token values go on the host
|
|
299
|
+
// element. The rest of the file's CSS is copied as it stands.
|
|
300
|
+
const declarations = [...(model.tokens ?? new Map()).entries()].map(([name, token]) => `${name}:${token.value}`).join(";");
|
|
301
|
+
if (declarations) parts.push(`:host{${declarations}}`);
|
|
302
|
+
const rules = withoutRootRule(model.styleText ?? "");
|
|
303
|
+
if (rules) parts.push(rules);
|
|
304
|
+
else parts.push(`section[data-type="page"]{${model.pageFrame || ""}}`);
|
|
305
|
+
return parts.join("\n");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* What the canvas injects into a page's shadow root: the file's rules, plus the
|
|
310
|
+
* one rule that belongs to the canvas.
|
|
311
|
+
*
|
|
312
|
+
* Where a page sits is the canvas's business. `data-canvas` says where, and the
|
|
313
|
+
* page box is the paper the page is drawn on. A page's outer margin is the
|
|
314
|
+
* document flow's business: it is what separates one page from the next when the
|
|
315
|
+
* file is opened in a browser. On the canvas it is nothing, and left in place it
|
|
316
|
+
* escapes the mount and the paper grows by it — the chrome then paints a 24px
|
|
317
|
+
* band under every page whose own background is not white. No reader of the file
|
|
318
|
+
* ever sees that band and the file does not describe it. So the canvas states
|
|
319
|
+
* the one thing it owns, after the file's rules, and nothing else.
|
|
320
|
+
*/
|
|
321
|
+
export function canvasMountCss(model, options = {}) {
|
|
322
|
+
return [pageCss(model, options), 'section[data-type="page"]{margin:0}'].join("\n");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The radius the canvas draws a page's paper with.
|
|
327
|
+
*
|
|
328
|
+
* The paper is the page's own shape. A page can round its own corners — the
|
|
329
|
+
* artboard can be a 24px-radius card rather than a screen — and the paper sits behind it.
|
|
330
|
+
* The chrome's own radius under a page with a bigger one leaves a white crescent
|
|
331
|
+
* in every corner, a shape the page never drew.
|
|
332
|
+
*
|
|
333
|
+
* The value is resolved here, not left to the page. The paper is drawn outside
|
|
334
|
+
* the shadow root, where the file's tokens are not defined: `var(--radius-lg)`
|
|
335
|
+
* would compute to nothing there and the corner would come back square.
|
|
336
|
+
*/
|
|
337
|
+
export function pagePaperRadius(page, tokens) {
|
|
338
|
+
const declared = page?.declarations?.get?.("border-radius");
|
|
339
|
+
if (!declared) return "";
|
|
340
|
+
return resolveTokens(declared, tokens);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The page box style for a page that sits on the canvas. */
|
|
344
|
+
export function pageBoxStyle(page) {
|
|
345
|
+
const map = new Map();
|
|
346
|
+
for (const declaration of parseDeclarations(page.boxStyle)) map.set(declaration.property, declaration.value);
|
|
347
|
+
return styleStringOf(map);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Where a page sits, with a fallback when the file states no position.
|
|
352
|
+
* The canvas uses this when `data-canvas` is absent, so a fresh file is not a
|
|
353
|
+
* pile of pages at the same spot.
|
|
354
|
+
*/
|
|
355
|
+
export function arrangePages(pages, options = {}) {
|
|
356
|
+
const gap = options.gap ?? 48;
|
|
357
|
+
const perRow = options.perRow ?? 4;
|
|
358
|
+
let nextX = 0;
|
|
359
|
+
let nextY = 0;
|
|
360
|
+
let rowHeight = 0;
|
|
361
|
+
let column = 0;
|
|
362
|
+
return pages.map((page) => {
|
|
363
|
+
if (page.position) return page.position;
|
|
364
|
+
const width = parseFloat(page.width) || 390;
|
|
365
|
+
const height = parseFloat(page.height) || 844;
|
|
366
|
+
if (column >= perRow) {
|
|
367
|
+
column = 0;
|
|
368
|
+
nextX = 0;
|
|
369
|
+
nextY += rowHeight + gap;
|
|
370
|
+
rowHeight = 0;
|
|
371
|
+
}
|
|
372
|
+
const position = { x: nextX, y: nextY };
|
|
373
|
+
nextX += width + gap;
|
|
374
|
+
rowHeight = Math.max(rowHeight, height);
|
|
375
|
+
column += 1;
|
|
376
|
+
return position;
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Font families a page uses that a browser does not already have.
|
|
382
|
+
*
|
|
383
|
+
* A design may name a family the machine does not have installed. Without a
|
|
384
|
+
* stylesheet the browser falls back, and the fallback has different metrics: the
|
|
385
|
+
* same text wraps at a different word and every box below it moves. That is not
|
|
386
|
+
* a cosmetic difference, it is a layout difference, and it is why the reference
|
|
387
|
+
* renders of an imported file disagreed with us while their total heights still
|
|
388
|
+
* matched.
|
|
389
|
+
*
|
|
390
|
+
* The list is what the file declares, resolved through its tokens. Nothing is
|
|
391
|
+
* appended: docs/format-v3.md §2.3 says a font family is written as given.
|
|
392
|
+
*/
|
|
393
|
+
export function webfontFamilies(model) {
|
|
394
|
+
const found = new Map();
|
|
395
|
+
const families = (value) => splitTopLevel(String(value ?? ""), ",")
|
|
396
|
+
.map((part) => decodeQuoteEntities(String(part).trim()).replace(/^["']|["']$/g, "").trim())
|
|
397
|
+
.filter(Boolean);
|
|
398
|
+
|
|
399
|
+
const visit = (node) => {
|
|
400
|
+
if (node.kind === "element") {
|
|
401
|
+
const style = styleMapOf(node).get("font-family");
|
|
402
|
+
if (style) {
|
|
403
|
+
for (const family of families(resolveTokens(style, model.tokens))) {
|
|
404
|
+
if (!SYSTEM_FAMILIES.has(family.toLowerCase())) found.set(family, family);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
for (const child of node.children ?? []) visit(child);
|
|
409
|
+
};
|
|
410
|
+
for (const page of model.pages) visit(page.node);
|
|
411
|
+
return [...found.values()].sort();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* One stylesheet URL per family, asking only for the weights a page uses.
|
|
416
|
+
*
|
|
417
|
+
* A family with no weights is still requested: the browser needs the family
|
|
418
|
+
* before it can pick a weight at all. The canvas asks for the same fonts the
|
|
419
|
+
* export declares, because the two must render the same file at the same text
|
|
420
|
+
* widths — otherwise the author ships a layout they never saw.
|
|
421
|
+
*/
|
|
422
|
+
export function webfontLinks(model, page) {
|
|
423
|
+
const families = webfontFamilies(model);
|
|
424
|
+
if (families.length === 0) return [];
|
|
425
|
+
const weights = new Set();
|
|
426
|
+
const visit = (node) => {
|
|
427
|
+
if (node?.kind === "element") {
|
|
428
|
+
const weight = styleMapOf(node).get("font-weight");
|
|
429
|
+
if (weight && /^\d{3}$/.test(weight)) weights.add(Number(weight));
|
|
430
|
+
}
|
|
431
|
+
for (const child of node?.children ?? []) visit(child);
|
|
432
|
+
};
|
|
433
|
+
// No page means the whole file. The canvas mounts every page at once, so it
|
|
434
|
+
// asks once for the union; asking per page would register the same family and
|
|
435
|
+
// weight from several subset files, and the browser would then have two
|
|
436
|
+
// `@font-face` rules for one face. `export` passes a page, because a
|
|
437
|
+
// deliverable should request only what it renders.
|
|
438
|
+
if (page) visit(page.node);
|
|
439
|
+
else for (const each of model.pages ?? []) visit(each.node);
|
|
440
|
+
const axis = [...weights].sort((a, b) => a - b);
|
|
441
|
+
return families.map((family) => {
|
|
442
|
+
const name = family.replace(/\s+/g, "+");
|
|
443
|
+
const query = axis.length ? `:wght@${axis.join(";")}` : "";
|
|
444
|
+
return `https://fonts.googleapis.com/css2?family=${name}${query}&display=swap`;
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* The families a browser is expected to have without being asked.
|
|
450
|
+
*
|
|
451
|
+
* The test is not "have I heard of it" but "does a stock machine ship it": the
|
|
452
|
+
* generic families, the three faces every desktop has, and the CJK faces the
|
|
453
|
+
* three desktop platforms install for their own scripts. `Inter`, `Roboto`,
|
|
454
|
+
* `Noto Sans` and `Noto Serif` are named by design tools and drawn from a
|
|
455
|
+
* webfont; a machine that has never installed one falls back, and the fallback
|
|
456
|
+
* has different metrics, so the design wraps somewhere else. Leaving them off
|
|
457
|
+
* this list is what makes the canvas and the deliverable ask for them, exactly as
|
|
458
|
+
* pen's own export does — offline the request fails and the fallback is what it
|
|
459
|
+
* always was, so nothing gets worse.
|
|
460
|
+
*/
|
|
461
|
+
const SYSTEM_FAMILIES = new Set([
|
|
462
|
+
"serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-serif",
|
|
463
|
+
"ui-sans-serif", "ui-monospace", "ui-rounded", "math", "emoji", "fangsong",
|
|
464
|
+
"-apple-system", "blinkmacsystemfont", "segoe ui", "helvetica", "helvetica neue",
|
|
465
|
+
"arial", "pingfang sc", "hiragino sans gb",
|
|
466
|
+
"microsoft yahei", "simsun", "simhei", "wenquanyi micro hei", "source han sans sc",
|
|
467
|
+
]);
|
|
468
|
+
|
|
469
|
+
function decodeQuoteEntities(value) {
|
|
470
|
+
return value.replace(/&(?:quot|#0*34|#x0*22);/gi, '"').replace(/&(?:apos|#0*39|#x0*27);/gi, "'");
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Convenience for a caller that only has text. */
|
|
474
|
+
export function renderText(text, options = {}) {
|
|
475
|
+
return renderModel(parse(text), options);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Elements in a page, for the layer tree.
|
|
480
|
+
*
|
|
481
|
+
* The inside of an `<svg>` is not listed. It is the opaque layer: `<path>`,
|
|
482
|
+
* `<circle>` and `<title>` are how one glyph draws itself, not nodes a user can
|
|
483
|
+
* select, and listing them buries the real structure under a hundred rows of
|
|
484
|
+
* geometry. The glyph's own `<svg>` is listed, with its name.
|
|
485
|
+
*/
|
|
486
|
+
export function pageTree(node, depth = 0, out = [], insideSvg = false) {
|
|
487
|
+
for (const child of elementChildren(node)) {
|
|
488
|
+
if (insideSvg) continue;
|
|
489
|
+
const isGlyph = child.tag === "svg";
|
|
490
|
+
out.push({
|
|
491
|
+
id: getAttr(child, "data-id"),
|
|
492
|
+
type: getAttr(child, "data-type"),
|
|
493
|
+
tag: child.tag,
|
|
494
|
+
name: getAttr(child, "data-name"),
|
|
495
|
+
depth,
|
|
496
|
+
hidden: getAttr(child, "data-hidden") === "true",
|
|
497
|
+
childCount: isGlyph ? 0 : elementChildren(child).length,
|
|
498
|
+
text: textOf(child).trim().slice(0, 60),
|
|
499
|
+
});
|
|
500
|
+
pageTree(child, depth + 1, out, isGlyph);
|
|
501
|
+
}
|
|
502
|
+
return out;
|
|
503
|
+
}
|