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.
Files changed (67) hide show
  1. package/README.en.md +235 -0
  2. package/README.md +184 -11
  3. package/SKILL.md +435 -0
  4. package/canvas/app.mjs +2870 -0
  5. package/canvas/groups.mjs +204 -0
  6. package/canvas/index.html +141 -0
  7. package/canvas/styles.css +1032 -0
  8. package/canvas/tree.mjs +249 -0
  9. package/cordis.patch.yml +8 -0
  10. package/design.mjs +172 -0
  11. package/docs/dsh-workbench.png +0 -0
  12. package/dsh/client.js +864 -0
  13. package/dsh/index.js +354 -0
  14. package/dsh/scan.js +139 -0
  15. package/dsh/skill.js +41 -0
  16. package/lib/base-css.mjs +92 -0
  17. package/lib/detect.mjs +123 -0
  18. package/lib/diagnostics.mjs +382 -0
  19. package/lib/export-html.mjs +309 -0
  20. package/lib/export-svg.mjs +111 -0
  21. package/lib/fragment.mjs +702 -0
  22. package/lib/geometry.mjs +827 -0
  23. package/lib/icons.mjs +106 -0
  24. package/lib/levenshtein.mjs +133 -0
  25. package/lib/node/cli.mjs +387 -0
  26. package/lib/node/export-svg.mjs +75 -0
  27. package/lib/node/fs.mjs +309 -0
  28. package/lib/node/op-json.mjs +75 -0
  29. package/lib/node/raster-run.mjs +115 -0
  30. package/lib/node/server.mjs +1264 -0
  31. package/lib/node/static.mjs +206 -0
  32. package/lib/node/verbs/add.mjs +220 -0
  33. package/lib/node/verbs/export.mjs +161 -0
  34. package/lib/node/verbs/format.mjs +106 -0
  35. package/lib/node/verbs/import.mjs +126 -0
  36. package/lib/node/verbs/ls.mjs +98 -0
  37. package/lib/node/verbs/migrate.mjs +236 -0
  38. package/lib/node/verbs/move.mjs +76 -0
  39. package/lib/node/verbs/new.mjs +284 -0
  40. package/lib/node/verbs/remove.mjs +41 -0
  41. package/lib/node/verbs/rename.mjs +87 -0
  42. package/lib/node/verbs/serve.mjs +164 -0
  43. package/lib/node/verbs/set.mjs +150 -0
  44. package/lib/node/verbs/show.mjs +122 -0
  45. package/lib/node/verbs/tokens.mjs +167 -0
  46. package/lib/node/verbs/validate.mjs +130 -0
  47. package/lib/node/write-verbs.mjs +215 -0
  48. package/lib/normalize.mjs +288 -0
  49. package/lib/ops.mjs +2063 -0
  50. package/lib/parse.mjs +997 -0
  51. package/lib/raster.mjs +186 -0
  52. package/lib/readers/pen.mjs +1684 -0
  53. package/lib/receipt.mjs +203 -0
  54. package/lib/render.mjs +503 -0
  55. package/lib/serialize.mjs +441 -0
  56. package/lib/spans.mjs +177 -0
  57. package/lib/svg-path.mjs +483 -0
  58. package/lib/table.mjs +1047 -0
  59. package/lib/tokens.mjs +295 -0
  60. package/lib/validate.mjs +929 -0
  61. package/package.json +68 -6
  62. package/references/cookbook.md +156 -0
  63. package/references/images.md +171 -0
  64. package/references/product-flow.md +408 -0
  65. package/references/schema.md +90 -0
  66. package/references/styling.md +181 -0
  67. 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 = { "&": "&amp;", "<": "&lt;", ">": "&gt;" };
149
+ const ATTR_ESCAPES = { "&": "&amp;", '"': "&quot;", "<": "&lt;", ">": "&gt;" };
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
+ }