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
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/export-html.mjs — a design file to a deliverable.
|
|
3
|
+
*
|
|
4
|
+
* A design file is already valid HTML, so "export" is mostly subtraction: take
|
|
5
|
+
* one page out, drop the design layer's bookkeeping, and make the result stand
|
|
6
|
+
* on its own.
|
|
7
|
+
*
|
|
8
|
+
* What the deliverable keeps:
|
|
9
|
+
* - the design's own tags, text and inline styles
|
|
10
|
+
* - the token block, unless `resolveTokens` bakes the values into the styles
|
|
11
|
+
* - the page's size, as a viewport meta so a browser renders it at that size
|
|
12
|
+
*
|
|
13
|
+
* What it drops:
|
|
14
|
+
* - `data-id`, `data-type` and every other `data-*` marker. The design layer
|
|
15
|
+
* is scaffolding for editing, not part of the page.
|
|
16
|
+
* - the page-frame rule, which exists so a file with several pages reads as a
|
|
17
|
+
* list. One page does not need it.
|
|
18
|
+
* - the page's place on the canvas, which is `data-canvas` and, in a file a
|
|
19
|
+
* canvas drag has touched, may also be spelled as `position:absolute` plus
|
|
20
|
+
* `left`/`top`. A standalone page is the whole document, so it has nothing
|
|
21
|
+
* to be positioned against.
|
|
22
|
+
* - `<title>` and `<meta name="design-schema">` are replaced: a deliverable
|
|
23
|
+
* needs the page's name, not the file's schema marker.
|
|
24
|
+
*
|
|
25
|
+
* What the deliverable takes from the page, rather than drops:
|
|
26
|
+
* - the page's own `background-color`, on `<body>`, so the area the page does
|
|
27
|
+
* not cover is not a colour the design never chose. See
|
|
28
|
+
* `documentBackground`.
|
|
29
|
+
*
|
|
30
|
+
* The renderer is shared with the canvas, so what a user dragged is what lands
|
|
31
|
+
* in the file. There is no second implementation to drift.
|
|
32
|
+
*
|
|
33
|
+
* Pure. No node: imports.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { renderModel, escapeText, escapeAttr, webfontLinks, withoutRootRule, resolveTokens as resolveTokenValue } from "./render.mjs";
|
|
37
|
+
import { hasBaseRules } from "./base-css.mjs";
|
|
38
|
+
import { parseDeclarations } from "./parse.mjs";
|
|
39
|
+
import { styleMapOf } from "./render.mjs";
|
|
40
|
+
import { compareProperties, PAGE_PLACEMENT } from "./table.mjs";
|
|
41
|
+
|
|
42
|
+
const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
|
43
|
+
const ATTR_ESCAPES = { "&": "&", '"': """, "<": "<", ">": ">" };
|
|
44
|
+
const TEXT_ESCAPES = { "&": "&", "<": "<", ">": ">" };
|
|
45
|
+
|
|
46
|
+
/** Attributes that belong to the design layer, not to the deliverable. */
|
|
47
|
+
const DESIGN_ATTRS = /^data-(id|type|canvas|name|hidden|locked|kind|icon|icon-library|credit)$/;
|
|
48
|
+
|
|
49
|
+
export class ExportError extends Error {
|
|
50
|
+
constructor(code, message, hint) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "ExportError";
|
|
53
|
+
this.code = code;
|
|
54
|
+
this.hint = hint;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renderPageHtml(model, page, options = {}) {
|
|
59
|
+
const resolveTokens = options.resolveTokens === true;
|
|
60
|
+
const renderOptions = { resolveTokens };
|
|
61
|
+
const body = renderNodeHtml(deliverableRoot(page.node), model.tokens, renderOptions, options);
|
|
62
|
+
|
|
63
|
+
const head = [];
|
|
64
|
+
head.push('<meta charset="utf-8">');
|
|
65
|
+
head.push(`<title>${escapeText(page.name)}</title>`);
|
|
66
|
+
head.push(`<meta name="viewport" content="width=${cssLength(page.width)}, height=${cssLength(page.height)}">`);
|
|
67
|
+
|
|
68
|
+
// A deliverable declares the fonts it uses. Without this the browser falls
|
|
69
|
+
// back to a different family, the text wraps at different words, and the
|
|
70
|
+
// layout below it moves — so a page that looks right on the author's machine
|
|
71
|
+
// looks wrong on the reviewer's. Only the families the page actually uses are
|
|
72
|
+
// requested, and only the weights that appear.
|
|
73
|
+
const fontHrefs = options.fonts === false ? [] : webfontLinks(model, page);
|
|
74
|
+
for (const href of fontHrefs) head.push(`<link rel="stylesheet" href="${escapeAttr(href)}">`);
|
|
75
|
+
|
|
76
|
+
const styles = [];
|
|
77
|
+
if (!resolveTokens) {
|
|
78
|
+
// The tokens live in the file's <head>, not on the page, and every
|
|
79
|
+
// `var(--x)` in the page refers to them. Dropping the block would leave the
|
|
80
|
+
// deliverable with a broken color on every element that used one — and it
|
|
81
|
+
// would look fine in the canvas, which is the worst kind of bug.
|
|
82
|
+
const declarations = tokenDeclarations(model, page.node);
|
|
83
|
+
if (declarations) styles.push(`:root{${declarations}}`);
|
|
84
|
+
}
|
|
85
|
+
styles.push(fileCss(model));
|
|
86
|
+
head.push(`<style>${styles.join("\n")}</style>`);
|
|
87
|
+
|
|
88
|
+
return `<!doctype html>
|
|
89
|
+
<html lang="${escapeAttr(options.lang ?? "zh")}">
|
|
90
|
+
<head>
|
|
91
|
+
${head.map((line) => ` ${line}`).join("\n")}
|
|
92
|
+
</head>
|
|
93
|
+
<body${documentBackground(page, model, resolveTokens)}>
|
|
94
|
+
${body}
|
|
95
|
+
</body>
|
|
96
|
+
</html>
|
|
97
|
+
`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The document's own surface, taken from the page.
|
|
102
|
+
*
|
|
103
|
+
* A deliverable is one page in a document, and the page covers its own box and
|
|
104
|
+
* nothing more. A viewport taller than the page shows whatever is behind it, and
|
|
105
|
+
* left white that is a colour the design never chose: in the reference file 13 of
|
|
106
|
+
* 26 pages are shorter than an 812px screen, the shortest by 339px — 42% of the
|
|
107
|
+
* screen. Pen's own export writes the frame's fill on `<body>` for the same
|
|
108
|
+
* reason, and a `.pen` file gives it nowhere else to read one: the format states
|
|
109
|
+
* no canvas colour.
|
|
110
|
+
*
|
|
111
|
+
* The value is copied, not invented, and only when the page states one. A page
|
|
112
|
+
* whose surface is a gradient or a photograph keeps the plain document: how a
|
|
113
|
+
* gradient covers an area larger than the page is a second decision, and this is
|
|
114
|
+
* not the place to make it.
|
|
115
|
+
*/
|
|
116
|
+
function documentBackground(page, model, bakeTokens) {
|
|
117
|
+
const declared = styleMapOf(page.node).get("background-color");
|
|
118
|
+
if (!declared) return "";
|
|
119
|
+
const value = bakeTokens ? resolveTokenValue(declared, model.tokens) : declared;
|
|
120
|
+
if (!value || value === "transparent") return "";
|
|
121
|
+
return ` style="background-color:${escapeAttr(value)}"`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The page as the deliverable sees it: without the canvas's placement.
|
|
126
|
+
*
|
|
127
|
+
* `data-canvas` is where a page sits, and the canvas positions it from there. A
|
|
128
|
+
* canvas drag also used to write that placement as `position:absolute` plus
|
|
129
|
+
* `left`/`top` on the page itself, which the canvas ignores and the deliverable
|
|
130
|
+
* does not: the page shipped absolutely positioned at (-85,-352), content
|
|
131
|
+
* pushed off the top-left corner of its own document. A standalone page is the
|
|
132
|
+
* whole document, so it has nothing to be positioned against.
|
|
133
|
+
*/
|
|
134
|
+
function deliverableRoot(node) {
|
|
135
|
+
const raw = (node.attrs ?? []).find((a) => a.name === "style")?.value ?? "";
|
|
136
|
+
const declarations = parseDeclarations(raw);
|
|
137
|
+
// A page cannot carry a rotation (`style/transform-on-page`), but export does
|
|
138
|
+
// not validate the file it is handed, so the declaration is dropped here as
|
|
139
|
+
// well: the deliverable is the page's own box, and a transform on it would
|
|
140
|
+
// ship an artboard turned inside a viewport that does not turn with it.
|
|
141
|
+
const canvasChrome = new Set([...PAGE_PLACEMENT, "transform"]);
|
|
142
|
+
if (!declarations.some((d) => canvasChrome.has(d.property))) return node;
|
|
143
|
+
const kept = declarations.filter((d) => !canvasChrome.has(d.property));
|
|
144
|
+
const style = kept.map((d) => `${d.property}:${d.value}`).join(";");
|
|
145
|
+
return {
|
|
146
|
+
...node,
|
|
147
|
+
attrs: (node.attrs ?? []).map((a) => (a.name === "style" ? { ...a, value: style } : a)),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The `:root` declarations for a deliverable: the file's token block, plus any
|
|
153
|
+
* custom property declared on the page itself. The ones the page mentions win,
|
|
154
|
+
* because a page may override a token for its own subtree.
|
|
155
|
+
*/
|
|
156
|
+
function tokenDeclarations(model, pageNode) {
|
|
157
|
+
const found = new Map();
|
|
158
|
+
for (const [name, token] of model.tokens ?? new Map()) found.set(name, token.value);
|
|
159
|
+
const styleAttr = (pageNode.attrs ?? []).find((a) => a.name === "style");
|
|
160
|
+
if (styleAttr && /--/.test(styleAttr.value ?? "")) {
|
|
161
|
+
for (const declaration of parseDeclarations(styleAttr.value)) {
|
|
162
|
+
if (declaration.property.startsWith("--")) found.set(declaration.property, declaration.value);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return [...found.entries()].map(([name, value]) => `${name}:${value}`).join(";");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The rules the deliverable carries.
|
|
170
|
+
*
|
|
171
|
+
* These are the FILE's rules, copied. A deliverable is evidence about its
|
|
172
|
+
* source, and a deliverable that quietly adds a reset its source does not have
|
|
173
|
+
* proves the wrong thing: it looks right while the file it came from does not.
|
|
174
|
+
*
|
|
175
|
+
* The fallback is for a file written before the base block existed. A deliverable
|
|
176
|
+
* with no box model at all is a broken page, so one is added — and `validate`
|
|
177
|
+
* reports `style/no-base-rules` on the source, which is where the fix belongs.
|
|
178
|
+
*/
|
|
179
|
+
function fileCss(model) {
|
|
180
|
+
const rules = withoutRootRule(model.styleText ?? "");
|
|
181
|
+
if (hasBaseRules(rules)) return rules;
|
|
182
|
+
return resetCss();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function resetCss() {
|
|
186
|
+
return [
|
|
187
|
+
"*,*::before,*::after{box-sizing:border-box}",
|
|
188
|
+
"html,body{margin:0;padding:0}",
|
|
189
|
+
"p,h1,h2,h3,h4,h5,h6{margin:0}",
|
|
190
|
+
"img{display:block;max-width:100%}",
|
|
191
|
+
// An icon's geometry is stroke-based and carries no fill of its own: the 67
|
|
192
|
+
// built-in glyphs are paths and circles with no `fill` and no `stroke`, so
|
|
193
|
+
// the SVG defaults apply and the browser paints every icon as a solid black
|
|
194
|
+
// shape. These attributes belong on the icon's own `<svg>`, and pen's export
|
|
195
|
+
// writes them there; they are also what makes `color` on the element tint
|
|
196
|
+
// the icon.
|
|
197
|
+
//
|
|
198
|
+
// The selector matches an icon and nothing else: a built-in glyph is always
|
|
199
|
+
// `<svg><path|circle>`, with no attribute of its own and no text. A caller
|
|
200
|
+
// can opt out with `svg[data-fill]`.
|
|
201
|
+
"svg:not([data-fill]) > :not([fill]):not([stroke]){fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}",
|
|
202
|
+
].join("");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function cssLength(value) {
|
|
206
|
+
const text = String(value ?? "auto");
|
|
207
|
+
const match = /^(-?\d+(?:\.\d+)?)(px)?$/.exec(text.trim());
|
|
208
|
+
if (!match) return text.trim();
|
|
209
|
+
return `${match[1]}`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Render one node for a deliverable.
|
|
214
|
+
*
|
|
215
|
+
* This is the renderer's node writer with the design attributes removed. It is a
|
|
216
|
+
* second walk rather than a post-processing regular expression, because a
|
|
217
|
+
* regular expression over HTML is how a `data-id` ends up inside a text node and
|
|
218
|
+
* a document gets corrupted.
|
|
219
|
+
*/
|
|
220
|
+
export function renderNodeHtml(node, tokens, renderOptions = {}, options = {}) {
|
|
221
|
+
if (node.kind === "text") return escapeText(node.value);
|
|
222
|
+
if (node.kind === "comment") return options.keepComments ? (node.raw ?? "") : "";
|
|
223
|
+
if (node.kind !== "element") return "";
|
|
224
|
+
|
|
225
|
+
const attrs = [];
|
|
226
|
+
for (const attr of node.attrs ?? []) {
|
|
227
|
+
if (attr.name === "style") continue;
|
|
228
|
+
if (DESIGN_ATTRS.test(attr.name)) continue;
|
|
229
|
+
if (attr.value === null || attr.value === undefined) {
|
|
230
|
+
attrs.push(attr.rawName || attr.name);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
attrs.push(`${attr.rawName || attr.name}="${escapeAttr(attr.value)}"`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const styleText = serializeStyle(node, tokens, renderOptions);
|
|
237
|
+
if (styleText) attrs.push(`style="${escapeAttr(styleText)}"`);
|
|
238
|
+
|
|
239
|
+
const head = attrs.length ? `<${node.tag} ${attrs.join(" ")}>` : `<${node.tag}>`;
|
|
240
|
+
const body = (node.children ?? []).map((child) => renderNodeHtml(child, tokens, renderOptions, options)).join("");
|
|
241
|
+
if (VOID.has(node.tag)) return `${head}${body}`;
|
|
242
|
+
return `${head}${body}</${node.tag}>`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A node's declarations, in table order, with tokens resolved or kept. */
|
|
246
|
+
export function serializeStyle(node, tokens, renderOptions = {}) {
|
|
247
|
+
const styleAttr = (node.attrs ?? []).find((a) => a.name === "style");
|
|
248
|
+
if (!styleAttr || !styleAttr.value) return "";
|
|
249
|
+
const declarations = parseDeclarations(styleAttr.value);
|
|
250
|
+
if (declarations.length === 0) return "";
|
|
251
|
+
const kept = declarations
|
|
252
|
+
.filter((d) => d.property !== "" && d.value !== "")
|
|
253
|
+
.map((d, index) => ({
|
|
254
|
+
property: d.property,
|
|
255
|
+
value: renderOptions.resolveTokens === false ? d.value : resolveTokenValue(d.value, tokens),
|
|
256
|
+
index,
|
|
257
|
+
}))
|
|
258
|
+
.sort((a, b) => compareProperties(a.property, b.property) || a.index - b.index);
|
|
259
|
+
return kept.map(({ property, value }) => `${property}:${value}`).join(";");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* One page of a design file, as a standalone document.
|
|
264
|
+
*
|
|
265
|
+
* @param {object} doc parsed design file
|
|
266
|
+
* @param {object} options `{ page, resolveTokens, keepComments, lang }`
|
|
267
|
+
*/
|
|
268
|
+
export function exportPage(doc, options = {}) {
|
|
269
|
+
const model = renderModel(doc, { resolveTokens: options.resolveTokens === true });
|
|
270
|
+
const pages = model.pages;
|
|
271
|
+
if (pages.length === 0) {
|
|
272
|
+
throw new ExportError("export/no-page", "The file has no page to export.", 'Add <section data-id="home" data-type="page">.');
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let page;
|
|
276
|
+
if (options.page) {
|
|
277
|
+
page = pages.find((p) => p.id === options.page);
|
|
278
|
+
if (!page) {
|
|
279
|
+
throw new ExportError(
|
|
280
|
+
"export/unknown-page",
|
|
281
|
+
`There is no page with data-id ${JSON.stringify(options.page)}.`,
|
|
282
|
+
`Pages: ${pages.map((p) => p.id).join(", ")}.`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
page = pages[0];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { html: renderPageHtml(model, page, options), page, model };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Every page, each written to a file named after its `data-id`. */
|
|
293
|
+
export function exportAllPages(doc, options = {}) {
|
|
294
|
+
const model = renderModel(doc, { resolveTokens: options.resolveTokens === true });
|
|
295
|
+
const wanted = model.pages;
|
|
296
|
+
if (wanted.length === 0) {
|
|
297
|
+
throw new ExportError("export/no-page", "The file has no page to export.", null);
|
|
298
|
+
}
|
|
299
|
+
return wanted.map((page) => ({
|
|
300
|
+
page,
|
|
301
|
+
file: `${page.id}.html`,
|
|
302
|
+
html: renderPageHtml(model, page, options),
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** True when the request asks for something only a browser can produce. */
|
|
307
|
+
export function needsBrowser(format) {
|
|
308
|
+
return ["png", "jpeg", "jpg", "webp"].includes(String(format).toLowerCase());
|
|
309
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/export-svg.mjs — a page as SVG, and as an image.
|
|
3
|
+
*
|
|
4
|
+
* ## SVG
|
|
5
|
+
*
|
|
6
|
+
* A page becomes one `<svg>` wrapping the HTML in `<foreignObject>`. This needs
|
|
7
|
+
* no browser, which makes it the only image-ish format that works on a machine
|
|
8
|
+
* with nothing installed.
|
|
9
|
+
*
|
|
10
|
+
* Be honest about what can open it: a browser renders `<foreignObject>` and
|
|
11
|
+
* Figma, Illustrator and Sketch show a blank rectangle. That is a property of
|
|
12
|
+
* the format, not a bug to work around, so the export says so.
|
|
13
|
+
*
|
|
14
|
+
* ## Raster
|
|
15
|
+
*
|
|
16
|
+
* PNG, JPEG and WebP need a browser, and `lib/node/raster-run.mjs` finds one.
|
|
17
|
+
* When none can be found the export fails and names three ways forward. It does
|
|
18
|
+
* NOT quietly write an SVG instead: a different format is a different
|
|
19
|
+
* deliverable, and nobody asked for it.
|
|
20
|
+
*
|
|
21
|
+
* Browser-safe for the SVG half. The raster half is in `lib/node/`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { escapeAttr, escapeText } from "./render.mjs";
|
|
25
|
+
|
|
26
|
+
export class ExportFormatError extends Error {
|
|
27
|
+
constructor(code, message, hint) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "ExportFormatError";
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.hint = hint;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const IMAGE_FORMATS = new Set(["svg", "png", "jpeg", "jpg", "webp"]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One page as an SVG document.
|
|
39
|
+
*
|
|
40
|
+
* @param {object} options `{ html, page, title }`
|
|
41
|
+
*/
|
|
42
|
+
export function pageToSvg(options) {
|
|
43
|
+
const html = String(options.html ?? "");
|
|
44
|
+
const page = options.page ?? {};
|
|
45
|
+
const width = numberOr(page.width, 390);
|
|
46
|
+
const height = numberOr(page.height, 844);
|
|
47
|
+
const title = options.title ?? page.name ?? "design";
|
|
48
|
+
|
|
49
|
+
// The body's own markup goes inside the foreignObject. A full document cannot
|
|
50
|
+
// nest inside an SVG, so the parts that carry meaning are lifted: the styles,
|
|
51
|
+
// the font links, and the body.
|
|
52
|
+
const styles = [...html.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join("\n");
|
|
53
|
+
const links = [...html.matchAll(/<link[^>]*>/g)].map((m) => m[0]).join("\n");
|
|
54
|
+
const body = /<body[^>]*>([\s\S]*?)<\/body>/.exec(html)?.[1] ?? "";
|
|
55
|
+
|
|
56
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
57
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xhtml="http://www.w3.org/1999/xhtml" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
58
|
+
<title>${escapeText(title)}</title>
|
|
59
|
+
<desc>Rendered from a justSkill design file. A browser draws this; Figma, Illustrator and Sketch show an empty rectangle because they do not implement foreignObject.</desc>
|
|
60
|
+
${links ? ` ${links}\n` : ""} <style>${styles}
|
|
61
|
+
.sd-page{width:${width}px;height:${height}px;overflow:hidden;background:#ffffff}
|
|
62
|
+
</style>
|
|
63
|
+
<foreignObject x="0" y="0" width="${width}" height="${height}">
|
|
64
|
+
<xhtml:div class="sd-page" xmlns="http://www.w3.org/1999/xhtml">${body}</xhtml:div>
|
|
65
|
+
</foreignObject>
|
|
66
|
+
</svg>
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function numberOr(value, fallback) {
|
|
71
|
+
const parsed = parseFloat(String(value ?? ""));
|
|
72
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A filename-safe extension for a format name. */
|
|
76
|
+
export function extensionFor(format) {
|
|
77
|
+
const name = String(format).toLowerCase();
|
|
78
|
+
return name === "jpg" ? "jpeg" : name;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The pixel size one page renders at.
|
|
83
|
+
*
|
|
84
|
+
* `--size` wins, then the page's own width and height, then the default. It lives here,
|
|
85
|
+
* in the pure half, because it is a policy about the document and not about files: the
|
|
86
|
+
* node half that writes the image should not be where the shape of a page is decided.
|
|
87
|
+
*/
|
|
88
|
+
export function renderSize(page, sizeOption) {
|
|
89
|
+
const size = parseSize(sizeOption);
|
|
90
|
+
return {
|
|
91
|
+
width: size?.width ?? numberOr(page?.width, 390),
|
|
92
|
+
height: size?.height ?? numberOr(page?.height, 844),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** `--size WxH`, or null when it is absent or unreadable. */
|
|
97
|
+
export function parseSize(value) {
|
|
98
|
+
if (value === undefined || value === null || value === "") return null;
|
|
99
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*[x×]\s*(\d+(?:\.\d+)?)\s*$/i.exec(String(value));
|
|
100
|
+
if (!match) {
|
|
101
|
+
throw new ExportFormatError("export/bad-size", `--size wants WxH, and got ${JSON.stringify(value)}.`, 'Such as --size 390x844.');
|
|
102
|
+
}
|
|
103
|
+
return { width: Number(match[1]), height: Number(match[2]) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A scale the browser accepts, so a typo cannot produce a 100× image. */
|
|
107
|
+
export function clampScale(value) {
|
|
108
|
+
const parsed = Number(value);
|
|
109
|
+
if (!Number.isFinite(parsed)) return 1;
|
|
110
|
+
return Math.min(3, Math.max(1, Math.round(parsed)));
|
|
111
|
+
}
|