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,1684 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/readers/pen.mjs — a `.pen` file to a design file.
|
|
3
|
+
*
|
|
4
|
+
* One direction only, and honestly so. Reading is a mapping exercise: pen's
|
|
5
|
+
* model is close enough to HTML with inline CSS that most of it has a direct
|
|
6
|
+
* spelling. Writing back is a different product, so there is no writer, and no
|
|
7
|
+
* pretence of one.
|
|
8
|
+
*
|
|
9
|
+
* Every loss is counted. `importPen` returns a ledger with three columns:
|
|
10
|
+
*
|
|
11
|
+
* - `lost` something in the file has no spelling here and is gone
|
|
12
|
+
* - `converted` a value was rewritten to the one spelling this format allows
|
|
13
|
+
* - `unmapped` a property this reader does not know at all, by name
|
|
14
|
+
*
|
|
15
|
+
* The ledger is the point. A silent import is how a user loses a gradient and
|
|
16
|
+
* finds out on the review screen.
|
|
17
|
+
*
|
|
18
|
+
* ## What maps to what
|
|
19
|
+
*
|
|
20
|
+
* Pen has no CSS, so the mapping is by intent, not by name:
|
|
21
|
+
*
|
|
22
|
+
* - a top-level frame becomes a page (`<section data-type="page">`)
|
|
23
|
+
* - `layout: "vertical"` becomes `display:flex;flex-direction:column`
|
|
24
|
+
* - an absent `layout` becomes `display:flex;flex-direction:row`
|
|
25
|
+
* - an absent `alignItems` becomes `align-items:flex-start`, on a row and on a
|
|
26
|
+
* column alike. Pen's own exporter writes `flex-start` for both, and
|
|
27
|
+
* centring a row moved every nanny card's text 2px down against its photo
|
|
28
|
+
* - `justifyContent`/`alignItems` become the CSS properties, with
|
|
29
|
+
* `space_between` becoming `space-between`
|
|
30
|
+
* - `fill` is `background-color`, except on text and icons, where it is `color`
|
|
31
|
+
* - an image fill's `mode` is `background-size`: `cover` and `contain` carry
|
|
32
|
+
* over as themselves, `fill` stretches and is `100% 100%`. Pen's own export
|
|
33
|
+
* writes both spellings. A mode with no CSS word for it is counted as
|
|
34
|
+
* unmapped instead of quietly becoming `cover`, which crops what the design
|
|
35
|
+
* stretched
|
|
36
|
+
* - `cornerRadius: 9999` stays a pixel radius, because `50%` on a 375x48 bar
|
|
37
|
+
* draws an ellipse and pen means a pill
|
|
38
|
+
* - `ellipse` becomes `data-kind="ellipse"` plus `border-radius:50%`
|
|
39
|
+
* - `clip` becomes `overflow:hidden`
|
|
40
|
+
* - `textGrowth: "fixed-width"` becomes `white-space:normal` on a fixed width
|
|
41
|
+
* - a `ref` is inlined: the node it names is copied in, the copy's root keeps
|
|
42
|
+
* the reference's own id and its descendants are renumbered under it, because
|
|
43
|
+
* this format stores no reference and an id is unique across the file. The
|
|
44
|
+
* copy is counted in `refs.inlined`
|
|
45
|
+
* - `descendants` patches are applied to that copy, field by field. An `icon`
|
|
46
|
+
* field swaps the glyph the copy draws, because a glyph is geometry here and
|
|
47
|
+
* a name is only its label
|
|
48
|
+
* - `variables` become the token block, and `$name` references become `var()`
|
|
49
|
+
* - `x`/`y` become `data-canvas`
|
|
50
|
+
*
|
|
51
|
+
* ## What has no spelling, and is counted as lost
|
|
52
|
+
*
|
|
53
|
+
* Non-linear gradients, mesh gradients, more than one fill, inner shadows,
|
|
54
|
+
* slots, themes, prototype links, and any property not in the list above. Each
|
|
55
|
+
* one is named in the ledger rather than dropped in the dark.
|
|
56
|
+
*
|
|
57
|
+
* Pure. No node: imports, so the canvas could read a `.pen` file too.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { parse, prepareBuiltDocument } from "../parse.mjs";
|
|
61
|
+
import { ICONS, ICON_LIBRARY, ICON_VIEW_BOX, hasIcon, iconBody } from "../icons.mjs";
|
|
62
|
+
import { pathBounds, rotatePath } from "../svg-path.mjs";
|
|
63
|
+
import { serializeCanonical } from "../serialize.mjs";
|
|
64
|
+
import { ensureGenericFamily, normalizeColor, normalizeValue, normalizeStyleText, round3, TOKEN_GROUP_UNIT, TOKEN_GROUPS } from "../table.mjs";
|
|
65
|
+
import { baseCss } from "../base-css.mjs";
|
|
66
|
+
|
|
67
|
+
export const PEN_VERSION_SUPPORTED = ["2.6", "2.19"];
|
|
68
|
+
|
|
69
|
+
const ICONS_COUNT = Object.keys(ICONS).length;
|
|
70
|
+
|
|
71
|
+
// -- json with comments -------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pen allows `//` and `/* *\/` comments in its JSON.
|
|
75
|
+
* A naive strip would eat a `//` inside a string, such as a URL.
|
|
76
|
+
*/
|
|
77
|
+
export function stripJsonComments(source) {
|
|
78
|
+
const text = String(source);
|
|
79
|
+
let out = "";
|
|
80
|
+
let inString = false;
|
|
81
|
+
let quote = null;
|
|
82
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
83
|
+
const ch = text[i];
|
|
84
|
+
if (inString) {
|
|
85
|
+
out += ch;
|
|
86
|
+
if (ch === "\\") { out += text[i + 1] ?? ""; i += 1; continue; }
|
|
87
|
+
if (ch === quote) inString = false;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (ch === '"' || ch === "'") { inString = true; quote = ch; out += ch; continue; }
|
|
91
|
+
if (ch === "/" && text[i + 1] === "/") {
|
|
92
|
+
while (i < text.length && text[i] !== "\n") i += 1;
|
|
93
|
+
out += "\n";
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ch === "/" && text[i + 1] === "*") {
|
|
97
|
+
const end = text.indexOf("*/", i + 2);
|
|
98
|
+
i = end === -1 ? text.length : end + 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
out += ch;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A refusal a caller can branch on, in the shape the CLI reports by name. */
|
|
107
|
+
export function penError(code, message) {
|
|
108
|
+
const failure = new Error(message);
|
|
109
|
+
failure.code = code;
|
|
110
|
+
return failure;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function parsePen(text) {
|
|
114
|
+
let json;
|
|
115
|
+
try {
|
|
116
|
+
json = JSON.parse(stripJsonComments(text));
|
|
117
|
+
} catch (error) {
|
|
118
|
+
throw penError("pen/invalid-json", `The .pen file is not JSON: ${error.message}`);
|
|
119
|
+
}
|
|
120
|
+
if (!json || typeof json !== "object" || !Array.isArray(json.children)) {
|
|
121
|
+
throw penError("pen/no-pages", "The .pen file has no children array, so it holds no artboards.");
|
|
122
|
+
}
|
|
123
|
+
return json;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// -- the ledger ---------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
export function createLedger() {
|
|
129
|
+
return {
|
|
130
|
+
version: null,
|
|
131
|
+
pages: 0,
|
|
132
|
+
sourceNodes: 0,
|
|
133
|
+
nodes: 0,
|
|
134
|
+
lost: {},
|
|
135
|
+
converted: {},
|
|
136
|
+
unmapped: {},
|
|
137
|
+
refs: { found: 0, inlined: 0, overridesApplied: 0, overrideFieldsDropped: 0 },
|
|
138
|
+
notes: [],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function count(target, key, by = 1) {
|
|
143
|
+
target[key] = (target[key] ?? 0) + by;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// -- escaping and ids ---------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
const TEXT_ESCAPES = { "&": "&", "<": "<", ">": ">" };
|
|
149
|
+
const ATTR_ESCAPES = { "&": "&", '"': """, "<": "<", ">": ">" };
|
|
150
|
+
|
|
151
|
+
const escapeText = (value) => String(value ?? "").replace(/[&<>]/g, (ch) => TEXT_ESCAPES[ch]);
|
|
152
|
+
const escapeAttr = (value) => String(value ?? "").replace(/[&"<>]/g, (ch) => ATTR_ESCAPES[ch]);
|
|
153
|
+
|
|
154
|
+
/** Pen does not constrain ids. This format does, so a bad one is rewritten. */
|
|
155
|
+
export const ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Pen ids to ids this format accepts.
|
|
159
|
+
*
|
|
160
|
+
* The result maps a NODE to its new id, not an id string to a new id string: a
|
|
161
|
+
* pen file may carry the same id twice, and a string-keyed map would silently
|
|
162
|
+
* keep only the last one, leaving the first node unimportable.
|
|
163
|
+
*
|
|
164
|
+
* @returns {Map<object, string>}
|
|
165
|
+
*/
|
|
166
|
+
export function idMapFor(json) {
|
|
167
|
+
const used = new Set();
|
|
168
|
+
const mapping = new Map();
|
|
169
|
+
const visit = (node) => {
|
|
170
|
+
const original = typeof node.id === "string" ? node.id : null;
|
|
171
|
+
let candidate = original
|
|
172
|
+
? (ID_PATTERN.test(original) ? original : sanitiseId(original))
|
|
173
|
+
: `n-${mapping.size + 1}`;
|
|
174
|
+
if (used.has(candidate)) {
|
|
175
|
+
let n = 2;
|
|
176
|
+
while (used.has(`${candidate}-${n}`)) n += 1;
|
|
177
|
+
candidate = `${candidate}-${n}`;
|
|
178
|
+
count(temporaryLedger.converted, "duplicateIds");
|
|
179
|
+
}
|
|
180
|
+
used.add(candidate);
|
|
181
|
+
mapping.set(node, candidate);
|
|
182
|
+
for (const child of node.children ?? []) visit(child);
|
|
183
|
+
};
|
|
184
|
+
for (const artboard of json.children) visit(artboard);
|
|
185
|
+
return mapping;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The node each source id names, for a file with unique ids.
|
|
190
|
+
*
|
|
191
|
+
* This is what inlining a `ref` reads: the reference stores the NAME of the node
|
|
192
|
+
* it points at, and the copy has to be made from that node's own subtree.
|
|
193
|
+
*/
|
|
194
|
+
export function idLookup(json) {
|
|
195
|
+
const byNode = idMapFor(json);
|
|
196
|
+
const byIdNode = new Map();
|
|
197
|
+
const visit = (node) => {
|
|
198
|
+
if (typeof node.id === "string" && !byIdNode.has(node.id)) byIdNode.set(node.id, node);
|
|
199
|
+
for (const child of node.children ?? []) visit(child);
|
|
200
|
+
};
|
|
201
|
+
for (const artboard of json.children) visit(artboard);
|
|
202
|
+
return { byNode, byIdNode };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sanitiseId(raw) {
|
|
206
|
+
const cleaned = String(raw).replace(/[^A-Za-z0-9_-]/g, "-").replace(/^[^A-Za-z_]+/, "");
|
|
207
|
+
return cleaned.slice(0, 64) || `n-${Math.abs(hash(String(raw))).toString(36)}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function hash(text) {
|
|
211
|
+
let h = 0;
|
|
212
|
+
for (let i = 0; i < text.length; i += 1) h = ((h << 5) - h + text.charCodeAt(i)) | 0;
|
|
213
|
+
return h;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// -- variables ----------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Pen variables become the token block. The group comes from the name's own
|
|
220
|
+
* prefix when it is one of the seven, so `$space-md` lands in `--space-md` and
|
|
221
|
+
* `$surface-container` lands in `--color-surface-container`.
|
|
222
|
+
*
|
|
223
|
+
* A variable that is themeable carries a LIST of values rather than one, and the
|
|
224
|
+
* list is not a value. Reading it as one used to write the string
|
|
225
|
+
* "[object Object],[object Object]" into the token block — a file that validated
|
|
226
|
+
* and whose tokens meant nothing. The entry with no `theme` of its own is the one
|
|
227
|
+
* every theme starts from and the one pen's own export inlines, so that entry is
|
|
228
|
+
* the value; a variable with nothing readable in it is left out and named, which
|
|
229
|
+
* leaves its references visible to the validator instead of silently wrong.
|
|
230
|
+
*/
|
|
231
|
+
export function tokensFromVariables(variables, ledger) {
|
|
232
|
+
const tokens = new Map();
|
|
233
|
+
const refs = new Map();
|
|
234
|
+
for (const [name, definition] of Object.entries(variables ?? {})) {
|
|
235
|
+
const raw = definition && typeof definition === "object" ? definition.value : definition;
|
|
236
|
+
const value = Array.isArray(raw) ? defaultThemeValue(raw, ledger) : raw;
|
|
237
|
+
const type = definition && typeof definition === "object" ? definition.type : null;
|
|
238
|
+
if (value !== null && typeof value === "object") {
|
|
239
|
+
count(ledger.lost, "unresolvedVariable");
|
|
240
|
+
ledger.notes.push(`variable ${name} carries ${Array.isArray(value) ? "a list of values" : "an object"} where this format wants one value; it was left out of the token block, and its references will not resolve`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const cssName = tokenNameFor(name, type);
|
|
244
|
+
let cssValue;
|
|
245
|
+
if (type === "number") {
|
|
246
|
+
const unit = TOKEN_GROUP_UNIT[cssName.slice(2).split("-")[0]] ?? "px";
|
|
247
|
+
cssValue = `${value}${unit}`;
|
|
248
|
+
count(ledger.converted, "numericTokens");
|
|
249
|
+
} else if (type === "string") {
|
|
250
|
+
// A `--font-*` token holds a family list, and pen's own export writes
|
|
251
|
+
// `system-ui, sans-serif` after every list it emits. A list that ends in a
|
|
252
|
+
// webfont ends in something the machine may not have, so the fallback
|
|
253
|
+
// becomes the DOCUMENT default — a serif face on most engines, and a
|
|
254
|
+
// different layout with it. The fallback goes on the token, because the
|
|
255
|
+
// elements reference the token and never spell a family themselves.
|
|
256
|
+
const quoted = `"${String(value)}"`;
|
|
257
|
+
cssValue = cssName.startsWith("--font-") ? ensureGenericFamily(quoted) : quoted;
|
|
258
|
+
count(ledger.converted, "stringTokens");
|
|
259
|
+
} else {
|
|
260
|
+
try {
|
|
261
|
+
cssValue = normalizeColor(String(value));
|
|
262
|
+
} catch {
|
|
263
|
+
cssValue = String(value);
|
|
264
|
+
count(ledger.converted, "unparsedColors");
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
tokens.set(cssName, { name: cssName, value: cssValue, type: type ?? "color" });
|
|
268
|
+
refs.set(name, cssName);
|
|
269
|
+
if (cssName !== `--${name}`) count(ledger.converted, "renamedTokens");
|
|
270
|
+
}
|
|
271
|
+
return { tokens, refs };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** The entry a themed variable carries for the document's default theme. */
|
|
275
|
+
function defaultThemeValue(entries, ledger) {
|
|
276
|
+
count(ledger.converted, "themedVariables");
|
|
277
|
+
const plain = entries.find((entry) => entry && typeof entry === "object" && entry.theme === undefined);
|
|
278
|
+
return plain ? plain.value : entries[0]?.value;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function tokenNameFor(name, type) {
|
|
282
|
+
const raw = String(name);
|
|
283
|
+
const lower = raw.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
284
|
+
const group = lower.split("-")[0];
|
|
285
|
+
if (TOKEN_GROUPS.includes(group)) return `--${lower}`;
|
|
286
|
+
const fallback = type === "number" ? "space" : type === "string" ? "font" : "color";
|
|
287
|
+
count(temporaryLedger, "tokensWithoutGroup");
|
|
288
|
+
return `--${fallback}-${lower}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// The token group fallback needs the ledger, and a module-level handle keeps
|
|
292
|
+
// `tokenNameFor` a pure function of its own name. It is set during an import.
|
|
293
|
+
let temporaryLedger = createLedger();
|
|
294
|
+
|
|
295
|
+
/** `$name` becomes `var(--name)`, or the value itself when it is not a token. */
|
|
296
|
+
export function resolveReference(value, refs, ledger) {
|
|
297
|
+
if (typeof value !== "string" || !value.startsWith("$")) return value;
|
|
298
|
+
const name = value.slice(1);
|
|
299
|
+
const cssName = refs.get(name) ?? `--${name}`;
|
|
300
|
+
if (!refs.has(name)) count(ledger.unmapped, "unresolvedVariableReferences");
|
|
301
|
+
return `var(${cssName})`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// -- style mapping ------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
const JUSTIFY = { space_between: "space-between", space_around: "space-around", space_evenly: "space-around", start: "flex-start", end: "flex-end", center: "center" };
|
|
307
|
+
const ALIGN = { start: "flex-start", end: "flex-end", center: "center", stretch: "stretch", baseline: "baseline" };
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* An image fill's `mode`, as CSS `background-size`.
|
|
311
|
+
*
|
|
312
|
+
* Pen states how the image meets its box. `cover` and `contain` carry over as
|
|
313
|
+
* the same keywords; `fill` stretches the image to the box, which CSS writes as
|
|
314
|
+
* `100% 100%`. A mode that is not here is not guessed at — the ledger reports it
|
|
315
|
+
* as unmapped, because a crop where the design stretches is exactly the kind of
|
|
316
|
+
* difference nobody looks for.
|
|
317
|
+
*/
|
|
318
|
+
const IMAGE_FILL_SIZE = { cover: "cover", contain: "contain", fill: "100% 100%" };
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Pen node to an inline `style` value.
|
|
322
|
+
*
|
|
323
|
+
* The order of the checks is the order of precedence: a text node's `fill` is
|
|
324
|
+
* a colour for the glyphs, not a background.
|
|
325
|
+
*/
|
|
326
|
+
export function styleFromNode(node, refs, ledger, context = {}) {
|
|
327
|
+
const style = new Map();
|
|
328
|
+
const set = (property, value) => {
|
|
329
|
+
if (value === undefined || value === null || value === "") return;
|
|
330
|
+
style.set(property, value);
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// A child of a `layout:none` frame is placed by its own x and y, and by nothing else.
|
|
334
|
+
//
|
|
335
|
+
// This is the other half of the `relative` set below: with neither, the child is a
|
|
336
|
+
// block box in the parent's flow, so two children that overlap in pen (a battery body
|
|
337
|
+
// and the fill inside it) stack in the file. `left`/`top` are the properties the table
|
|
338
|
+
// accepts for this, and `position` before `width` is the emit order.
|
|
339
|
+
if (context.parentLayoutNone === true && Number.isFinite(node.x) && Number.isFinite(node.y)) {
|
|
340
|
+
set("position", "absolute");
|
|
341
|
+
set("left", `${round3(node.x)}px`);
|
|
342
|
+
set("top", `${round3(node.y)}px`);
|
|
343
|
+
count(ledger.converted, "absoluteChild");
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Three cases, and the difference between the last two matters:
|
|
347
|
+
//
|
|
348
|
+
// "vertical" a column
|
|
349
|
+
// "horizontal" a row, which is also pen's DEFAULT when `layout` is absent
|
|
350
|
+
// "none" no flow at all; the children are positioned absolutely
|
|
351
|
+
//
|
|
352
|
+
// Reading an absent `layout` as a column is a tempting mistake and a costly
|
|
353
|
+
// one: 208 frames in the reference file have more than one child and no
|
|
354
|
+
// `layout` field, and every one of them is a row.
|
|
355
|
+
//
|
|
356
|
+
// `none` used to be counted as a conversion and then not converted: the frame got no
|
|
357
|
+
// declarations at all, so its children — which pen places by their own x and y — became
|
|
358
|
+
// ordinary block boxes and stacked. The status bar's battery fill rendered *below* the
|
|
359
|
+
// battery body instead of inside it. Now the frame becomes the containing block (see
|
|
360
|
+
// the absolute placement below) and `relative` is what makes that true.
|
|
361
|
+
if (node.layout === "none") {
|
|
362
|
+
set("position", "relative");
|
|
363
|
+
count(ledger.converted, "layoutNone");
|
|
364
|
+
} else if (node.layout === "vertical") {
|
|
365
|
+
set("display", "flex");
|
|
366
|
+
set("flex-direction", "column");
|
|
367
|
+
// Pen's own default for a column. It matters more than it looks: with
|
|
368
|
+
// `stretch`, a frame with no width fills the column, and the reference
|
|
369
|
+
// artboard renders its buttons as full-width bars instead of the pills it
|
|
370
|
+
// drew. `flex-start` is what pen means when it does not say.
|
|
371
|
+
set("align-items", "flex-start");
|
|
372
|
+
if (node.gap !== undefined) set("gap", `${node.gap}px`);
|
|
373
|
+
} else if (hasElementChildren(node)) {
|
|
374
|
+
set("display", "flex");
|
|
375
|
+
set("flex-direction", "row");
|
|
376
|
+
set("align-items", "flex-start");
|
|
377
|
+
if (node.gap !== undefined) set("gap", `${node.gap}px`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// An explicit value is written AFTER the default, or the default erases it.
|
|
381
|
+
if (node.justifyContent) set("justify-content", JUSTIFY[node.justifyContent] ?? node.justifyContent);
|
|
382
|
+
if (node.alignItems) set("align-items", ALIGN[node.alignItems] ?? node.alignItems);
|
|
383
|
+
|
|
384
|
+
if (node.padding !== undefined) {
|
|
385
|
+
const padding = Array.isArray(node.padding) ? node.padding : [node.padding];
|
|
386
|
+
set("padding", boxValue(padding));
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const parentIsRow = context.parentIsRow === true;
|
|
390
|
+
const parentIsColumn = context.parentIsColumn === true;
|
|
391
|
+
|
|
392
|
+
// Pen's `width` on an auto-layout REUSABLE frame is the CONTENT width, and the
|
|
393
|
+
// padding sits outside it. Pen's own exporter says so: in the reference
|
|
394
|
+
// artboard it writes `content-box` on exactly two nodes, `TabBar` and
|
|
395
|
+
// `BottomActionBar`, and `border-box` on the other 115.
|
|
396
|
+
//
|
|
397
|
+
// Both conditions are load-bearing, and each one is a measured counterexample:
|
|
398
|
+
//
|
|
399
|
+
// `reusable` `Month/2026年9月` is a column with `gap` and 12px of padding
|
|
400
|
+
// on both sides, and its 236px is the BORDER box. Only the
|
|
401
|
+
// reusable masters state a content width.
|
|
402
|
+
// `layout|gap` `StatusBar` is a master with 48px of padding, and its 375px
|
|
403
|
+
// is the border box too, because it is a fixed-position frame
|
|
404
|
+
// and not an auto-layout one.
|
|
405
|
+
//
|
|
406
|
+
// Across the whole reference file the rule matches ten nodes, and eight of
|
|
407
|
+
// them have no padding on the axis they declare, so they do not move. The two
|
|
408
|
+
// that do move are `TabBar` 375 → 423 and `BottomActionBar` 375 → 407, which
|
|
409
|
+
// is what pen's own export of the same artboard measures.
|
|
410
|
+
const autoLayout = node.layout === "vertical" || node.layout === "horizontal" || node.gap !== undefined;
|
|
411
|
+
const pad = node.reusable === true && autoLayout && node.padding !== undefined ? paddingSides(node.padding) : null;
|
|
412
|
+
|
|
413
|
+
// Filling is about the axis, and only a container can fill one.
|
|
414
|
+
//
|
|
415
|
+
// in a ROW, `fill_container` on the width shares the main axis: flex:1.
|
|
416
|
+
// in a COLUMN, the width is the cross axis and stretches.
|
|
417
|
+
// `fill_container` on a HEIGHT is the cross axis in a row and the main axis
|
|
418
|
+
// in a column.
|
|
419
|
+
//
|
|
420
|
+
// Two mistakes live here, and both are quiet. Writing `100%` collapses,
|
|
421
|
+
// because a percentage inside an auto-width parent resolves to zero. And
|
|
422
|
+
// stretching a text node along a row stretches it VERTICALLY, which is not
|
|
423
|
+
// what the author asked for: the sheet in the reference modal grew to four
|
|
424
|
+
// times its height that way. A text node hugs its content instead.
|
|
425
|
+
if (node.width !== undefined) {
|
|
426
|
+
const value = widthValue(node.width, node.textGrowth, parentIsRow ? true : parentIsColumn ? false : undefined);
|
|
427
|
+
if (value === FILL_ALONG) {
|
|
428
|
+
if (context.text && !hasElementChildren(node)) set("width", "fit-content");
|
|
429
|
+
else { set("flex", "1 1 0%"); set("min-width", "0"); }
|
|
430
|
+
} else if (value === FILL_ACROSS) {
|
|
431
|
+
if (context.text && !hasElementChildren(node)) set("width", "fit-content");
|
|
432
|
+
// `align-self: stretch` is the obvious spelling and it is the wrong one:
|
|
433
|
+
// it is overridden by the parent's own `align-items`, and a column whose
|
|
434
|
+
// author wrote `align-items: flex-start` — which pen does by default —
|
|
435
|
+
// then leaves the child hugging its content. A tab bar collapsed from
|
|
436
|
+
// 327px to 38px that way. `100%` fills a parent that has a definite
|
|
437
|
+
// width, which is the case where pen says `fill_container` on the cross
|
|
438
|
+
// axis.
|
|
439
|
+
else set("width", "100%");
|
|
440
|
+
} else {
|
|
441
|
+
set("width", paddedSize(value, pad, "x"));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
if (node.height !== undefined) {
|
|
445
|
+
const value = heightValue(node.height, parentIsColumn ? true : parentIsRow ? false : undefined);
|
|
446
|
+
if (value === FILL_ALONG) { set("flex", "1 1 0%"); set("min-height", "0"); }
|
|
447
|
+
else if (value === FILL_ACROSS) set("align-self", "stretch");
|
|
448
|
+
else set("height", paddedSize(value, pad, "y"));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// A frame with no width stretches to its parent, which is the flex default
|
|
452
|
+
// and what pen means by an unset width on a container. Only a leaf hugs its
|
|
453
|
+
// content, because a leaf has nothing to stretch around.
|
|
454
|
+
if (node.width === undefined && !hasElementChildren(node) && context.text) set("width", "fit-content");
|
|
455
|
+
|
|
456
|
+
// Pen's auto layout is a fixed-position model: a control keeps the size it was
|
|
457
|
+
// drawn at, and nothing shrinks to fit. CSS flexbox is the opposite — every
|
|
458
|
+
// item starts at `flex-shrink: 1` — so a 375px status bar inside a 404px column
|
|
459
|
+
// gets squeezed, and the whole artboard reads as squashed.
|
|
460
|
+
//
|
|
461
|
+
// Pen's own exporter states this on every flex child, and the pen FILE has no
|
|
462
|
+
// field for it: it is what the format means, not something the author chose.
|
|
463
|
+
// So it is written here, where pen's semantics are translated, and not in the
|
|
464
|
+
// format table, where it would rewrite every hand-written design.
|
|
465
|
+
const penType = node.type;
|
|
466
|
+
const isContainer = penType === "frame" || node.reusable === true;
|
|
467
|
+
if (isContainer && !style.has("flex")) set("flex-shrink", "0");
|
|
468
|
+
|
|
469
|
+
if (node.textGrowth === "fixed-width" && node.width === undefined) {
|
|
470
|
+
set("white-space", "normal");
|
|
471
|
+
count(ledger.converted, "textGrowth");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// A fill is a background on a box and a colour on a glyph — except on a glyph
|
|
475
|
+
// that strokes its own outline, where the stroke IS the drawing. There the
|
|
476
|
+
// stroke's paint is the colour, and a fill beside it is named as lost by
|
|
477
|
+
// `glyphPlan`: this format gives a glyph one paint, and the element's `color`
|
|
478
|
+
// is it.
|
|
479
|
+
const glyphStrokePaint = context.glyph ? glyphStroke(node) : null;
|
|
480
|
+
const fill = node.fill;
|
|
481
|
+
if (glyphStrokePaint) {
|
|
482
|
+
set("color", colourValue(resolveReference(glyphStrokePaint.paint, refs, ledger), ledger));
|
|
483
|
+
} else if (typeof fill === "string") {
|
|
484
|
+
const value = colourValue(resolveReference(fill, refs, ledger), ledger);
|
|
485
|
+
if (context.glyph) set("color", value);
|
|
486
|
+
else if (context.text) set("color", value);
|
|
487
|
+
else set("background-color", value);
|
|
488
|
+
} else if (fill && typeof fill === "object") {
|
|
489
|
+
applyObjectFill(fill, style, set, ledger, context);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (node.cornerRadius !== undefined) {
|
|
493
|
+
if (Array.isArray(node.cornerRadius)) {
|
|
494
|
+
// Pen writes one radius, or four in top-right-bottom-left order. This
|
|
495
|
+
// format has a single `border-radius`, so four equal corners become one
|
|
496
|
+
// value and anything else becomes the four longhand properties.
|
|
497
|
+
const corners = node.cornerRadius.map((v) => (Number(v) >= 9999 ? "9999px" : `${Number(v)}px`));
|
|
498
|
+
if (corners.length === 1 || corners.every((v) => v === corners[0])) {
|
|
499
|
+
set("border-radius", corners[0]);
|
|
500
|
+
} else {
|
|
501
|
+
// This format has one radius for all four corners (docs/format-v3.md,
|
|
502
|
+
// canonical form). Per-corner radii would be four more properties in
|
|
503
|
+
// the table and four more controls in the inspector, for one node in a
|
|
504
|
+
// 1541-node reference file. So the corners collapse to the largest and
|
|
505
|
+
// the loss is named, rather than the format growing a rare feature.
|
|
506
|
+
const largest = corners.slice().sort((a, b) => (parseFloat(b) || 0) - (parseFloat(a) || 0))[0];
|
|
507
|
+
set("border-radius", largest);
|
|
508
|
+
count(ledger.lost, "perCornerRadius");
|
|
509
|
+
}
|
|
510
|
+
count(ledger.converted, "cornerRadiusArray");
|
|
511
|
+
} else if (node.cornerRadius >= 9999) {
|
|
512
|
+
// A pill, and `50%` is NOT one: a percentage radius is measured against
|
|
513
|
+
// the box's own width and height, so on a 421x48 button it draws an
|
|
514
|
+
// ellipse. Pen says 9999 and means "as round as the box allows", which is
|
|
515
|
+
// what a very large pixel radius does.
|
|
516
|
+
set("border-radius", "9999px");
|
|
517
|
+
count(ledger.converted, "pillRadius");
|
|
518
|
+
} else {
|
|
519
|
+
set("border-radius", `${node.cornerRadius}px`);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (node.type === "ellipse") {
|
|
524
|
+
// Pen's ellipse is a true ellipse: `rx` and `ry` differ when the box is not
|
|
525
|
+
// square, and `50%` is the only radius that does the same — it is measured
|
|
526
|
+
// against the box's own width and height. The declaration goes on the node
|
|
527
|
+
// and not into a `[data-kind="ellipse"]` rule, because `export` strips the
|
|
528
|
+
// design attributes and the rule would stop matching in the deliverable.
|
|
529
|
+
set("border-radius", "50%");
|
|
530
|
+
count(ledger.converted, "ellipseRadius");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// A glyph draws its outline, so a stroke on it is the drawing and not a ring
|
|
534
|
+
// around the box. `applyStroke` below writes the ring, which is right for a
|
|
535
|
+
// frame and wrong for a `<svg>`; the glyph's own stroke is written on its
|
|
536
|
+
// `<path>` instead. See `glyphStroke`.
|
|
537
|
+
if ((node.stroke !== undefined || node.strokeWidth !== undefined) && !context.glyph) {
|
|
538
|
+
applyStroke(node, style, set, refs, ledger);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (node.effect !== undefined) applyEffect(node.effect, style, set, ledger);
|
|
542
|
+
|
|
543
|
+
if (node.opacity !== undefined && node.opacity < 1) set("opacity", String(node.opacity));
|
|
544
|
+
if (node.clip === true) set("overflow", "hidden");
|
|
545
|
+
// A stroked outline is centred on its path, so half of it lies outside the
|
|
546
|
+
// drawing; a turned one lies outside its own box. Both are still drawn, which
|
|
547
|
+
// is what pen means by `overflow: visible` on the same element. It goes after
|
|
548
|
+
// `clip`, because for a glyph the spilling is the design.
|
|
549
|
+
if (context.glyphOverflow === true) set("overflow", "visible");
|
|
550
|
+
|
|
551
|
+
if (context.text) applyTextStyle(node, style, set, refs, ledger);
|
|
552
|
+
|
|
553
|
+
return styleOrder(style, ledger);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* A colour as this format spells it: six or eight digit lower case hex.
|
|
558
|
+
* A `var()` reference is left alone, and a value that cannot be read is passed
|
|
559
|
+
* through and counted, because the validator will report it with a better
|
|
560
|
+
* message than this reader can.
|
|
561
|
+
*/
|
|
562
|
+
function colourValue(value, ledger) {
|
|
563
|
+
const text = String(value ?? "");
|
|
564
|
+
if (text === "" || text.startsWith("var(")) return text;
|
|
565
|
+
try {
|
|
566
|
+
return normalizeColor(text);
|
|
567
|
+
} catch {
|
|
568
|
+
count(ledger.converted, "unparsedColors");
|
|
569
|
+
return text;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* The declarations a `ref` states for itself: only size, and only when the
|
|
575
|
+
* source gave one. Everything else belongs to the node it points at.
|
|
576
|
+
*
|
|
577
|
+
* The main axis is `flex`, and `flex-basis` beats the copy's own `width`, so
|
|
578
|
+
* filling along the axis works from the reference alone. The cross axis is
|
|
579
|
+
* different: `align-self: stretch` stretches a flex item only while its own
|
|
580
|
+
* cross size is `auto`, and a master that states a size — every master does —
|
|
581
|
+
* wins over it. A `fill_container` divider inside the review list therefore
|
|
582
|
+
* stayed at the master's 320px instead of the list's 311px. A percentage says
|
|
583
|
+
* what the reference means, and resolves to `auto` when the parent has no
|
|
584
|
+
* definite size, which is the case where stretching would have done nothing
|
|
585
|
+
* either.
|
|
586
|
+
*/
|
|
587
|
+
function styleFromRefSize(node, context = {}, ledger) {
|
|
588
|
+
const style = new Map();
|
|
589
|
+
const width = node.width === undefined ? undefined : widthValue(node.width, node.textGrowth, context.parentIsRow === true);
|
|
590
|
+
if (width === FILL_ALONG) { style.set("flex", "1 1 0%"); style.set("min-width", "0"); }
|
|
591
|
+
else if (width === FILL_ACROSS) style.set("width", "100%");
|
|
592
|
+
else if (typeof width === "string") style.set("width", width);
|
|
593
|
+
if (node.height !== undefined) {
|
|
594
|
+
const height = heightValue(node.height, context.parentIsColumn === true);
|
|
595
|
+
if (height === FILL_ALONG) { style.set("flex", "1 1 0%"); style.set("min-height", "0"); }
|
|
596
|
+
else if (height === FILL_ACROSS) style.set("height", "100%");
|
|
597
|
+
else style.set("height", height);
|
|
598
|
+
}
|
|
599
|
+
if (style.size === 0) return "";
|
|
600
|
+
return orderedStyleText([...style.entries()], ledger);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* One declaration list over another, in the table's order.
|
|
605
|
+
*
|
|
606
|
+
* The same merge the format's own renderer does for a field-level patch: a
|
|
607
|
+
* property the later list states replaces the earlier one and every other
|
|
608
|
+
* declaration survives. Both halves go through the format's normalizer, so the
|
|
609
|
+
* result is canonical by construction.
|
|
610
|
+
*/
|
|
611
|
+
function mergeStyleText(base, extra, ledger) {
|
|
612
|
+
if (!extra) return base;
|
|
613
|
+
const map = new Map();
|
|
614
|
+
for (const declaration of parseBareDeclarations(base)) map.set(declaration.property, declaration.value);
|
|
615
|
+
for (const declaration of parseBareDeclarations(extra)) map.set(declaration.property, declaration.value);
|
|
616
|
+
return orderedStyleText([...map.entries()], ledger);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** Split a declaration list. Only used to hand text back to the format table. */
|
|
620
|
+
function parseBareDeclarations(value) {
|
|
621
|
+
const declarations = [];
|
|
622
|
+
for (const part of String(value ?? "").split(";")) {
|
|
623
|
+
const trimmed = part.trim();
|
|
624
|
+
if (!trimmed) continue;
|
|
625
|
+
const colon = trimmed.indexOf(":");
|
|
626
|
+
if (colon === -1) continue;
|
|
627
|
+
declarations.push({ property: trimmed.slice(0, colon).trim().toLowerCase(), value: trimmed.slice(colon + 1).trim() });
|
|
628
|
+
}
|
|
629
|
+
return declarations;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function hasElementChildren(node) {
|
|
633
|
+
return Array.isArray(node.children) && node.children.some((child) => child && typeof child === "object" && child.type);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function boxValue(values) {
|
|
637
|
+
return values.map((v) => (v === 0 ? "0" : `${v}px`)).join(" ");
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* The four padding sides, in CSS order.
|
|
642
|
+
*
|
|
643
|
+
* Pen writes padding as one number, or as a list in top/right/bottom/left order,
|
|
644
|
+
* which is also CSS order — so a list of three is top, horizontal, bottom, the
|
|
645
|
+
* same as `padding: a b c` in CSS.
|
|
646
|
+
*/
|
|
647
|
+
function paddingSides(padding) {
|
|
648
|
+
const values = (Array.isArray(padding) ? padding : [padding])
|
|
649
|
+
.map((v) => (typeof v === "number" ? v : parseFloat(v)))
|
|
650
|
+
.filter((v) => Number.isFinite(v));
|
|
651
|
+
if (values.length === 0) return { top: 0, right: 0, bottom: 0, left: 0 };
|
|
652
|
+
if (values.length === 1) return { top: values[0], right: values[0], bottom: values[0], left: values[0] };
|
|
653
|
+
if (values.length === 2) return { top: values[0], right: values[1], bottom: values[0], left: values[1] };
|
|
654
|
+
if (values.length === 3) return { top: values[0], right: values[1], bottom: values[2], left: values[1] };
|
|
655
|
+
return { top: values[0], right: values[1], bottom: values[2], left: values[3] };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** A declared size plus the padding on that axis. A keyword passes through. */
|
|
659
|
+
function paddedSize(value, pad, axis) {
|
|
660
|
+
if (!pad) return value;
|
|
661
|
+
const match = /^(-?\d+(?:\.\d+)?)px$/.exec(String(value));
|
|
662
|
+
if (!match) return value;
|
|
663
|
+
const extra = axis === "x" ? pad.left + pad.right : pad.top + pad.bottom;
|
|
664
|
+
if (!extra) return value;
|
|
665
|
+
return `${Math.round((Number(match[1]) + extra) * 100) / 100}px`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Sizes that are not a number.
|
|
670
|
+
*
|
|
671
|
+
* `fill_container` needs the parent's axis to be spelled, so these return a
|
|
672
|
+
* marker: filling the main axis and filling the cross axis are two different
|
|
673
|
+
* declarations. Returning `100%` for both is what a first pass does, and it
|
|
674
|
+
* collapses — a percentage width inside a parent whose own width is auto
|
|
675
|
+
* resolves to zero, so a panel beside a fixed portrait shrinks instead of
|
|
676
|
+
* filling the rest, and its text wraps three times.
|
|
677
|
+
*/
|
|
678
|
+
const FILL_ALONG = Symbol("fill-along");
|
|
679
|
+
const FILL_ACROSS = Symbol("fill-across");
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* Pen writes sizes as a number, or as one of a few keywords.
|
|
683
|
+
*
|
|
684
|
+
* `fill_container` and `fill` both mean "take the parent's width"; `hug` means
|
|
685
|
+
* "shrink to the content". Pen 2.19 renamed the first pair, and the newer name is
|
|
686
|
+
* by far the more common one in a real file: 714 uses against a handful.
|
|
687
|
+
*/
|
|
688
|
+
function widthValue(width, textGrowth, parentIsRow) {
|
|
689
|
+
if (width === "fill_container" || width === "fill") {
|
|
690
|
+
if (parentIsRow === undefined) return "100%";
|
|
691
|
+
return parentIsRow ? FILL_ALONG : FILL_ACROSS;
|
|
692
|
+
}
|
|
693
|
+
if (width === "hug" || width === "hug_contents" || width === "fit_content") return "fit-content";
|
|
694
|
+
if (width === "max_content") return "fit-content";
|
|
695
|
+
if (width === null) return "fit-content";
|
|
696
|
+
if (typeof width === "string") return width;
|
|
697
|
+
return `${width}px`;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/** The same keywords apply to a height. */
|
|
701
|
+
function heightValue(height, parentIsColumn) {
|
|
702
|
+
if (height === "fill_container" || height === "fill") {
|
|
703
|
+
if (parentIsColumn === undefined) return "100%";
|
|
704
|
+
return parentIsColumn ? FILL_ALONG : FILL_ACROSS;
|
|
705
|
+
}
|
|
706
|
+
if (height === "hug" || height === "hug_contents" || height === "fit_content") return "fit-content";
|
|
707
|
+
if (height === "max_content") return "fit-content";
|
|
708
|
+
if (height === null) return "fit-content";
|
|
709
|
+
if (typeof height === "string") return height;
|
|
710
|
+
return `${height}px`;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function applyObjectFill(fill, style, set, ledger, context) {
|
|
714
|
+
const type = fill.type ?? "solid";
|
|
715
|
+
if (type === "image") {
|
|
716
|
+
if (context.text || context.glyph) {
|
|
717
|
+
count(ledger.lost, "imageFillOnText");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (fill.url) {
|
|
721
|
+
set("background-image", `url("${fill.url}")`);
|
|
722
|
+
// How the image meets its box. `cover` and `contain` are the CSS keywords
|
|
723
|
+
// of the same name; pen's `fill` stretches the image, which CSS writes as
|
|
724
|
+
// `100% 100%`. A mode this format cannot draw stays in the ledger as
|
|
725
|
+
// unmapped rather than quietly becoming `cover`: an image that is cropped
|
|
726
|
+
// where the design stretches it is a difference no one would look for.
|
|
727
|
+
const size = IMAGE_FILL_SIZE[fill.mode ?? "cover"];
|
|
728
|
+
if (size) set("background-size", size);
|
|
729
|
+
set("background-position", "center");
|
|
730
|
+
count(ledger.converted, "imageFills");
|
|
731
|
+
if (!size) count(ledger.unmapped, `fillMode:${fill.mode}`);
|
|
732
|
+
} else {
|
|
733
|
+
count(ledger.lost, "imageFillWithoutUrl");
|
|
734
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
if (type === "gradient") {
|
|
738
|
+
if (fill.gradientType && fill.gradientType !== "linear") {
|
|
739
|
+
count(ledger.lost, `gradient:${fill.gradientType}`);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
const stops = Array.isArray(fill.colors) ? fill.colors : [];
|
|
743
|
+
if (stops.length < 2) {
|
|
744
|
+
count(ledger.lost, "gradientWithFewerThanTwoStops");
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
// Pen's `rotation` runs counter-clockwise and CSS measures clockwise from
|
|
748
|
+
// "to top", so one is the other subtracted from 360. Two measurements pin
|
|
749
|
+
// it, and both come from pen's own rendering of the reference file:
|
|
750
|
+
//
|
|
751
|
+
// 180 the caption scrim. Pen is dark at the bottom, and 360-180 = 180.
|
|
752
|
+
// 135 the meetup banner. Pen's darkest corner is the bottom LEFT, which
|
|
753
|
+
// is 225deg, and 360-135 = 225.
|
|
754
|
+
//
|
|
755
|
+
// An earlier version wrote `rotation + 180` instead. That is right for 180
|
|
756
|
+
// and 90 degrees wrong for everything else, which is the kind of error a
|
|
757
|
+
// gradient hides: the band is still a band, it is just on the wrong side.
|
|
758
|
+
const rotation = Number.isFinite(fill.rotation) ? ((360 - fill.rotation) % 360 + 360) % 360 : 180;
|
|
759
|
+
const rendered = stops
|
|
760
|
+
.map((stop) => {
|
|
761
|
+
let color = String(stop.color ?? "#000000");
|
|
762
|
+
try {
|
|
763
|
+
color = normalizeColor(color);
|
|
764
|
+
} catch {
|
|
765
|
+
count(ledger.converted, "unparsedGradientStops");
|
|
766
|
+
}
|
|
767
|
+
const position = Number.isFinite(stop.position) ? `${Math.round(stop.position * 100)}%` : null;
|
|
768
|
+
return position === null ? color : `${color} ${position}`;
|
|
769
|
+
})
|
|
770
|
+
.join(",");
|
|
771
|
+
set("background-image", `linear-gradient(${rotation}deg,${rendered})`);
|
|
772
|
+
count(ledger.converted, "linearGradients");
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (Array.isArray(fill.enabled) || fill.enabled === false) {
|
|
776
|
+
count(ledger.lost, "disabledFill");
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
count(ledger.unmapped, `fillType:${type}`);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function applyStroke(node, style, set, refs, ledger) {
|
|
783
|
+
// 2.19 writes `stroke: {fill, thickness, align}`; 2.6 writes `stroke` plus
|
|
784
|
+
// `strokeWidth`. Both are read, so one reader covers both files.
|
|
785
|
+
let color = node.stroke;
|
|
786
|
+
let width = node.strokeWidth ?? 1;
|
|
787
|
+
if (node.stroke && typeof node.stroke === "object" && !Array.isArray(node.stroke)) {
|
|
788
|
+
// A stroke with no paint is not drawn. `border` needs a colour and there is none, so
|
|
789
|
+
// writing one means inventing it — and the value this used to invent was black.
|
|
790
|
+
//
|
|
791
|
+
// Two measurements in the reference artboards say the paint-less stroke is structure,
|
|
792
|
+
// not design. Every painted one follows its page: the same battery border is #000000 on
|
|
793
|
+
// the light pages and #ffffff on the dark ones, and the nine notification buttons carry
|
|
794
|
+
// nine different colours and thicknesses, four of them carrying none at all. Every
|
|
795
|
+
// paint-less one is the identical `{align:"inside",thickness:1}` on all nine styles,
|
|
796
|
+
// which no designer writes and no theme can colour. And the invented line hides where
|
|
797
|
+
// nobody looked: black on #141417 is invisible, black on white is a box.
|
|
798
|
+
const paint = node.stroke.fill ?? node.stroke.color;
|
|
799
|
+
if (paint === undefined || paint === null) {
|
|
800
|
+
count(ledger.lost, "strokeWithoutPaint");
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
color = paint;
|
|
804
|
+
width = node.stroke.thickness ?? width;
|
|
805
|
+
if (node.stroke.align && node.stroke.align !== "inside") {
|
|
806
|
+
count(ledger.lost, `strokeAlign:${node.stroke.align}`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const cssColor = colourValue(resolveReference(color, refs, ledger), ledger);
|
|
810
|
+
if (width && typeof width === "object") {
|
|
811
|
+
const sides = [width.top ?? 0, width.right ?? 0, width.bottom ?? 0, width.left ?? 0];
|
|
812
|
+
set("border-width", sides.map((v) => (v === 0 ? "0" : `${v}px`)).join(" "));
|
|
813
|
+
set("border-style", "solid");
|
|
814
|
+
set("border-color", cssColor);
|
|
815
|
+
count(ledger.converted, "perSideStroke");
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const cssWidth = typeof width === "number" ? width : 1;
|
|
819
|
+
if (cssWidth === 0) return;
|
|
820
|
+
set("border", `${cssWidth}px solid ${cssColor}`);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function applyEffect(effect, style, set, ledger) {
|
|
824
|
+
const effects = Array.isArray(effect) ? effect : [effect];
|
|
825
|
+
const shadows = [];
|
|
826
|
+
for (const entry of effects) {
|
|
827
|
+
if (!entry || typeof entry !== "object") continue;
|
|
828
|
+
if (entry.type === "innerShadow" || entry.shadowType === "inner") {
|
|
829
|
+
count(ledger.lost, "innerShadow");
|
|
830
|
+
continue;
|
|
831
|
+
}
|
|
832
|
+
if (entry.type !== "shadow") {
|
|
833
|
+
count(ledger.unmapped, `effect:${entry.type}`);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const x = entry.offset?.x ?? 0;
|
|
837
|
+
const y = entry.offset?.y ?? 0;
|
|
838
|
+
const blur = entry.blur ?? 0;
|
|
839
|
+
let color = String(entry.color ?? "#00000040");
|
|
840
|
+
try {
|
|
841
|
+
color = normalizeColor(color);
|
|
842
|
+
} catch {
|
|
843
|
+
count(ledger.converted, "unparsedShadowColors");
|
|
844
|
+
}
|
|
845
|
+
shadows.push(`${x}px ${y}px ${blur}px ${color}`);
|
|
846
|
+
}
|
|
847
|
+
if (shadows.length) set("box-shadow", shadows.join(", "));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function applyTextStyle(node, style, set, refs, ledger) {
|
|
851
|
+
if (node.fontFamily) {
|
|
852
|
+
const value = resolveReference(node.fontFamily, refs, ledger);
|
|
853
|
+
if (value.startsWith("var(")) {
|
|
854
|
+
set("font-family", value);
|
|
855
|
+
} else {
|
|
856
|
+
// Pen's own export appends `system-ui, sans-serif` to every family it
|
|
857
|
+
// writes, and it is not decoration. A list that ends in a webfont ends in
|
|
858
|
+
// something the machine may not have, so the fallback becomes the DOCUMENT
|
|
859
|
+
// default — a serif face on most engines, and a different layout with it.
|
|
860
|
+
// Offline, behind a proxy, or on a build machine, the design would stop
|
|
861
|
+
// looking like the design.
|
|
862
|
+
set("font-family", ensureGenericFamily(String(node.fontFamily)));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
if (node.fontSize !== undefined) set("font-size", `${node.fontSize}px`);
|
|
866
|
+
if (node.fontWeight !== undefined) set("font-weight", String(node.fontWeight));
|
|
867
|
+
if (node.lineHeight !== undefined) set("line-height", String(node.lineHeight));
|
|
868
|
+
if (node.letterSpacing !== undefined) {
|
|
869
|
+
set("letter-spacing", `${node.letterSpacing}px`);
|
|
870
|
+
}
|
|
871
|
+
if (node.textAlign) set("text-align", node.textAlign);
|
|
872
|
+
if (node.textTransform) set("text-transform", node.textTransform);
|
|
873
|
+
// A newline in pen's text is a line break the author placed, and pen's own
|
|
874
|
+
// export writes it as `<br>` inside ordinary whitespace. Written as a literal
|
|
875
|
+
// newline it is a space: HTML folds it away, the line wraps wherever it happens
|
|
876
|
+
// to fit, and the design reads differently on every machine with a different
|
|
877
|
+
// font. `pre-line` is the spelling of what pen renders — the breaks are kept
|
|
878
|
+
// and runs of spaces still fold. `pre-wrap` keeps the spaces as well, and a
|
|
879
|
+
// space at the end of a line then counts towards the line's centre: 11px of
|
|
880
|
+
// shift on the welcome document, measured against pen's export of the same file.
|
|
881
|
+
if (hasHardBreak(node.content)) {
|
|
882
|
+
set("white-space", "pre-line");
|
|
883
|
+
count(ledger.converted, "hardBreaks");
|
|
884
|
+
} else if (node.fixedWidth === true) set("white-space", "normal");
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/** Whether a text node's content carries a line break of its own. */
|
|
888
|
+
function hasHardBreak(content) {
|
|
889
|
+
return typeof content === "string" && /[\n\r]/.test(content);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/** Emit order, without importing the table: this reader must stay dependency-light. */
|
|
893
|
+
const STYLE_ORDER = [
|
|
894
|
+
"display", "flex-direction", "flex-wrap", "align-items", "justify-content", "gap", "padding",
|
|
895
|
+
"position", "left", "top", "width", "height", "flex", "background-color", "border",
|
|
896
|
+
"border-width", "border-style", "border-color", "border-radius", "box-shadow", "opacity",
|
|
897
|
+
"overflow", "font-family", "font-size", "font-weight", "line-height", "letter-spacing",
|
|
898
|
+
"color", "text-align", "text-transform", "white-space", "background-image", "background-size",
|
|
899
|
+
"background-position",
|
|
900
|
+
];
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Order the declarations and hand each value to the format's own normalizer.
|
|
904
|
+
*
|
|
905
|
+
* The second half matters more than it looks. Pen writes `font-weight: "normal"`
|
|
906
|
+
* and shadow offsets as `0px`; the canonical form wants `400` and `0`. Re-deriving
|
|
907
|
+
* those rules here would give two normalizers that drift apart, so the reader
|
|
908
|
+
* borrows the one the validator and the canvas already use.
|
|
909
|
+
*/
|
|
910
|
+
function styleOrder(map, ledger) {
|
|
911
|
+
return orderedStyleText([...map.entries()], ledger);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Order declarations the way the format table does, and put each value through
|
|
916
|
+
* the format's own normalizer.
|
|
917
|
+
*
|
|
918
|
+
* Both halves matter. Pen writes `font-weight: "normal"` and shadow offsets as
|
|
919
|
+
* `0px`; the canonical form wants `400` and `0`. And the emit ORDER is fixed by
|
|
920
|
+
* the table — `min-width` before `flex`, `position` before `width` — so a reader
|
|
921
|
+
* with its own idea of the order writes a file whose very first `format --check`
|
|
922
|
+
* reports a diff the author never made.
|
|
923
|
+
*
|
|
924
|
+
* Re-deriving either here would give two implementations, and two
|
|
925
|
+
* implementations drift.
|
|
926
|
+
*/
|
|
927
|
+
function orderedStyleText(pairs, ledger) {
|
|
928
|
+
const kept = [];
|
|
929
|
+
for (const [property, value] of pairs) {
|
|
930
|
+
if (value === undefined || value === null || value === "") continue;
|
|
931
|
+
kept.push([property, normaliseValueFor(property, value, ledger)]);
|
|
932
|
+
}
|
|
933
|
+
const text = kept.map(([property, value]) => `${property}:${value}`).join(";");
|
|
934
|
+
return normalizeStyleText(text, parseBareDeclarations).text;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* One value, through the format's own normalizer.
|
|
939
|
+
*
|
|
940
|
+
* The validator has the better message for a value it cannot read, so an
|
|
941
|
+
* unreadable value is passed through and recorded rather than re-diagnosed.
|
|
942
|
+
*/
|
|
943
|
+
function normaliseValueFor(property, value, ledger) {
|
|
944
|
+
if (!ledger || property.startsWith("--")) return value;
|
|
945
|
+
try {
|
|
946
|
+
const normalized = normalizeValue(property, value);
|
|
947
|
+
if (normalized.changed) count(ledger.converted, "normalizedValues");
|
|
948
|
+
return normalized.value;
|
|
949
|
+
} catch (error) {
|
|
950
|
+
ledger.notes.push(`${property}:${value} — ${error.message}`);
|
|
951
|
+
return value;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// -- nodes --------------------------------------------------------------------
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Pen node types, as this format's types.
|
|
959
|
+
*
|
|
960
|
+
* `icon_font` and `path` are both an icon by another spelling. Pen 2.6 writes a glyph as
|
|
961
|
+
* `iconFontName` + `iconFontFamily` where 2.19 writes `icon` + `library`, and a `path`
|
|
962
|
+
* node carries the outline itself instead of a name. All three draw the same thing, so
|
|
963
|
+
* they all become `icon`; what differs is where the geometry comes from, and that is
|
|
964
|
+
* `glyphBody`'s business.
|
|
965
|
+
*/
|
|
966
|
+
const TYPE_MAP = {
|
|
967
|
+
frame: "frame", text: "text", icon: "icon", icon_font: "icon", path: "icon",
|
|
968
|
+
rectangle: "shape", ellipse: "shape",
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* The type this node becomes, or null when the node has no spelling here.
|
|
973
|
+
*
|
|
974
|
+
* `reusable` changes nothing: pen's master is a frame that other nodes reference,
|
|
975
|
+
* and the reference is what carries the meaning. Nothing in this format is named
|
|
976
|
+
* by another element, so a master and a plain frame are the same element.
|
|
977
|
+
*/
|
|
978
|
+
function dataTypeFor(node) {
|
|
979
|
+
return TYPE_MAP[node.type] ?? null;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* A glyph's name, in whichever of the three spellings the node uses.
|
|
984
|
+
*
|
|
985
|
+
* A `path` node has no name of its own — its outline is the glyph — so the layer name
|
|
986
|
+
* becomes one. The table wants letters, digits and hyphens, and `data-icon` is a label
|
|
987
|
+
* here: the drawing is the geometry, and the validator does not look the name up.
|
|
988
|
+
*/
|
|
989
|
+
function iconNameFor(node) {
|
|
990
|
+
const named = node.icon ?? node.iconFontName;
|
|
991
|
+
if (named !== undefined && named !== null && String(named) !== "") return String(named);
|
|
992
|
+
const slug = String(node.name ?? "").trim().toLowerCase()
|
|
993
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
994
|
+
.replace(/^-+|-+$/g, "");
|
|
995
|
+
return slug === "" ? "circle" : slug;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* The stroke a glyph draws itself with, or null when it is filled instead.
|
|
1000
|
+
*
|
|
1001
|
+
* Two spellings, and both are read: 2.6 writes `stroke` as a colour beside
|
|
1002
|
+
* `strokeWidth`, 2.19 writes `stroke: {fill, thickness, align}`. A width with no
|
|
1003
|
+
* paint is not a stroke — `applyStroke` says why at length, and the reason does
|
|
1004
|
+
* not change because the node is a glyph.
|
|
1005
|
+
*
|
|
1006
|
+
* Pure: it resolves nothing and counts nothing, because the style and the markup
|
|
1007
|
+
* both ask it the same question and a helper that counted would count twice. The
|
|
1008
|
+
* paint goes through the same `colourValue`/`resolveReference` path as a fill.
|
|
1009
|
+
*/
|
|
1010
|
+
function glyphStroke(node) {
|
|
1011
|
+
const stroke = node.stroke;
|
|
1012
|
+
let paint = null;
|
|
1013
|
+
let width = null;
|
|
1014
|
+
if (typeof stroke === "string") {
|
|
1015
|
+
paint = stroke;
|
|
1016
|
+
width = node.strokeWidth ?? null;
|
|
1017
|
+
} else if (stroke && typeof stroke === "object" && !Array.isArray(stroke)) {
|
|
1018
|
+
paint = stroke.fill ?? stroke.color ?? null;
|
|
1019
|
+
width = stroke.thickness ?? node.strokeWidth ?? null;
|
|
1020
|
+
}
|
|
1021
|
+
if (stroke === undefined && node.strokeWidth !== undefined) return null;
|
|
1022
|
+
if (paint === null || paint === undefined || paint === "") return null;
|
|
1023
|
+
|
|
1024
|
+
const linecap = CAPS.has(node.strokeLinecap) ? node.strokeLinecap : null;
|
|
1025
|
+
const linejoin = JOINS.has(node.strokeLinejoin) ? node.strokeLinejoin : null;
|
|
1026
|
+
if (width !== null && typeof width === "object") {
|
|
1027
|
+
// A per-side width on an outline has no meaning — which side of a curve? —
|
|
1028
|
+
// and pen writes the four-value spelling for boxes. It collapses to the
|
|
1029
|
+
// largest, the way `cornerRadius` collapses to its largest corner, and the
|
|
1030
|
+
// ledger names it instead of the outline disappearing.
|
|
1031
|
+
const sides = [width.top, width.right, width.bottom, width.left].map(Number).filter(Number.isFinite);
|
|
1032
|
+
const largest = sides.length ? Math.max(...sides) : null;
|
|
1033
|
+
return { paint, width: largest, perSide: true, linecap, linejoin };
|
|
1034
|
+
}
|
|
1035
|
+
const number = Number(width);
|
|
1036
|
+
return { paint, width: Number.isFinite(number) && number > 0 ? number : null, perSide: false, linecap, linejoin };
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const CAPS = new Set(["butt", "round", "square"]);
|
|
1040
|
+
const JOINS = new Set(["miter", "round", "bevel"]);
|
|
1041
|
+
|
|
1042
|
+
/** A node's own rotation, in pen's degrees, or null when it has none worth writing. */
|
|
1043
|
+
function rotationOf(node) {
|
|
1044
|
+
const rotation = Number(node.rotation);
|
|
1045
|
+
if (!Number.isFinite(rotation) || rotation % 360 === 0) return null;
|
|
1046
|
+
return rotation;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Everything a glyph's element needs, decided once.
|
|
1051
|
+
*
|
|
1052
|
+
* The grid first. A named glyph comes from the built-in set and is drawn on that
|
|
1053
|
+
* set's grid. A `path` node carries its own outline, and the box that outline is
|
|
1054
|
+
* drawn in is the OUTLINE'S OWN box — not the node's. The two are the same for
|
|
1055
|
+
* every glyph a design tool draws at the size it was drawn, which is why the
|
|
1056
|
+
* node's box looked right for so long; they stop being the same the moment a
|
|
1057
|
+
* path is scaled, and a 14×14 arrow in a 366×366 node then drew 14 pixels wide
|
|
1058
|
+
* in the corner of its own box. pen's own export of that node says
|
|
1059
|
+
* `viewBox="0 0 14 14"` on a 366×366 element: the outline's box is the grid, and
|
|
1060
|
+
* the element scales it. So the outline is measured, and its box is the viewBox.
|
|
1061
|
+
*
|
|
1062
|
+
* A node whose outline cannot be measured keeps the node's box, which is what
|
|
1063
|
+
* this reader did for every path before, and which is right for an outline that
|
|
1064
|
+
* does not parse or has no area to speak of.
|
|
1065
|
+
*
|
|
1066
|
+
* Then the turn. This format has a `transform`, but it is one `rotate()` about
|
|
1067
|
+
* the element's centre, and there is no `transform-origin`; pen turns an element
|
|
1068
|
+
* about its top-left corner. The two pivots differ, so a rotated outline is
|
|
1069
|
+
* rewritten instead of wrapped: the coordinates are turned about the box's own
|
|
1070
|
+
* origin, which is where pen turns it, and pen measures rotation
|
|
1071
|
+
* counter-clockwise where CSS measures it clockwise. The geometry is no longer
|
|
1072
|
+
* the bytes pen wrote, so the ledger says so.
|
|
1073
|
+
*
|
|
1074
|
+
* @param {object} node
|
|
1075
|
+
* @param {object} ledger
|
|
1076
|
+
* @returns {{viewBox: string, geometry: string|null, stroke: object|null, overflow: boolean, turned: boolean}}
|
|
1077
|
+
*/
|
|
1078
|
+
function glyphPlan(node, ledger) {
|
|
1079
|
+
const plan = { viewBox: ICON_VIEW_BOX, geometry: null, stroke: glyphStroke(node), overflow: false, turned: false };
|
|
1080
|
+
const isPath = node.type === "path" && typeof node.geometry === "string" && node.geometry !== "";
|
|
1081
|
+
|
|
1082
|
+
if (!isPath) {
|
|
1083
|
+
// A named glyph with a stroke of its own: the width is not reproduced,
|
|
1084
|
+
// because the built-in set's drawing is stroked by the file's own base rule
|
|
1085
|
+
// and this reader will not invent a second stroke weight for it.
|
|
1086
|
+
if (plan.stroke) {
|
|
1087
|
+
count(ledger.lost, "iconStroke");
|
|
1088
|
+
if (ledger.lost.iconStroke === 1) {
|
|
1089
|
+
ledger.notes.push(`a named glyph carries stroke width ${plan.stroke.width ?? "?"}; this format draws the built-in set at its own weight, so the width was not reproduced`);
|
|
1090
|
+
}
|
|
1091
|
+
plan.stroke = null;
|
|
1092
|
+
}
|
|
1093
|
+
return plan;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
count(ledger.converted, "pathAsGeometry");
|
|
1097
|
+
const bounds = pathBounds(node.geometry);
|
|
1098
|
+
plan.geometry = node.geometry;
|
|
1099
|
+
plan.viewBox = bounds ? boxText(bounds) : nodeBoxText(node);
|
|
1100
|
+
|
|
1101
|
+
if (plan.stroke) {
|
|
1102
|
+
if (plan.stroke.perSide) {
|
|
1103
|
+
count(ledger.lost, "pathStrokeWidthPerSide");
|
|
1104
|
+
ledger.notes.push(`node ${node.id ?? "?"} strokes its outline with one width per side; an outline has no sides, so the largest was used and the ledger names the loss`);
|
|
1105
|
+
}
|
|
1106
|
+
if (node.fill !== undefined && node.fill !== null) {
|
|
1107
|
+
// pen would draw the fill and the stroke; this format gives the element one
|
|
1108
|
+
// `color`, and the stroke is the one that is drawn. Named, not silent.
|
|
1109
|
+
count(ledger.lost, "glyphFill");
|
|
1110
|
+
ledger.notes.push(`node ${node.id ?? "?"} carries both a fill and a stroke; this format draws one paint per glyph, and the stroke is the one written`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const rotation = rotationOf(node);
|
|
1115
|
+
if (rotation !== null && bounds) {
|
|
1116
|
+
try {
|
|
1117
|
+
plan.geometry = rotatePath(node.geometry, -rotation, bounds.minX, bounds.minY);
|
|
1118
|
+
plan.turned = true;
|
|
1119
|
+
count(ledger.converted, "bakedRotation");
|
|
1120
|
+
if (ledger.converted.bakedRotation === 1) {
|
|
1121
|
+
ledger.notes.push(`node ${node.id ?? "?"} is rotated ${rotation} degrees; pen turns it about its top-left corner and this format can only turn about the centre, so the outline itself was turned by ${-rotation} and the ledger counts it as bakedRotation`);
|
|
1122
|
+
}
|
|
1123
|
+
} catch {
|
|
1124
|
+
plan.turned = false;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Half of a centred stroke lies outside the outline, and a turned outline lies
|
|
1129
|
+
// outside the box it was measured in. Both are drawn anyway, as pen draws them.
|
|
1130
|
+
plan.overflow = Boolean(plan.stroke) || plan.turned;
|
|
1131
|
+
|
|
1132
|
+
// pen stretches an outline into the node's box (`preserveAspectRatio="none"`);
|
|
1133
|
+
// this format's `<svg>` keeps the aspect ratio and centres what is left over.
|
|
1134
|
+
// The difference is a fraction of a pixel on the glyphs that have one, and it
|
|
1135
|
+
// is counted rather than hidden.
|
|
1136
|
+
if (bounds) {
|
|
1137
|
+
const inkAspect = (bounds.maxX - bounds.minX) / (bounds.maxY - bounds.minY);
|
|
1138
|
+
const boxAspect = Number(node.width) / Number(node.height);
|
|
1139
|
+
if (Number.isFinite(boxAspect) && boxAspect > 0 && Math.abs(inkAspect / boxAspect - 1) > 0.01) {
|
|
1140
|
+
count(ledger.converted, "letterboxedGlyph");
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return plan;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** The viewBox text for a measured outline. */
|
|
1147
|
+
function boxText(bounds) {
|
|
1148
|
+
return `${round3(bounds.minX)} ${round3(bounds.minY)} ${round3(bounds.maxX - bounds.minX)} ${round3(bounds.maxY - bounds.minY)}`;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** The viewBox text for a node whose outline could not be measured. */
|
|
1152
|
+
function nodeBoxText(node) {
|
|
1153
|
+
const width = Number(node.width);
|
|
1154
|
+
const height = Number(node.height);
|
|
1155
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return ICON_VIEW_BOX;
|
|
1156
|
+
return `0 0 ${round3(width)} ${round3(height)}`;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
function tagFor(node, dataType) {
|
|
1160
|
+
if (dataType === "text") return "p";
|
|
1161
|
+
if (dataType === "icon") return "svg";
|
|
1162
|
+
if (dataType === "image") return "img";
|
|
1163
|
+
return "div";
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* What a child has to know about its parent.
|
|
1168
|
+
*
|
|
1169
|
+
* Two axes and one absence. `fill_container` means "share the main axis" in a row and
|
|
1170
|
+
* "stretch across" in a column — two different declarations — and a child of a
|
|
1171
|
+
* `layout:none` frame is placed by its own x and y rather than by the flow.
|
|
1172
|
+
*
|
|
1173
|
+
* This exists because the artboard loop built the same object by hand and left the flags
|
|
1174
|
+
* out, so every direct child of a page was read as if its parent's axis were unknown and
|
|
1175
|
+
* `fill_container` fell back to `width:100%`. On a row page that is wrong by exactly the
|
|
1176
|
+
* width of the sibling before it: the Swiss Clean dashboard's content took the full
|
|
1177
|
+
* 1440px beside its 240px sidebar, and the page's own `overflow:hidden` cut those 240px
|
|
1178
|
+
* off the right edge — the Export button and half the last metric card. It hid for as long
|
|
1179
|
+
* as it did because the reference file's 25 pages all have `layout: vertical`, where
|
|
1180
|
+
* `100%` and `stretch` happen to agree.
|
|
1181
|
+
*/
|
|
1182
|
+
function childContext(node, context) {
|
|
1183
|
+
const { forcedStyle, forcedAttrs, ...rest } = context;
|
|
1184
|
+
return {
|
|
1185
|
+
...rest,
|
|
1186
|
+
parentIsRow: node.layout !== "vertical",
|
|
1187
|
+
parentIsColumn: node.layout === "vertical",
|
|
1188
|
+
parentLayoutNone: node.layout === "none",
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Render one pen node as HTML.
|
|
1194
|
+
*
|
|
1195
|
+
* The reader writes HTML text and then parses it with the real parser, so an
|
|
1196
|
+
* imported file goes through exactly the same model, serializer and validator
|
|
1197
|
+
* as a hand-written one. Building a tree directly would create a second
|
|
1198
|
+
* representation that could drift from the parser's.
|
|
1199
|
+
*/
|
|
1200
|
+
function renderNode(node, refs, ledger, context = {}) {
|
|
1201
|
+
if (node.type === "ref") return renderRef(node, refs, ledger, context);
|
|
1202
|
+
if (context.countSource !== false) ledger.sourceNodes += 1;
|
|
1203
|
+
const dataType = dataTypeFor(node);
|
|
1204
|
+
if (!dataType) {
|
|
1205
|
+
count(ledger.lost, `nodeType:${node.type}`);
|
|
1206
|
+
return "";
|
|
1207
|
+
}
|
|
1208
|
+
ledger.nodes += 1;
|
|
1209
|
+
|
|
1210
|
+
const id = context.ids.get(node) ?? node.id ?? `n-${ledger.nodes}`;
|
|
1211
|
+
const attrs = [`data-id="${escapeAttr(id)}"`, `data-type="${dataType}"`];
|
|
1212
|
+
if (node.name) attrs.push(`data-name="${escapeAttr(node.name)}"`);
|
|
1213
|
+
|
|
1214
|
+
if (dataType === "shape") {
|
|
1215
|
+
// A reusable ellipse is an ellipse like any other: `data-kind` is the name of
|
|
1216
|
+
// the shape and the node's own `border-radius:50%` is the shape itself, so a
|
|
1217
|
+
// copy of it scales with its box exactly as pen's ellipse does.
|
|
1218
|
+
attrs.push(`data-kind="${node.type === "ellipse" ? "ellipse" : "rect"}"`);
|
|
1219
|
+
}
|
|
1220
|
+
// The glyph's own answers — its grid, its outline, its stroke — are decided
|
|
1221
|
+
// before the style, because the style has to know whether the drawing spills
|
|
1222
|
+
// outside its box. See `glyphPlan`.
|
|
1223
|
+
const glyph = dataType === "icon" ? glyphPlan(node, ledger) : null;
|
|
1224
|
+
|
|
1225
|
+
// A rotation this format cannot express: everything except a glyph whose
|
|
1226
|
+
// outline could be turned. Named once, counted once, and the count is what the
|
|
1227
|
+
// printed ledger line shows.
|
|
1228
|
+
const rotation = rotationOf(node);
|
|
1229
|
+
if (rotation !== null && !glyph?.turned) {
|
|
1230
|
+
if (!ledger.lost.rotation) {
|
|
1231
|
+
ledger.notes.push(`node ${id} is rotated ${rotation} degrees; pen turns it about its top-left corner, which this format cannot express, so it is drawn upright and the ledger names the loss`);
|
|
1232
|
+
}
|
|
1233
|
+
count(ledger.lost, "rotation");
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (dataType === "icon") {
|
|
1237
|
+
attrs.push(`data-icon="${escapeAttr(iconNameFor(node))}"`);
|
|
1238
|
+
attrs.push(`data-icon-library="${escapeAttr(node.library ?? node.iconFontFamily ?? "lucide")}"`);
|
|
1239
|
+
// Without this the glyph is not scaled, it is clipped. See `glyphPlan`.
|
|
1240
|
+
attrs.push(`viewBox="${glyph.viewBox}"`);
|
|
1241
|
+
}
|
|
1242
|
+
// A patch that only says "not hidden" writes nothing: the attribute is how a
|
|
1243
|
+
// node is hidden, and its absence is the same statement in fewer bytes.
|
|
1244
|
+
if (node.__dataHidden === "true") attrs.push('data-hidden="true"');
|
|
1245
|
+
for (const [name, value] of context.forcedAttrs ?? []) {
|
|
1246
|
+
attrs.push(`${name}="${escapeAttr(value)}"`);
|
|
1247
|
+
}
|
|
1248
|
+
// What this node's own style has to know about itself — not what its children need,
|
|
1249
|
+
// which is `childContext()` below. The two were one name once, and the local one
|
|
1250
|
+
// shadowed the function.
|
|
1251
|
+
const styleContext = {
|
|
1252
|
+
...context,
|
|
1253
|
+
glyph: dataType === "icon",
|
|
1254
|
+
text: dataType === "text",
|
|
1255
|
+
glyphOverflow: glyph?.overflow === true,
|
|
1256
|
+
};
|
|
1257
|
+
let style = styleFromNode(node, refs, ledger, styleContext);
|
|
1258
|
+
// Field-level patches a reference carried (`fontSize`, `width`, …) merge over the
|
|
1259
|
+
// copy's own declarations here, in the same order and through the same normalizer
|
|
1260
|
+
// as every other declaration the reader writes.
|
|
1261
|
+
if (Array.isArray(node.__extraStyle) && node.__extraStyle.length > 0) {
|
|
1262
|
+
style = mergeStyleText(style, node.__extraStyle.map(([property, value]) => `${property}:${value}`).join(";"), ledger);
|
|
1263
|
+
}
|
|
1264
|
+
if (context.forcedStyle) style = mergeStyleText(style, context.forcedStyle, ledger);
|
|
1265
|
+
if (style) attrs.push(`style="${escapeAttr(style)}"`);
|
|
1266
|
+
|
|
1267
|
+
const tag = tagFor(node, dataType);
|
|
1268
|
+
if (dataType === "image" || dataType === "icon") {
|
|
1269
|
+
const inner = dataType === "icon" ? glyphBody(node, ledger, glyph) : "";
|
|
1270
|
+
return `<${tag} ${attrs.join(" ")}>${inner}</${tag}>`;
|
|
1271
|
+
}
|
|
1272
|
+
if (!Array.isArray(node.children) || node.children.length === 0) {
|
|
1273
|
+
if (dataType === "text") {
|
|
1274
|
+
return `<${tag} ${attrs.join(" ")}>${escapeText(node.content ?? node.name ?? "")}</${tag}>`;
|
|
1275
|
+
}
|
|
1276
|
+
return `<${tag} ${attrs.join(" ")}></${tag}>`;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// A child has to know its parent's axis: `fill_container` means "share the
|
|
1280
|
+
// main axis" in a row and "stretch across" in a column, and those are two
|
|
1281
|
+
// different declarations.
|
|
1282
|
+
const children = node.children
|
|
1283
|
+
.map((child) => renderNode(child, refs, ledger, childContext(node, context)))
|
|
1284
|
+
.join("\n");
|
|
1285
|
+
if (dataType === "text") {
|
|
1286
|
+
return `<${tag} ${attrs.join(" ")}>${escapeText(node.content ?? "")}</${tag}>`;
|
|
1287
|
+
}
|
|
1288
|
+
return `<${tag} ${attrs.join(" ")}>\n${children}\n</${tag}>`;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
/**
|
|
1292
|
+
* A glyph, as inline geometry.
|
|
1293
|
+
*
|
|
1294
|
+
* Pen stores a glyph by name. This format stores geometry, so the name is
|
|
1295
|
+
* resolved against the built-in set and the glyph's own paths are written in.
|
|
1296
|
+
* Nothing is fetched: the set is in the repository, so an import works offline
|
|
1297
|
+
* and produces the same bytes on every machine.
|
|
1298
|
+
*
|
|
1299
|
+
* A name the set does not carry becomes a visible placeholder AND is counted in
|
|
1300
|
+
* the ledger. A wrong glyph would be worse than an obviously wrong one, and a
|
|
1301
|
+
* silent one would be worse than both.
|
|
1302
|
+
*
|
|
1303
|
+
* @param {object} node
|
|
1304
|
+
* @param {object} ledger
|
|
1305
|
+
* @param {object} plan what `glyphPlan` decided for this node
|
|
1306
|
+
*/
|
|
1307
|
+
function glyphBody(node, ledger, plan) {
|
|
1308
|
+
// Pen 2.6 writes a glyph as `iconFontName` where 2.19 writes `icon`. Same drawing, older
|
|
1309
|
+
// spelling, and worth a ledger line: the reference file carries 233 of them, and before
|
|
1310
|
+
// this they were all counted as an unknown node type.
|
|
1311
|
+
if (node.type === "icon_font") count(ledger.converted, "iconFont");
|
|
1312
|
+
// A `path` node IS geometry: there is no name to look up, and the outline is the glyph.
|
|
1313
|
+
if (plan && plan.geometry !== null) {
|
|
1314
|
+
const turns = node.fillRule === "evenodd" ? ' fill-rule="evenodd"' : "";
|
|
1315
|
+
if (plan.stroke) {
|
|
1316
|
+
// The stroke is the drawing, so nothing is filled and the width is the one
|
|
1317
|
+
// pen wrote. `vector-effect` keeps that width when the outline is scaled
|
|
1318
|
+
// into the node's box, which is what makes a 50 in a 14-unit outline
|
|
1319
|
+
// fifty pixels wide — pen's own export writes the same three attributes on
|
|
1320
|
+
// the same element, for the same reason.
|
|
1321
|
+
const parts = [
|
|
1322
|
+
`d="${escapeAttr(plan.geometry)}"`,
|
|
1323
|
+
'fill="none"',
|
|
1324
|
+
'stroke="currentColor"',
|
|
1325
|
+
`stroke-width="${plan.stroke.width ?? 1}"`,
|
|
1326
|
+
plan.stroke.linecap ? `stroke-linecap="${plan.stroke.linecap}"` : "",
|
|
1327
|
+
plan.stroke.linejoin ? `stroke-linejoin="${plan.stroke.linejoin}"` : "",
|
|
1328
|
+
'vector-effect="non-scaling-stroke"',
|
|
1329
|
+
].filter(Boolean);
|
|
1330
|
+
return `<path ${parts.join(" ")}${turns}></path>`;
|
|
1331
|
+
}
|
|
1332
|
+
// `fill="currentColor"` opts this path out of the base rule that strokes every child
|
|
1333
|
+
// of an `<svg>` carrying neither `data-fill` nor its own `fill`. Pen's `path` with a
|
|
1334
|
+
// fill is a filled outline, and a filled outline drawn as a stroke is a different
|
|
1335
|
+
// shape.
|
|
1336
|
+
return `<path d="${escapeAttr(plan.geometry)}" fill="currentColor"${turns}></path>`;
|
|
1337
|
+
}
|
|
1338
|
+
return glyphFor(node.icon ?? node.iconFontName, ledger);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* The same lookup, by name. A reference can swap the glyph one copy draws, and
|
|
1343
|
+
* that patch is geometry too, so it goes through here.
|
|
1344
|
+
*/
|
|
1345
|
+
function glyphFor(value, ledger) {
|
|
1346
|
+
const name = String(value ?? "");
|
|
1347
|
+
if (name && hasIcon(name)) return iconBody(name);
|
|
1348
|
+
count(ledger.lost, name ? "glyphNotInSet" : "iconWithoutName");
|
|
1349
|
+
if (name) ledger.notes.push(`glyph ${JSON.stringify(name)} is not in the built-in set (${ICONS_COUNT} glyphs): a placeholder was written`);
|
|
1350
|
+
return `<rect x="3" y="3" width="18" height="18" rx="3" fill="none" stroke="currentColor"></rect>`;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
/**
|
|
1354
|
+
* How deep a chain of references may nest before the reader calls it a loop.
|
|
1355
|
+
*
|
|
1356
|
+
* Pen allows a reference inside a master, which is how one reusable frame is
|
|
1357
|
+
* built out of another. A chain that comes back to a reference it already
|
|
1358
|
+
* inlined would never terminate, and a chain 16 deep is not a design any more.
|
|
1359
|
+
*/
|
|
1360
|
+
const REF_DEPTH_LIMIT = 16;
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* A `ref` node, inlined.
|
|
1364
|
+
*
|
|
1365
|
+
* Pen stores a reference and, separately, the node it names. This format stores
|
|
1366
|
+
* no reference: the subtree is copied in, so the file is what it draws. The copy's
|
|
1367
|
+
* root keeps the reference node's own id — that is the element the reference
|
|
1368
|
+
* stood for — and the copy's descendants are renumbered under it, because an id
|
|
1369
|
+
* is unique across the whole file and one master may be referenced many times.
|
|
1370
|
+
*
|
|
1371
|
+
* A `ref` that names nothing is refused rather than written as an empty box: the
|
|
1372
|
+
* import would produce a file whose content is missing, and a checker would have
|
|
1373
|
+
* to guess that an empty frame was meant to be a card.
|
|
1374
|
+
*/
|
|
1375
|
+
function renderRef(node, refs, ledger, context) {
|
|
1376
|
+
const chain = context.chain ?? [];
|
|
1377
|
+
const target = context.byIdNode?.get(node.ref);
|
|
1378
|
+
if (!target) {
|
|
1379
|
+
throw penError("pen/unresolved-ref",
|
|
1380
|
+
`The reference ${JSON.stringify(node.ref)} points at no node in this file, so there is no subtree to inline.`);
|
|
1381
|
+
}
|
|
1382
|
+
if (chain.includes(node.ref)) {
|
|
1383
|
+
throw penError("pen/ref-cycle",
|
|
1384
|
+
`These references form a loop: ${[...chain, node.ref].join(" -> ")}.`);
|
|
1385
|
+
}
|
|
1386
|
+
if (chain.length >= REF_DEPTH_LIMIT) {
|
|
1387
|
+
throw penError("pen/ref-depth",
|
|
1388
|
+
`References nest more than ${REF_DEPTH_LIMIT} deep at ${JSON.stringify(node.ref)}.`);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
const rootId = context.ids.get(node) ?? node.id ?? `n-${ledger.nodes + 1}`;
|
|
1392
|
+
// A reference that carries children of its own is a shape pen's model does not
|
|
1393
|
+
// merge into the master. Dropping them quietly would be content the file never
|
|
1394
|
+
// shows, so the ledger names it.
|
|
1395
|
+
if ((node.children ?? []).some((child) => child && typeof child === "object")) {
|
|
1396
|
+
count(ledger.lost, "refChildren");
|
|
1397
|
+
ledger.notes.push(`the reference ${JSON.stringify(rootId)} carries children of its own; pen's reference is the master's subtree, so they were not written`);
|
|
1398
|
+
}
|
|
1399
|
+
const copy = structuredClone(target);
|
|
1400
|
+
applyRefOverrides(copy, node.descendants, refs, ledger);
|
|
1401
|
+
renameCopy(target, copy, rootId, context.usedIds);
|
|
1402
|
+
count(ledger.refs, "inlined");
|
|
1403
|
+
|
|
1404
|
+
return renderNode(copy, refs, ledger, {
|
|
1405
|
+
...context,
|
|
1406
|
+
// The bytes under a copy are the master's, counted once where the master is.
|
|
1407
|
+
countSource: false,
|
|
1408
|
+
chain: [...chain, node.ref],
|
|
1409
|
+
// A reference may state its own size. It was merged over the master's own
|
|
1410
|
+
// declarations at mount time once; here it is one more declaration list.
|
|
1411
|
+
forcedStyle: styleFromRefSize(node, context, ledger),
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/**
|
|
1416
|
+
* Renumber a copy so its ids stay unique, in place.
|
|
1417
|
+
*
|
|
1418
|
+
* The root keeps the reference's own id and every descendant becomes
|
|
1419
|
+
* `<root>__<its own id>`, which is the same shape a hand-written id takes
|
|
1420
|
+
* (`home__title`) and the only shape that keeps a nested copy readable. Past 64
|
|
1421
|
+
* characters — the format's limit — the id falls back to the root plus a hash of
|
|
1422
|
+
* the original, and a collision takes a `-2` suffix. All of it is deterministic:
|
|
1423
|
+
* the same `.pen` produces the same bytes.
|
|
1424
|
+
*/
|
|
1425
|
+
function renameCopy(original, copy, rootId, usedIds) {
|
|
1426
|
+
const seen = usedIds ?? new Set();
|
|
1427
|
+
seen.add(rootId);
|
|
1428
|
+
const visit = (source, dupe, isRoot) => {
|
|
1429
|
+
if (isRoot) dupe.id = rootId;
|
|
1430
|
+
else {
|
|
1431
|
+
const base = source.id;
|
|
1432
|
+
if (typeof base === "string" && base !== "") dupe.id = uniqueCopyId(rootId, base, seen);
|
|
1433
|
+
else delete dupe.id;
|
|
1434
|
+
}
|
|
1435
|
+
const sourceChildren = (source.children ?? []).filter((child) => child && typeof child === "object");
|
|
1436
|
+
const copyChildren = (dupe.children ?? []).filter((child) => child && typeof child === "object");
|
|
1437
|
+
for (let i = 0; i < sourceChildren.length && i < copyChildren.length; i += 1) {
|
|
1438
|
+
visit(sourceChildren[i], copyChildren[i], false);
|
|
1439
|
+
}
|
|
1440
|
+
};
|
|
1441
|
+
visit(original, copy, true);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function uniqueCopyId(rootId, base, used) {
|
|
1445
|
+
let candidate = `${rootId}__${base}`;
|
|
1446
|
+
if (candidate.length > 64) candidate = `${rootId.slice(0, 40)}__${Math.abs(hash(base)).toString(36)}`;
|
|
1447
|
+
let id = candidate;
|
|
1448
|
+
for (let n = 2; used.has(id); n += 1) id = `${candidate}-${n}`;
|
|
1449
|
+
used.add(id);
|
|
1450
|
+
return id;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* The field-level patches a `ref` carries, applied to the copy's own nodes.
|
|
1455
|
+
*
|
|
1456
|
+
* Pen's `descendants` names nodes of the master. The copy is a pen tree too, so a
|
|
1457
|
+
* patch is a pen property — that is the whole reason this happens before rendering
|
|
1458
|
+
* rather than to the bytes afterwards: `icon` becomes the master's glyph resolved
|
|
1459
|
+
* from the built-in set, `fill` goes through the same colour path as any fill, and
|
|
1460
|
+
* a CSS field is a declaration the renderer already knows how to order.
|
|
1461
|
+
*
|
|
1462
|
+
* A field with no spelling here is counted in `unmapped`, never dropped quietly.
|
|
1463
|
+
*/
|
|
1464
|
+
function applyRefOverrides(copy, descendants, refs, ledger) {
|
|
1465
|
+
if (!descendants || typeof descendants !== "object") return;
|
|
1466
|
+
const index = new Map();
|
|
1467
|
+
walkPen(copy, (node) => {
|
|
1468
|
+
if (typeof node.id === "string" && !index.has(node.id)) index.set(node.id, node);
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
let applied = 0;
|
|
1472
|
+
for (const [targetId, fields] of Object.entries(descendants)) {
|
|
1473
|
+
const target = index.get(targetId);
|
|
1474
|
+
if (!target) {
|
|
1475
|
+
count(ledger.unmapped, `overrideTarget:${targetId}`);
|
|
1476
|
+
ledger.notes.push(`the reference patches ${JSON.stringify(targetId)}, which is not an id inside the node it points at`);
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
let touched = false;
|
|
1480
|
+
const declarations = [];
|
|
1481
|
+
for (const [key, value] of Object.entries(fields ?? {})) {
|
|
1482
|
+
if (key === "content") { target.content = String(value); touched = true; continue; }
|
|
1483
|
+
if (key === "fill") { target.fill = resolveReference(value, refs, ledger); touched = true; continue; }
|
|
1484
|
+
// `icon` swaps one copy's glyph. The name is the node's own spelling, and
|
|
1485
|
+
// the geometry follows from it: a glyph here is bytes, so writing the name
|
|
1486
|
+
// without redrawing is a label that disagrees with its own drawing.
|
|
1487
|
+
if (key === "icon") {
|
|
1488
|
+
target.icon = String(value);
|
|
1489
|
+
count(ledger.converted, "iconOverrides");
|
|
1490
|
+
touched = true;
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
if (key === "enabled" || key === "visible") {
|
|
1494
|
+
if (value) delete target.__dataHidden;
|
|
1495
|
+
else target.__dataHidden = "true";
|
|
1496
|
+
touched = true;
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
const css = OVERRIDE_CSS[key];
|
|
1500
|
+
if (css) {
|
|
1501
|
+
const rendered = cssValueFor(css, value, { refs }, ledger);
|
|
1502
|
+
if (rendered) { declarations.push([css, rendered]); touched = true; }
|
|
1503
|
+
continue;
|
|
1504
|
+
}
|
|
1505
|
+
ledger.refs.overrideFieldsDropped += 1;
|
|
1506
|
+
count(ledger.unmapped, `override:${key}`);
|
|
1507
|
+
}
|
|
1508
|
+
if (declarations.length) target.__extraStyle = declarations;
|
|
1509
|
+
if (touched) applied += 1;
|
|
1510
|
+
}
|
|
1511
|
+
if (applied > 0) ledger.refs.overridesApplied += 1;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const OVERRIDE_CSS = {
|
|
1515
|
+
fontSize: "font-size",
|
|
1516
|
+
fontWeight: "font-weight",
|
|
1517
|
+
lineHeight: "line-height",
|
|
1518
|
+
letterSpacing: "letter-spacing",
|
|
1519
|
+
textAlign: "text-align",
|
|
1520
|
+
width: "width",
|
|
1521
|
+
height: "height",
|
|
1522
|
+
cornerRadius: "border-radius",
|
|
1523
|
+
clip: "overflow",
|
|
1524
|
+
opacity: "opacity",
|
|
1525
|
+
};
|
|
1526
|
+
|
|
1527
|
+
function cssValueFor(property, value, context, ledger) {
|
|
1528
|
+
if (property === "overflow") return value ? "hidden" : "visible";
|
|
1529
|
+
if (property === "border-radius") return value >= 9999 ? "9999px" : `${value}px`;
|
|
1530
|
+
if (["width", "height", "font-size", "letter-spacing", "cornerRadius"].includes(property)) return `${value}px`;
|
|
1531
|
+
if (property === "opacity") return String(value);
|
|
1532
|
+
return resolveReference(String(value), context.refs, ledger);
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
// -- the import ---------------------------------------------------------------
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* @param {string} text the `.pen` file
|
|
1539
|
+
* @param {object} options `{ title, fallbackTitle }`
|
|
1540
|
+
* `title` wins, then the document's own `name`, then `fallbackTitle`. The last one exists
|
|
1541
|
+
* because this function is handed text and cannot know what the file is called; the verb
|
|
1542
|
+
* that read it supplies the name. Before it, a `.pen` with no `name` was titled
|
|
1543
|
+
* "imported", which is a title that says only that something happened.
|
|
1544
|
+
* @returns {{ html: string, ledger: object, diagnostics: Array }}
|
|
1545
|
+
*/
|
|
1546
|
+
export function importPen(text, options = {}) {
|
|
1547
|
+
const ledger = createLedger();
|
|
1548
|
+
temporaryLedger = ledger;
|
|
1549
|
+
const json = parsePen(text);
|
|
1550
|
+
ledger.version = json.version ?? null;
|
|
1551
|
+
if (ledger.version && !PEN_VERSION_SUPPORTED.includes(ledger.version)) {
|
|
1552
|
+
ledger.notes.push(`version ${ledger.version} is newer than this reader was written against (${PEN_VERSION_SUPPORTED.join(", ")})`);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
const { tokens, refs } = tokensFromVariables(json.variables, ledger);
|
|
1556
|
+
const { byNode: ids, byIdNode } = idLookup(json);
|
|
1557
|
+
const renamed = [...ids.entries()].filter(([node, to]) => node.id !== to).length;
|
|
1558
|
+
if (renamed > 0) count(ledger.converted, "renumberedIds", renamed);
|
|
1559
|
+
|
|
1560
|
+
// Every id the file has already spent. An inlined copy renumbers under its
|
|
1561
|
+
// reference's id, and it has to stay out of this set: an id is unique across the
|
|
1562
|
+
// whole document.
|
|
1563
|
+
const usedIds = new Set(ids.values());
|
|
1564
|
+
const context = { ids, byIdNode, refs, tokens, usedIds, chain: [] };
|
|
1565
|
+
const pages = [];
|
|
1566
|
+
for (const artboard of json.children) {
|
|
1567
|
+
if (artboard.type !== "frame") {
|
|
1568
|
+
count(ledger.lost, `topLevelType:${artboard.type}`);
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
// A reusable artboard is a frame other nodes reference. It is still an
|
|
1572
|
+
// artboard, so it is still a page: nothing here is scaffolding.
|
|
1573
|
+
const id = ids.get(artboard) ?? artboard.id ?? `page-${pages.length}`;
|
|
1574
|
+
const attrs = [`data-id="${escapeAttr(id)}"`, `data-type="page"`];
|
|
1575
|
+
if (artboard.name) attrs.push(`data-name="${escapeAttr(artboard.name)}"`);
|
|
1576
|
+
if (Number.isFinite(artboard.x) || Number.isFinite(artboard.y)) {
|
|
1577
|
+
attrs.push(`data-canvas="${Math.round(artboard.x ?? 0)},${Math.round(artboard.y ?? 0)}"`);
|
|
1578
|
+
}
|
|
1579
|
+
attrs.push(`style="${escapeAttr(pageStyle(artboard, refs, ledger))}"`);
|
|
1580
|
+
|
|
1581
|
+
const children = (artboard.children ?? [])
|
|
1582
|
+
.map((child) => renderNode(child, refs, ledger, childContext(artboard, context)))
|
|
1583
|
+
.join("\n");
|
|
1584
|
+
pages.push(` <section ${attrs.join(" ")}>\n${children}\n </section>`);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
ledger.pages = pages.length;
|
|
1588
|
+
|
|
1589
|
+
if (pages.length === 0) {
|
|
1590
|
+
const failure = new Error("The .pen file has no top-level frame, so it has no artboard to import.");
|
|
1591
|
+
failure.code = "pen/no-pages";
|
|
1592
|
+
throw failure;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
ledger.refs.found = countRefs(json);
|
|
1596
|
+
|
|
1597
|
+
const tokenBlock = [...tokens.values()].map((t) => `${t.name}:${t.value}`).join(";");
|
|
1598
|
+
const head = [
|
|
1599
|
+
" <meta charset=\"utf-8\">",
|
|
1600
|
+
` <title>${escapeText(options.title ?? json.name ?? options.fallbackTitle ?? "imported")}</title>`,
|
|
1601
|
+
' <meta name="design-schema" content="3.0">',
|
|
1602
|
+
// The base rules come first, in the same block as the tokens. Without them
|
|
1603
|
+
// the imported file means something else in a plain browser than it does in
|
|
1604
|
+
// the canvas: `width` becomes the content width, and every label grows the
|
|
1605
|
+
// height of its default margin. See `lib/base-css.mjs`.
|
|
1606
|
+
` <style>${baseCss()}\n :root{${tokenBlock}}</style>`,
|
|
1607
|
+
' <style>section[data-type="page"]{ margin:0 0 24px }</style>',
|
|
1608
|
+
].join("\n");
|
|
1609
|
+
|
|
1610
|
+
const html = [
|
|
1611
|
+
"<!doctype html>",
|
|
1612
|
+
"<html lang=\"zh\">",
|
|
1613
|
+
"<head>",
|
|
1614
|
+
head,
|
|
1615
|
+
"</head>",
|
|
1616
|
+
"<body>",
|
|
1617
|
+
pages.join("\n"),
|
|
1618
|
+
"</body>",
|
|
1619
|
+
"</html>",
|
|
1620
|
+
"",
|
|
1621
|
+
].join("\n");
|
|
1622
|
+
|
|
1623
|
+
// Round trip through the real parser and serializer, so the output is
|
|
1624
|
+
// canonical by construction rather than by hope.
|
|
1625
|
+
const doc = prepareBuiltDocument(parse(html));
|
|
1626
|
+
return { html: serializeCanonical(doc), ledger, diagnostics: doc.diagnostics ?? [] };
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* An artboard's own style becomes the page box's style.
|
|
1631
|
+
*
|
|
1632
|
+
* The artboard IS a frame, so it goes through the same mapping a frame does.
|
|
1633
|
+
* Mapping only its fill and size is the mistake that hides a whole screen class:
|
|
1634
|
+
* a modal artboard carries `justifyContent: "end"` so its sheet sits at the
|
|
1635
|
+
* bottom, and dropping that leaves the sheet at the top of an otherwise empty
|
|
1636
|
+
* page — with the same total height, which is what makes it hard to notice.
|
|
1637
|
+
*
|
|
1638
|
+
* The values go through the same normalizers the per-node path uses, so an
|
|
1639
|
+
* imported file is canonical by construction and its first `format --check`
|
|
1640
|
+
* passes. A reader that writes its own spelling is a reader that produces files
|
|
1641
|
+
* whose first edit shows up as a formatting diff.
|
|
1642
|
+
*/
|
|
1643
|
+
function pageStyle(artboard, refs, ledger) {
|
|
1644
|
+
const style = new Map();
|
|
1645
|
+
for (const part of styleFromNode(artboard, refs, ledger, { parentIsColumn: true }).split(";")) {
|
|
1646
|
+
const trimmed = part.trim();
|
|
1647
|
+
const colon = trimmed.indexOf(":");
|
|
1648
|
+
if (colon === -1) continue;
|
|
1649
|
+
style.set(trimmed.slice(0, colon).trim(), trimmed.slice(colon + 1).trim());
|
|
1650
|
+
}
|
|
1651
|
+
// The page box is positioned by the canvas, so it keeps its own size, and it
|
|
1652
|
+
// clips what overflows.
|
|
1653
|
+
if (artboard.width !== undefined) style.set("width", `${Number(artboard.width)}px`);
|
|
1654
|
+
if (artboard.height !== undefined) style.set("height", `${Number(artboard.height)}px`);
|
|
1655
|
+
style.set("overflow", "hidden");
|
|
1656
|
+
return orderedStyleText([...style.entries()], ledger);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
function walkPen(node, visit) {
|
|
1660
|
+
visit(node);
|
|
1661
|
+
for (const child of node.children ?? []) walkPen(child, visit);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
/** Every `ref` in the file, whether or not it resolves. */
|
|
1665
|
+
function countRefs(json) {
|
|
1666
|
+
let n = 0;
|
|
1667
|
+
for (const artboard of json.children) walkPen(artboard, (node) => { if (node.type === "ref") n += 1; });
|
|
1668
|
+
return n;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/** A one-line summary, for a CLI to print. */
|
|
1672
|
+
export function ledgerLine(ledger) {
|
|
1673
|
+
const lost = Object.entries(ledger.lost);
|
|
1674
|
+
const unmapped = Object.entries(ledger.unmapped);
|
|
1675
|
+
const parts = [
|
|
1676
|
+
`${ledger.pages} pages`,
|
|
1677
|
+
`${ledger.nodes} elements`,
|
|
1678
|
+
`${ledger.refs.inlined} references inlined`,
|
|
1679
|
+
`${ledger.refs.overridesApplied} with overrides`,
|
|
1680
|
+
];
|
|
1681
|
+
if (lost.length) parts.push(`lost: ${lost.map(([k, v]) => `${k}×${v}`).join(", ")}`);
|
|
1682
|
+
if (unmapped.length) parts.push(`unmapped: ${unmapped.map(([k, v]) => `${k}×${v}`).join(", ")}`);
|
|
1683
|
+
return parts.join(" · ");
|
|
1684
|
+
}
|