fresnel-icon 0.2.0 → 0.2.1
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/dist/cli.js +362 -154
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -18,8 +18,12 @@ var APPEARANCE_LABEL = {
|
|
|
18
18
|
function appearanceKeys(a) {
|
|
19
19
|
switch (a) {
|
|
20
20
|
case "dark":
|
|
21
|
-
case "clear-dark":
|
|
22
21
|
return ["dark"];
|
|
22
|
+
// Clear and Tinted (light and dark) are all rendered from Icon Composer's Mono, written as "tinted".
|
|
23
|
+
// Apple: "For mono, you can preview clear and tinted variants"; Assets.car keeps only light / dark /
|
|
24
|
+
// tintable renditions. A dark value does not carry over to Clear dark [推測: strong].
|
|
25
|
+
case "clear-light":
|
|
26
|
+
case "clear-dark":
|
|
23
27
|
case "tinted-light":
|
|
24
28
|
case "tinted-dark":
|
|
25
29
|
return ["tinted"];
|
|
@@ -135,6 +139,7 @@ function readProps(obj) {
|
|
|
135
139
|
function writeProps(props, order, structural = {}) {
|
|
136
140
|
const out = {};
|
|
137
141
|
const emitted = /* @__PURE__ */ new Set();
|
|
142
|
+
let fromFile = true;
|
|
138
143
|
const emit = (rawKey) => {
|
|
139
144
|
if (emitted.has(rawKey)) return;
|
|
140
145
|
if (rawKey in structural) {
|
|
@@ -142,17 +147,28 @@ function writeProps(props, order, structural = {}) {
|
|
|
142
147
|
emitted.add(rawKey);
|
|
143
148
|
return;
|
|
144
149
|
}
|
|
150
|
+
const literal = props[rawKey];
|
|
151
|
+
if (rawKey.endsWith(SPEC_SUFFIX) && literal?.plain) {
|
|
152
|
+
out[rawKey] = structuredClone(literal.plain.value);
|
|
153
|
+
emitted.add(rawKey);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
145
156
|
const isSpec = rawKey.endsWith(SPEC_SUFFIX);
|
|
146
157
|
const base = isSpec ? rawKey.slice(0, -SPEC_SUFFIX.length) : rawKey;
|
|
147
158
|
const p = props[base];
|
|
148
159
|
if (!p) return;
|
|
149
|
-
if (isSpec ? p.specs && p.specs.length > 0 : p.plain) {
|
|
160
|
+
if (isSpec ? p.specs && (p.specs.length > 0 || fromFile) : p.plain) {
|
|
150
161
|
out[rawKey] = structuredClone(isSpec ? p.specs : p.plain.value);
|
|
151
162
|
emitted.add(rawKey);
|
|
152
163
|
}
|
|
153
164
|
};
|
|
154
165
|
for (const k of order) emit(k);
|
|
166
|
+
fromFile = false;
|
|
155
167
|
for (const [base, p] of Object.entries(props)) {
|
|
168
|
+
if (base.endsWith(SPEC_SUFFIX) && p.plain) {
|
|
169
|
+
emit(base);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
156
172
|
if (p.plain) emit(base);
|
|
157
173
|
if (p.specs?.length) emit(base + SPEC_SUFFIX);
|
|
158
174
|
}
|
|
@@ -200,6 +216,11 @@ function baseValue(p) {
|
|
|
200
216
|
if (b) return b.value;
|
|
201
217
|
return p.plain?.value;
|
|
202
218
|
}
|
|
219
|
+
function variantValue(p, v) {
|
|
220
|
+
const vv = toVariant(v);
|
|
221
|
+
if (isEmpty(vv)) return baseValue(p);
|
|
222
|
+
return p?.specs?.find((e) => sameVariant(e, vv))?.value;
|
|
223
|
+
}
|
|
203
224
|
function variantsOf(p) {
|
|
204
225
|
return (p?.specs ?? []).filter((e) => !isBaseEntry(e)).map((e) => ({ ...e.appearance ? { appearance: e.appearance } : {}, ...e.idiom ? { idiom: e.idiom } : {} }));
|
|
205
226
|
}
|
|
@@ -392,6 +413,7 @@ function svgSize(svg) {
|
|
|
392
413
|
return { width: 1024, height: 1024 };
|
|
393
414
|
}
|
|
394
415
|
function pngSize(b) {
|
|
416
|
+
if (b.length < 24) return { width: 0, height: 0 };
|
|
395
417
|
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
396
418
|
return { width: dv.getUint32(16), height: dv.getUint32(20) };
|
|
397
419
|
}
|
|
@@ -403,6 +425,10 @@ function jpgSize(b) {
|
|
|
403
425
|
continue;
|
|
404
426
|
}
|
|
405
427
|
const marker = b[i + 1];
|
|
428
|
+
if (marker === 255) {
|
|
429
|
+
i++;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
406
432
|
const len = b[i + 2] << 8 | b[i + 3];
|
|
407
433
|
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
|
|
408
434
|
return { height: b[i + 5] << 8 | b[i + 6], width: b[i + 7] << 8 | b[i + 8] };
|
|
@@ -442,15 +468,26 @@ function expandInternalEntities(svg) {
|
|
|
442
468
|
if (v.length <= 2048 && !/[&<]/.test(v)) ents.set(m[1], v);
|
|
443
469
|
}
|
|
444
470
|
const body = svg.slice(0, doctype.index) + svg.slice(doctype.index + doctype[0].length);
|
|
445
|
-
|
|
471
|
+
let budget = Math.max(1 << 20, body.length * 4);
|
|
472
|
+
return body.replace(/&([\w.-]+);/g, (m, name) => {
|
|
473
|
+
const v = ents.get(name);
|
|
474
|
+
if (v === void 0 || v.length > budget) return m;
|
|
475
|
+
budget -= v.length;
|
|
476
|
+
return v;
|
|
477
|
+
});
|
|
446
478
|
}
|
|
479
|
+
var DATA_IMAGE = /^data:image\/(?:png|jpe?g|gif|webp|svg\+xml)(?:;[\w.+-]+=[\w.+-]+)*;base64,[a-z0-9+/=\s]*$/i;
|
|
447
480
|
function safeUrlRef(v) {
|
|
448
481
|
const t = v.trim().replace(/^['"]|['"]$/g, "").trim();
|
|
449
|
-
return t.startsWith("#") ||
|
|
482
|
+
return t.startsWith("#") || DATA_IMAGE.test(t);
|
|
450
483
|
}
|
|
484
|
+
function decodeXmlEntities(v) {
|
|
485
|
+
return v.replace(/&#x([0-9a-f]+);/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/&#(\d+);/g, (_m, d) => String.fromCodePoint(Number(d) % 1114112)).replace(/&(lt|gt|quot|apos|amp);/g, (_m, n2) => ({ lt: "<", gt: ">", quot: '"', apos: "'", amp: "&" })[n2]);
|
|
486
|
+
}
|
|
487
|
+
var escapeXmlText = (v) => v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
451
488
|
function cleanCss(css0) {
|
|
452
489
|
const css = css0.replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1");
|
|
453
|
-
return css.replace(/@import\b[^;]*;?/gi, "").replace(/url\s*\(([^)]*)\)/gi, (m, inner) => safeUrlRef(inner) ? m : "none").replace(/expression\s*\(/gi, "(").replace(/javascript\s*:/gi, "");
|
|
490
|
+
return css.replace(/@import\b[^;]*;?/gi, "").replace(/url\s*\(([^)]*)\)/gi, (m, inner) => safeUrlRef(inner) ? m : "none").replace(/(?:-webkit-)?image-set\s*\(|\bsrc\s*\(/gi, "(").replace(/expression\s*\(/gi, "(").replace(/javascript\s*:/gi, "");
|
|
454
491
|
}
|
|
455
492
|
function cleanAttrs(raw) {
|
|
456
493
|
const out = [];
|
|
@@ -471,8 +508,10 @@ function cleanAttrs(raw) {
|
|
|
471
508
|
if (/javascript:|vbscript:/.test(plain) || lname === "xml:base") continue;
|
|
472
509
|
let v = value;
|
|
473
510
|
if (lname === "style" || /url\(/.test(plain)) {
|
|
474
|
-
|
|
475
|
-
|
|
511
|
+
const css = cleanCss(decodeXmlEntities(v));
|
|
512
|
+
const refs = [...decodeForCheck(css).matchAll(/url\(([^)]*)\)/g)].map((m2) => m2[1]);
|
|
513
|
+
if (refs.some((r) => !safeUrlRef(r))) continue;
|
|
514
|
+
v = escapeXmlText(css);
|
|
476
515
|
}
|
|
477
516
|
out.push(`${name}="${v.replace(/"/g, """)}"`);
|
|
478
517
|
}
|
|
@@ -486,7 +525,7 @@ function sanitizeOnce(svg) {
|
|
|
486
525
|
while (i < svg.length) {
|
|
487
526
|
const lt = svg.indexOf("<", i);
|
|
488
527
|
const text2 = lt < 0 ? svg.slice(i) : svg.slice(i, lt);
|
|
489
|
-
if (!skip.length) out += inStyle ? cleanCss(text2) : text2;
|
|
528
|
+
if (!skip.length) out += inStyle ? escapeXmlText(cleanCss(decodeXmlEntities(text2))) : text2;
|
|
490
529
|
if (lt < 0) break;
|
|
491
530
|
if (svg.startsWith("<!--", lt)) {
|
|
492
531
|
const e = svg.indexOf("-->", lt + 4);
|
|
@@ -496,7 +535,7 @@ function sanitizeOnce(svg) {
|
|
|
496
535
|
if (svg.startsWith("<![CDATA[", lt)) {
|
|
497
536
|
const e = svg.indexOf("]]>", lt);
|
|
498
537
|
const body = svg.slice(lt + 9, e < 0 ? svg.length : e);
|
|
499
|
-
if (!skip.length) out += inStyle ?
|
|
538
|
+
if (!skip.length) out += inStyle ? escapeXmlText(cleanCss(body)) : `<![CDATA[${body}]]>`;
|
|
500
539
|
i = e < 0 ? svg.length : e + 3;
|
|
501
540
|
continue;
|
|
502
541
|
}
|
|
@@ -569,12 +608,12 @@ function sanitizeOnce(svg) {
|
|
|
569
608
|
}
|
|
570
609
|
function sanitizeSvg(svg) {
|
|
571
610
|
let s = expandInternalEntities(svg);
|
|
572
|
-
for (let n2 = 0; n2 <
|
|
611
|
+
for (let n2 = 0; n2 < 8; n2++) {
|
|
573
612
|
const next = sanitizeOnce(s);
|
|
574
|
-
if (next === s)
|
|
613
|
+
if (next === s) return s;
|
|
575
614
|
s = next;
|
|
576
615
|
}
|
|
577
|
-
return
|
|
616
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"/>';
|
|
578
617
|
}
|
|
579
618
|
function svgFacts(svg) {
|
|
580
619
|
const widths = [...svg.matchAll(/stroke-width\s*[:=]\s*["']?\s*([0-9.]+)/gi)].map((m) => Number(m[1])).filter((n2) => n2 > 0);
|
|
@@ -601,7 +640,15 @@ function assetText(a) {
|
|
|
601
640
|
function mimeOf(a) {
|
|
602
641
|
return a.kind === "svg" ? "image/svg+xml" : a.kind === "png" ? "image/png" : a.kind === "jpg" ? "image/jpeg" : "application/octet-stream";
|
|
603
642
|
}
|
|
643
|
+
var MAX_DECODE_PIXELS = 64e6;
|
|
604
644
|
function decodePng(b) {
|
|
645
|
+
try {
|
|
646
|
+
return decodePngUnchecked(b);
|
|
647
|
+
} catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function decodePngUnchecked(b) {
|
|
605
652
|
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
606
653
|
let off = 8;
|
|
607
654
|
let width = 0;
|
|
@@ -629,6 +676,7 @@ function decodePng(b) {
|
|
|
629
676
|
off += 12 + len;
|
|
630
677
|
}
|
|
631
678
|
if (depth !== 8 || interlace !== 0) return null;
|
|
679
|
+
if (!width || !height || width * height > MAX_DECODE_PIXELS) return null;
|
|
632
680
|
const ch = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[ctype];
|
|
633
681
|
if (!ch) return null;
|
|
634
682
|
const total = idat.reduce((a, x) => a + x.length, 0);
|
|
@@ -806,7 +854,7 @@ function isSafeBundlePath(rel) {
|
|
|
806
854
|
return rel.split("/").every((p) => p !== "" && p !== "." && p !== ".." && !p.startsWith(".") && p.length <= 255);
|
|
807
855
|
}
|
|
808
856
|
function readBundle(files) {
|
|
809
|
-
const jsonKey = Object.keys(files).
|
|
857
|
+
const jsonKey = Object.keys(files).filter((k) => k === "icon.json" || k.endsWith("/icon.json")).sort((a, b) => a.split("/").length - b.split("/").length || a.length - b.length)[0];
|
|
810
858
|
if (!jsonKey) throw new IconFormatError("icon.json not found in the bundle");
|
|
811
859
|
const prefix = jsonKey.slice(0, -"icon.json".length);
|
|
812
860
|
const base = parseIconJson(strFromU8(files[jsonKey]));
|
|
@@ -815,7 +863,7 @@ function readBundle(files) {
|
|
|
815
863
|
for (const [k, v] of Object.entries(files)) {
|
|
816
864
|
if (!k.startsWith(prefix) || k === jsonKey || k.endsWith("/")) continue;
|
|
817
865
|
const rel = k.slice(prefix.length);
|
|
818
|
-
if (!isSafeBundlePath(rel)) continue;
|
|
866
|
+
if (!isSafeBundlePath(rel) || rel.endsWith(".fresnel-tmp")) continue;
|
|
819
867
|
if (rel.startsWith("Assets/") && rel.split("/").length === 2) {
|
|
820
868
|
const name = rel.slice("Assets/".length);
|
|
821
869
|
if (name) assets[name] = makeAsset(name, v);
|
|
@@ -850,10 +898,12 @@ function syncFeatures(doc) {
|
|
|
850
898
|
}
|
|
851
899
|
function writeBundle(doc, extra = {}) {
|
|
852
900
|
const synced = { props: { ...doc.props }, groups: doc.groups };
|
|
901
|
+
if (synced.props.features) synced.props.features = structuredClone(synced.props.features);
|
|
853
902
|
syncFeatures(synced);
|
|
854
903
|
const files = { ...extra };
|
|
855
|
-
files["icon.json"] = strToU8(serializeIconJson({ ...doc, props: synced.props }));
|
|
856
904
|
for (const a of Object.values(doc.assets)) files[`Assets/${a.name}`] = a.bytes;
|
|
905
|
+
delete files["icon.json"];
|
|
906
|
+
files["icon.json"] = strToU8(serializeIconJson({ ...doc, props: synced.props }));
|
|
857
907
|
return files;
|
|
858
908
|
}
|
|
859
909
|
function unzipBundle(zip) {
|
|
@@ -902,8 +952,13 @@ function resolvePath(doc, path) {
|
|
|
902
952
|
const p = path.trim();
|
|
903
953
|
if (p === "" || p === "document" || p === "/") return { kind: "document" };
|
|
904
954
|
const whole = doc.groups.filter((g2) => nameOf(g2).toLowerCase() === p.toLowerCase());
|
|
905
|
-
if (whole.length === 1) return { kind: "group", id: whole[0].id };
|
|
906
955
|
const [gp, lp, ...rest] = p.split("/");
|
|
956
|
+
if (whole.length === 1) {
|
|
957
|
+
const g2 = lp !== void 0 && !rest.length ? doc.groups.find((x) => nameOf(x).toLowerCase() === gp.trim().toLowerCase()) : void 0;
|
|
958
|
+
if (g2?.layers.some((l2) => nameOf(l2).toLowerCase() === lp.trim().toLowerCase()))
|
|
959
|
+
throw new OpError(`"${path}" matches both a group and a layer; use #index (e.g. "#${doc.groups.indexOf(whole[0])}")`);
|
|
960
|
+
return { kind: "group", id: whole[0].id };
|
|
961
|
+
}
|
|
907
962
|
if (rest.length) throw new OpError(`Invalid target "${path}". Use "Group" or "Group/Layer".`);
|
|
908
963
|
const pick = (list, key, what) => {
|
|
909
964
|
const k = key.trim();
|
|
@@ -940,9 +995,10 @@ function cleanName(name) {
|
|
|
940
995
|
}
|
|
941
996
|
function uniqueName(existing, wanted0) {
|
|
942
997
|
const wanted = cleanName(wanted0) || "Untitled";
|
|
943
|
-
|
|
998
|
+
const taken = new Set(existing.map((n2) => n2.toLowerCase()));
|
|
999
|
+
if (!taken.has(wanted.toLowerCase())) return wanted;
|
|
944
1000
|
let i = 2;
|
|
945
|
-
while (
|
|
1001
|
+
while (taken.has(`${wanted} ${i}`.toLowerCase())) i++;
|
|
946
1002
|
return `${wanted} ${i}`;
|
|
947
1003
|
}
|
|
948
1004
|
function addGroup(doc, opts = {}) {
|
|
@@ -963,10 +1019,15 @@ function clampIndex(i, len) {
|
|
|
963
1019
|
function addAsset(doc, asset) {
|
|
964
1020
|
let name = asset.name.replace(/[\\/\u0000-\u001f:]/g, "_").replace(/^[.\s]+/, "").trim() || "image";
|
|
965
1021
|
const dot = name.lastIndexOf(".");
|
|
966
|
-
const
|
|
967
|
-
|
|
1022
|
+
const ext = dot > 0 ? name.slice(dot, dot + 16) : "";
|
|
1023
|
+
let stem = dot > 0 ? name.slice(0, dot) : name;
|
|
1024
|
+
while (new TextEncoder().encode(stem).length > 200) stem = stem.slice(0, -1);
|
|
1025
|
+
name = stem + ext;
|
|
1026
|
+
const clash = (n2) => Object.values(doc.assets).find((a) => a.name.toLowerCase() === n2.toLowerCase());
|
|
968
1027
|
let i = 2;
|
|
969
|
-
|
|
1028
|
+
for (let c = clash(name); c && !sameBytes(c.bytes, asset.bytes); c = clash(name)) name = `${stem} ${i++}${ext}`;
|
|
1029
|
+
const same = clash(name);
|
|
1030
|
+
if (same) return same.name;
|
|
970
1031
|
doc.assets[name] = { ...asset, name };
|
|
971
1032
|
return name;
|
|
972
1033
|
}
|
|
@@ -1018,31 +1079,40 @@ function normalizeFillColors(f) {
|
|
|
1018
1079
|
const o = { ...f };
|
|
1019
1080
|
if (typeof o.solid === "string") o.solid = normalizeColorInput(o.solid);
|
|
1020
1081
|
if (typeof o["automatic-gradient"] === "string") o["automatic-gradient"] = normalizeColorInput(o["automatic-gradient"]);
|
|
1021
|
-
if (Array.isArray(o["linear-gradient"]))
|
|
1082
|
+
if (Array.isArray(o["linear-gradient"])) {
|
|
1083
|
+
if (!o["linear-gradient"].every((c) => typeof c === "string")) throw new OpError("linear-gradient takes a list of color strings");
|
|
1084
|
+
o["linear-gradient"] = o["linear-gradient"].map(normalizeColorInput);
|
|
1085
|
+
}
|
|
1022
1086
|
return o;
|
|
1023
1087
|
}
|
|
1024
1088
|
function removeNode(doc, ref) {
|
|
1025
1089
|
if (ref.kind === "document") throw new OpError("The document can't be removed");
|
|
1090
|
+
let removedImages;
|
|
1026
1091
|
if (ref.kind === "group") {
|
|
1027
1092
|
const i = doc.groups.findIndex((g) => g.id === ref.id);
|
|
1028
1093
|
if (i < 0) throw new OpError("Group not found");
|
|
1094
|
+
removedImages = imagesOf(doc.groups[i].layers);
|
|
1029
1095
|
doc.groups.splice(i, 1);
|
|
1030
1096
|
} else {
|
|
1031
1097
|
const f = findLayer(doc, ref.id);
|
|
1032
1098
|
if (!f) throw new OpError("Layer not found");
|
|
1099
|
+
removedImages = imagesOf([f.layer]);
|
|
1033
1100
|
f.group.layers.splice(f.index, 1);
|
|
1034
1101
|
}
|
|
1035
|
-
pruneAssets(doc);
|
|
1102
|
+
pruneAssets(doc, removedImages);
|
|
1036
1103
|
}
|
|
1037
|
-
function
|
|
1104
|
+
function imagesOf(layers) {
|
|
1038
1105
|
const used = /* @__PURE__ */ new Set();
|
|
1039
|
-
for (const
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1106
|
+
for (const l of layers) {
|
|
1107
|
+
const p = l.props["image-name"];
|
|
1108
|
+
if (p?.plain && typeof p.plain.value === "string") used.add(p.plain.value);
|
|
1109
|
+
for (const s of p?.specs ?? []) if (typeof s.value === "string") used.add(s.value);
|
|
1110
|
+
}
|
|
1111
|
+
return used;
|
|
1112
|
+
}
|
|
1113
|
+
function pruneAssets(doc, only) {
|
|
1114
|
+
const used = imagesOf(doc.groups.flatMap((g) => g.layers));
|
|
1115
|
+
for (const k of Object.keys(doc.assets)) if (!used.has(k) && (!only || only.has(k))) delete doc.assets[k];
|
|
1046
1116
|
}
|
|
1047
1117
|
function moveNode(doc, ref, index, toGroupId) {
|
|
1048
1118
|
if (ref.kind === "document") throw new OpError("The document can't be moved");
|
|
@@ -1109,9 +1179,10 @@ function validate(doc) {
|
|
|
1109
1179
|
let anyTinted = false;
|
|
1110
1180
|
const scan = (props) => {
|
|
1111
1181
|
for (const p of Object.values(props)) {
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
1182
|
+
for (const v of variantsOf(p)) {
|
|
1183
|
+
if (v.appearance === "dark") anyDark = true;
|
|
1184
|
+
if (v.appearance === "tinted") anyTinted = true;
|
|
1185
|
+
}
|
|
1115
1186
|
}
|
|
1116
1187
|
};
|
|
1117
1188
|
scan(doc.props);
|
|
@@ -1142,19 +1213,22 @@ function validate(doc) {
|
|
|
1142
1213
|
}
|
|
1143
1214
|
if (Math.abs(a.width - a.height) > 0.5) {
|
|
1144
1215
|
push("V-03", `"${n2}" is ${round(a.width)}\xD7${round(a.height)}. Apple recommends square, unmasked layers.`, lName, lref);
|
|
1145
|
-
} else if (a.kind !== "svg" &&
|
|
1146
|
-
push("V-03", `"${n2}"
|
|
1216
|
+
} else if (a.kind !== "svg" && pos.scale > 1.01) {
|
|
1217
|
+
push("V-03", `"${n2}" (${a.width}\xD7${a.height} px) is scaled up ${round(pos.scale)}\xD7 and may look soft. Use a larger image or a vector.`, lName, lref);
|
|
1147
1218
|
}
|
|
1148
1219
|
if (a.kind === "png" && a.width === a.height && a.width >= 256) {
|
|
1149
1220
|
const bmp = safeDecode(a.bytes);
|
|
1150
1221
|
if (bmp && looksPreMasked(bmp)) push("V-04", `"${n2}" seems to have rounded corners baked in. The system masks icons itself.`, lName, lref);
|
|
1151
1222
|
}
|
|
1152
1223
|
if (a.kind === "svg") {
|
|
1153
|
-
const
|
|
1224
|
+
const text2 = assetText(a);
|
|
1225
|
+
const f = svgFacts(text2);
|
|
1226
|
+
const vb = /<svg\b[^>]*\sviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)/i.exec(text2);
|
|
1227
|
+
const unit2 = vb && Number(vb[1]) > 0 ? svgSize(text2).width / Number(vb[1]) : 1;
|
|
1154
1228
|
if (f.hasText) push("V-07", `"${n2}" contains <text>. Convert text to outlines.`, lName, lref);
|
|
1155
1229
|
if (f.hasFilter) push("V-07", `"${n2}" uses SVG filters (blur, glow, shadow). Let the system draw effects.`, lName, lref);
|
|
1156
|
-
if (f.minStrokeWidth !== null && f.minStrokeWidth * pos.scale < 10)
|
|
1157
|
-
push("V-06", `"${n2}" has strokes as thin as ${f.minStrokeWidth}. They'll be under 0.16 px at 16 px.`, lName, lref);
|
|
1230
|
+
if (f.minStrokeWidth !== null && f.minStrokeWidth * unit2 * pos.scale < 10)
|
|
1231
|
+
push("V-06", `"${n2}" has strokes as thin as ${round(f.minStrokeWidth * unit2)} pt. They'll be under 0.16 px at 16 px.`, lName, lref);
|
|
1158
1232
|
}
|
|
1159
1233
|
}
|
|
1160
1234
|
}
|
|
@@ -1370,10 +1444,24 @@ function moveBy(doc, ref, dx, dy, ctx, variant) {
|
|
|
1370
1444
|
props = f.layer.props;
|
|
1371
1445
|
scale = resolveProp(f.group.props.position, ctx, null)?.scale ?? 1;
|
|
1372
1446
|
}
|
|
1373
|
-
const p = resolveProp(props.position, ctx, { scale: 1, "translation-in-points": [0, 0] }) ?? { scale: 1, "translation-in-points": [0, 0] };
|
|
1374
|
-
const [x, y] = p["translation-in-points"] ?? [0, 0];
|
|
1375
1447
|
const round2 = (v) => Math.round(v * 100) / 100;
|
|
1376
|
-
|
|
1448
|
+
const shift = (p) => {
|
|
1449
|
+
const q = p ?? { scale: 1, "translation-in-points": [0, 0] };
|
|
1450
|
+
const [x, y] = q["translation-in-points"] ?? [0, 0];
|
|
1451
|
+
return { ...q, "translation-in-points": [round2(x + dx / scale), round2(y + dy / scale)] };
|
|
1452
|
+
};
|
|
1453
|
+
const vv = toVariant(variant);
|
|
1454
|
+
if (vv.appearance !== void 0 || vv.idiom !== void 0) {
|
|
1455
|
+
const cur = variantValue(props.position, vv) ?? resolveProp(props.position, ctx, null);
|
|
1456
|
+
setProp(doc, ref, "position", shift(cur), variant);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
const pos = props.position;
|
|
1460
|
+
setProp(doc, ref, "position", shift(baseValue(pos)), void 0);
|
|
1461
|
+
for (const e of pos?.specs ?? []) {
|
|
1462
|
+
if (e.appearance === void 0 && e.idiom === void 0) continue;
|
|
1463
|
+
if (e.value && typeof e.value === "object") e.value = shift(e.value);
|
|
1464
|
+
}
|
|
1377
1465
|
}
|
|
1378
1466
|
function alignNodes(doc, refs, how, ctx, opts = {}) {
|
|
1379
1467
|
const boxes = refs.map((r) => ({ ref: r, box: boxOf(doc, r, ctx) })).filter((x) => !!x.box);
|
|
@@ -1454,6 +1542,7 @@ async function saveIcon(path, doc) {
|
|
|
1454
1542
|
const assetsDir = join(root, "Assets");
|
|
1455
1543
|
await assertNoSymlink(root, assetsDir);
|
|
1456
1544
|
await mkdir(assetsDir, { recursive: true });
|
|
1545
|
+
const written = [];
|
|
1457
1546
|
for (const [rel, bytes] of Object.entries(files)) {
|
|
1458
1547
|
const target = resolve(root, ...rel.split("/"));
|
|
1459
1548
|
await assertNoSymlink(root, target);
|
|
@@ -1463,16 +1552,20 @@ async function saveIcon(path, doc) {
|
|
|
1463
1552
|
await rm(tmp, { force: true });
|
|
1464
1553
|
await writeFile(tmp, bytes, { flag: "wx" });
|
|
1465
1554
|
await rename(tmp, target);
|
|
1555
|
+
written.push(target);
|
|
1466
1556
|
}
|
|
1467
1557
|
const keep = new Set(Object.keys(doc.assets));
|
|
1468
1558
|
await assertNoSymlink(root, assetsDir);
|
|
1469
1559
|
for (const e of await readdir(assetsDir, { withFileTypes: true })) {
|
|
1470
|
-
if (e.isFile()
|
|
1560
|
+
if (!e.isFile() || keep.has(e.name)) continue;
|
|
1561
|
+
if (e.name.endsWith(".fresnel-tmp") || isSafeBundlePath(`Assets/${e.name}`) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
|
|
1471
1562
|
}
|
|
1563
|
+
return written;
|
|
1472
1564
|
}
|
|
1473
1565
|
async function writeFiles(dir, files) {
|
|
1474
1566
|
const written = [];
|
|
1475
1567
|
const root = resolve(dir);
|
|
1568
|
+
if ((await lstat(root).catch(() => null))?.isSymbolicLink()) throw new Error(`Refusing to write through a symbolic link: ${dir}`);
|
|
1476
1569
|
for (const [rel, bytes] of Object.entries(files)) {
|
|
1477
1570
|
const target = resolve(root, ...rel.split("/"));
|
|
1478
1571
|
const r = relative(root, target);
|
|
@@ -1523,7 +1616,7 @@ function contentBoxOf(asset) {
|
|
|
1523
1616
|
}
|
|
1524
1617
|
function svgAssetsNeedingBounds(doc) {
|
|
1525
1618
|
const out = /* @__PURE__ */ new Set();
|
|
1526
|
-
const hasGradient = (p) => [p?.plain?.value, ...(p?.specs ?? []).map((s) => s.value)].some((v) => !!v && typeof v === "object" && "linear-gradient" in v);
|
|
1619
|
+
const hasGradient = (p) => [p?.plain?.value, ...(p?.specs ?? []).map((s) => s.value)].some((v) => !!v && typeof v === "object" && ("linear-gradient" in v || "automatic-gradient" in v));
|
|
1527
1620
|
for (const g of doc.groups)
|
|
1528
1621
|
for (const l of g.layers) {
|
|
1529
1622
|
if (!hasGradient(l.props.fill)) continue;
|
|
@@ -1552,12 +1645,12 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
|
|
|
1552
1645
|
}
|
|
1553
1646
|
var SQUIRCLE = squirclePath();
|
|
1554
1647
|
var C = CANVAS;
|
|
1555
|
-
function maskPath(shape) {
|
|
1648
|
+
function maskPath(shape, pad2 = 0) {
|
|
1556
1649
|
if (shape === "circle") {
|
|
1557
1650
|
const r = C / 2 + 32;
|
|
1558
1651
|
return `M${C / 2},${C / 2 - r} A${r},${r} 0 1 1 ${C / 2},${C / 2 + r} A${r},${r} 0 1 1 ${C / 2},${C / 2 - r}Z`;
|
|
1559
1652
|
}
|
|
1560
|
-
if (shape === "none") return `
|
|
1653
|
+
if (shape === "none") return `M${-pad2},${-pad2} H${C + pad2} V${C + pad2} H${-pad2}Z`;
|
|
1561
1654
|
return SQUIRCLE;
|
|
1562
1655
|
}
|
|
1563
1656
|
|
|
@@ -1583,6 +1676,17 @@ function hexLuma(hex) {
|
|
|
1583
1676
|
const v = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
|
|
1584
1677
|
return luminance({ r: v(0), g: v(2), b: v(4), a: 1 });
|
|
1585
1678
|
}
|
|
1679
|
+
var NAMED = { white: 1, black: 0 };
|
|
1680
|
+
function paintLuma(v) {
|
|
1681
|
+
if (v.startsWith("#")) return hexLuma(v);
|
|
1682
|
+
const m = /^rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)/i.exec(v);
|
|
1683
|
+
if (m) {
|
|
1684
|
+
const ch = (x) => Math.min(1, Math.max(0, x.endsWith("%") ? Number(x.slice(0, -1)) / 100 : Number(x) / 255));
|
|
1685
|
+
return luminance({ r: ch(m[1]), g: ch(m[2]), b: ch(m[3]), a: 1 });
|
|
1686
|
+
}
|
|
1687
|
+
const named = NAMED[v.toLowerCase()];
|
|
1688
|
+
return named ?? null;
|
|
1689
|
+
}
|
|
1586
1690
|
function assetLuma(a) {
|
|
1587
1691
|
const hit = cache2.get(a.bytes);
|
|
1588
1692
|
if (hit !== void 0) return hit;
|
|
@@ -1603,10 +1707,11 @@ function assetLuma(a) {
|
|
|
1603
1707
|
}
|
|
1604
1708
|
} else if (a.kind === "svg") {
|
|
1605
1709
|
const text2 = new TextDecoder().decode(a.display ?? a.bytes);
|
|
1606
|
-
const
|
|
1607
|
-
const
|
|
1608
|
-
const
|
|
1609
|
-
if (
|
|
1710
|
+
const paint = /(?:fill|stop-color)\s*[:=]\s*["']?\s*(rgba?\([^)]*\)|[^"';>\s]+)/gi;
|
|
1711
|
+
const values = [...text2.matchAll(paint)].map((m) => m[1].trim()).filter((v) => !/^(none|transparent|inherit|currentcolor|url\()/i.test(v));
|
|
1712
|
+
const ls = values.map(paintLuma).filter((x) => x !== null);
|
|
1713
|
+
if (ls.length) l = ls.reduce((x, y) => x + y, 0) / ls.length;
|
|
1714
|
+
else l = values.length ? 0.5 : 0;
|
|
1610
1715
|
}
|
|
1611
1716
|
cache2.set(a.bytes, l);
|
|
1612
1717
|
return l;
|
|
@@ -1624,6 +1729,7 @@ function rasterRenderOptions(ro) {
|
|
|
1624
1729
|
};
|
|
1625
1730
|
}
|
|
1626
1731
|
var C2 = CANVAS;
|
|
1732
|
+
var PAD = C2 / 4;
|
|
1627
1733
|
var H2 = C2 / 2;
|
|
1628
1734
|
var b64Cache = /* @__PURE__ */ new WeakMap();
|
|
1629
1735
|
var b64 = (bytes) => {
|
|
@@ -1642,11 +1748,32 @@ var b64 = (bytes) => {
|
|
|
1642
1748
|
};
|
|
1643
1749
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
1644
1750
|
var n = (x) => +x.toFixed(3);
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1751
|
+
var DEFAULT_TINT = { r: 0.25, g: 0.65, b: 0.63, a: 1 };
|
|
1752
|
+
function tintToRGBA(input) {
|
|
1753
|
+
try {
|
|
1754
|
+
const c = parseColor(normalizeColorInput(input));
|
|
1755
|
+
return c ? { ...toRGBA(c), a: 1 } : DEFAULT_TINT;
|
|
1756
|
+
} catch {
|
|
1757
|
+
return DEFAULT_TINT;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
var num2 = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
1761
|
+
function safePosition(p) {
|
|
1762
|
+
if (!p || typeof p !== "object") return null;
|
|
1763
|
+
const o = p;
|
|
1764
|
+
const t = Array.isArray(o["translation-in-points"]) ? o["translation-in-points"] : [];
|
|
1765
|
+
const scale = num2(o.scale, 1);
|
|
1766
|
+
return { scale: scale > 0 ? scale : 1, tx: num2(t[0], 0), ty: num2(t[1], 0) };
|
|
1767
|
+
}
|
|
1768
|
+
function safeOrientation(o) {
|
|
1769
|
+
const pt = (v) => {
|
|
1770
|
+
const q = v;
|
|
1771
|
+
return q && typeof q === "object" && Number.isFinite(q.x) && Number.isFinite(q.y) ? { x: q.x, y: q.y } : null;
|
|
1772
|
+
};
|
|
1773
|
+
const r = o;
|
|
1774
|
+
const start = r && typeof r === "object" ? pt(r.start) : null;
|
|
1775
|
+
const stop = r && typeof r === "object" ? pt(r.stop) : null;
|
|
1776
|
+
return start && stop ? { start, stop } : void 0;
|
|
1650
1777
|
}
|
|
1651
1778
|
var colorOf = (s) => {
|
|
1652
1779
|
const c = typeof s === "string" ? parseColor(s) : null;
|
|
@@ -1693,10 +1820,10 @@ function paintFor(fill, defs, box, darkSystem) {
|
|
|
1693
1820
|
const c = colorOf(f["automatic-gradient"]);
|
|
1694
1821
|
return lin(shade(c, 0.22), shade(c, -0.08));
|
|
1695
1822
|
}
|
|
1696
|
-
if (Array.isArray(f["linear-gradient"])) {
|
|
1823
|
+
if (Array.isArray(f["linear-gradient"]) && f["linear-gradient"].length) {
|
|
1697
1824
|
const [a, b] = f["linear-gradient"];
|
|
1698
|
-
const o = f.orientation;
|
|
1699
|
-
return lin(colorOf(a), colorOf(b), o?.start, o?.stop);
|
|
1825
|
+
const o = safeOrientation(f.orientation);
|
|
1826
|
+
return lin(colorOf(a), colorOf(b ?? a), o?.start, o?.stop);
|
|
1700
1827
|
}
|
|
1701
1828
|
return null;
|
|
1702
1829
|
}
|
|
@@ -1709,22 +1836,25 @@ function renderSvg(doc, opts = {}) {
|
|
|
1709
1836
|
const dark = isDarkAppearance(appearance);
|
|
1710
1837
|
const isClear = appearance === "clear-light" || appearance === "clear-dark";
|
|
1711
1838
|
const isTinted = appearance === "tinted-light" || appearance === "tinted-dark";
|
|
1712
|
-
const tint =
|
|
1839
|
+
const tint = opts.tint ? tintToRGBA(opts.tint) : DEFAULT_TINT;
|
|
1840
|
+
const idioms = idiomsFor(ctx.idiom);
|
|
1841
|
+
const forPlatform = (s) => s.idiom === void 0 || idioms.includes(s.idiom);
|
|
1713
1842
|
const angle = (opts.lightAngle ?? 0) * Math.PI / 180;
|
|
1714
1843
|
const toLight = { x: Math.sin(angle), y: -Math.cos(angle) };
|
|
1715
1844
|
const defs = new Defs(opts.idPrefix ?? "");
|
|
1716
1845
|
const body = [];
|
|
1717
1846
|
const fillProp = doc.props.fill;
|
|
1718
1847
|
let bgFill = resolveProp(fillProp, ctx, "automatic");
|
|
1719
|
-
const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark");
|
|
1848
|
+
const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark" && forPlatform(s));
|
|
1720
1849
|
let darken = 0;
|
|
1721
1850
|
if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
|
|
1722
1851
|
let bgPaint;
|
|
1723
|
-
const P = mask === "circle" ? 32 : 0;
|
|
1852
|
+
const P = mask === "circle" || ctx.idiom === "watchOS" ? 32 : 0;
|
|
1724
1853
|
const full = { x: -P, y: -P, w: C2 + 2 * P, h: C2 + 2 * P };
|
|
1725
|
-
const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted"
|
|
1854
|
+
const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" && forPlatform(s));
|
|
1726
1855
|
if (monoOverride) {
|
|
1727
|
-
|
|
1856
|
+
const mono = resolveProp(fillProp, { appearance: dark ? "tinted-dark" : "tinted-light", idiom: ctx.idiom }, "automatic");
|
|
1857
|
+
bgPaint = paintFor(mono ?? "automatic", defs, full, dark);
|
|
1728
1858
|
} else if (isClear) {
|
|
1729
1859
|
bgPaint = dark ? paintFor({ "linear-gradient": ["srgb:0.24,0.25,0.28,0.92", "srgb:0.11,0.12,0.13,0.92"] }, defs, full, true) : paintFor({ "linear-gradient": ["srgb:0.97,0.98,0.99,0.86", "srgb:0.84,0.87,0.91,0.86"] }, defs, full, false);
|
|
1730
1860
|
} else if (isTinted) {
|
|
@@ -1765,7 +1895,10 @@ function renderSvg(doc, opts = {}) {
|
|
|
1765
1895
|
plainId = plain;
|
|
1766
1896
|
}
|
|
1767
1897
|
function drawGroup(g, backdropId) {
|
|
1768
|
-
const
|
|
1898
|
+
const gp = safePosition(resolveProp(g.props.position, ctx, null));
|
|
1899
|
+
const gs = gp?.scale ?? 1;
|
|
1900
|
+
const gtx = gp?.tx ?? 0;
|
|
1901
|
+
const gty = gp?.ty ?? 0;
|
|
1769
1902
|
const layers = FRONT_FIRST ? [...g.layers].reverse() : [...g.layers];
|
|
1770
1903
|
const placed = [];
|
|
1771
1904
|
for (const l of layers) {
|
|
@@ -1773,14 +1906,23 @@ function renderSvg(doc, opts = {}) {
|
|
|
1773
1906
|
const name = resolveProp(l.props["image-name"], ctx, "");
|
|
1774
1907
|
const asset = doc.assets[name];
|
|
1775
1908
|
if (!asset || asset.kind === "pdf" || asset.kind === "other") continue;
|
|
1776
|
-
|
|
1909
|
+
if (!(asset.width > 0 && asset.height > 0 && Number.isFinite(asset.width) && Number.isFinite(asset.height))) continue;
|
|
1910
|
+
const pos = safePosition(resolveProp(l.props.position, ctx, null));
|
|
1777
1911
|
const s = pos?.scale ?? 1;
|
|
1778
|
-
const [tx, ty] = pos?.["translation-in-points"] ?? [0, 0];
|
|
1779
1912
|
const w = asset.width * s;
|
|
1780
1913
|
const h = asset.height * s;
|
|
1781
|
-
|
|
1914
|
+
const x = H2 + (pos?.tx ?? 0) - w / 2;
|
|
1915
|
+
const y = H2 + (pos?.ty ?? 0) - h / 2;
|
|
1916
|
+
const cx0 = H2 + gtx + (x - H2) * gs;
|
|
1917
|
+
const cy0 = H2 + gty + (y - H2) * gs;
|
|
1918
|
+
const cx1 = cx0 + w * gs;
|
|
1919
|
+
const cy1 = cy0 + h * gs;
|
|
1920
|
+
if (cx1 < -P || cy1 < -P || cx0 > C2 + P || cy0 > C2 + P) continue;
|
|
1921
|
+
placed.push({ layer: l, asset, x, y, w, h });
|
|
1782
1922
|
}
|
|
1783
1923
|
if (!placed.length) return null;
|
|
1924
|
+
const groupOpacity = Math.min(1, Math.max(0, num2(resolveProp(g.props.opacity, ctx, 1), 1)));
|
|
1925
|
+
if (groupOpacity <= 0) return null;
|
|
1784
1926
|
const imgId = /* @__PURE__ */ new Map();
|
|
1785
1927
|
const layerEls = [];
|
|
1786
1928
|
const glassEls = [];
|
|
@@ -1797,7 +1939,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1797
1939
|
const s = p.w / p.asset.width;
|
|
1798
1940
|
const place = `transform="translate(${n(p.x)} ${n(p.y)}) scale(${n(s)})"`;
|
|
1799
1941
|
const fill = resolveProp(p.layer.props.fill, ctx, null);
|
|
1800
|
-
const opacity = resolveProp(p.layer.props.opacity, ctx, 1);
|
|
1942
|
+
const opacity = Math.min(1, Math.max(0, num2(resolveProp(p.layer.props.opacity, ctx, 1), 1)));
|
|
1801
1943
|
const blend = cssBlend(resolveProp(p.layer.props["blend-mode"], ctx, "normal"));
|
|
1802
1944
|
const cb = contentBoxOf(p.asset);
|
|
1803
1945
|
const fbox = cb ? { x: p.x + cb.x * p.w, y: p.y + cb.y * p.h, w: cb.w * p.w, h: cb.h * p.h } : { x: p.x, y: p.y, w: p.w, h: p.h };
|
|
@@ -1805,7 +1947,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1805
1947
|
let el;
|
|
1806
1948
|
if (paint) {
|
|
1807
1949
|
const mid = defs.id("lm");
|
|
1808
|
-
defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="
|
|
1950
|
+
defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}"><use href="#${iid}" ${place}/></mask>`);
|
|
1809
1951
|
el = `<rect x="${n(p.x)}" y="${n(p.y)}" width="${n(p.w)}" height="${n(p.h)}" fill="${paint}" mask="url(#${mid})"/>`;
|
|
1810
1952
|
} else {
|
|
1811
1953
|
el = `<use href="#${iid}" ${place}/>`;
|
|
@@ -1817,43 +1959,55 @@ function renderSvg(doc, opts = {}) {
|
|
|
1817
1959
|
glassLumas.push(fill && fill !== "automatic" ? fillLuma(fill, dark) ?? assetLuma(p.asset) : assetLuma(p.asset));
|
|
1818
1960
|
}
|
|
1819
1961
|
}
|
|
1820
|
-
const gTransform =
|
|
1962
|
+
const gTransform = gp ? ` transform="translate(${n(H2 + gtx)} ${n(H2 + gty)}) scale(${n(gs)}) translate(${-H2} ${-H2})"` : "";
|
|
1963
|
+
const canvasRegion = `filterUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}"`;
|
|
1964
|
+
const gx0 = H2 + (-PAD - H2 - gtx) / gs;
|
|
1965
|
+
const gy0 = H2 + (-PAD - H2 - gty) / gs;
|
|
1966
|
+
const groupRegion = `filterUnits="userSpaceOnUse" x="${n(gx0)}" y="${n(gy0)}" width="${n((C2 + 2 * PAD) / gs)}" height="${n((C2 + 2 * PAD) / gs)}"`;
|
|
1821
1967
|
const contentId = defs.id("gc");
|
|
1822
1968
|
defs.add(`<g id="${contentId}"${gTransform}>${layerEls.join("")}</g>`);
|
|
1823
1969
|
contentIds.push(contentId);
|
|
1824
1970
|
const out = [];
|
|
1825
|
-
const
|
|
1971
|
+
const shadow0 = resolveProp(g.props.shadow, ctx, { kind: "neutral", opacity: 0.5 });
|
|
1972
|
+
const shadow = shadow0 && typeof shadow0 === "object" ? { kind: shadow0.kind, opacity: Math.min(1, Math.max(0, num2(shadow0.opacity, 0.5))) } : null;
|
|
1826
1973
|
if (effects && shadow && shadow.kind !== "none" && shadow.opacity > 0) {
|
|
1827
1974
|
const fid = defs.id("sh");
|
|
1828
1975
|
if (shadow.kind === "neutral") {
|
|
1829
1976
|
defs.add(
|
|
1830
|
-
`<filter id="${fid}"
|
|
1977
|
+
`<filter id="${fid}" ${canvasRegion}><feGaussianBlur in="SourceAlpha" stdDeviation="16"/><feOffset dy="14" result="b"/><feFlood flood-color="#000" flood-opacity="0.45"/><feComposite in2="b" operator="in"/></filter>`
|
|
1831
1978
|
);
|
|
1832
1979
|
} else {
|
|
1833
1980
|
defs.add(
|
|
1834
|
-
`<filter id="${fid}"
|
|
1981
|
+
`<filter id="${fid}" ${canvasRegion}><feGaussianBlur in="SourceGraphic" stdDeviation="18"/><feOffset dy="16"/><feColorMatrix type="matrix" values="0.75 0 0 0 0 0 0.75 0 0 0 0 0 0.75 0 0 0 0 0 0.8 0"/></filter>`
|
|
1835
1982
|
);
|
|
1836
1983
|
}
|
|
1837
1984
|
out.push(`<use href="#${contentId}" filter="url(#${fid})" opacity="${n(shadow.opacity)}"/>`);
|
|
1838
1985
|
}
|
|
1839
|
-
const
|
|
1840
|
-
const
|
|
1986
|
+
const tr0 = resolveProp(g.props.translucency, ctx, { enabled: true, value: 0.5 });
|
|
1987
|
+
const tr = tr0 && typeof tr0 === "object" ? { enabled: !!tr0.enabled, value: num2(tr0.value, 0.5) } : null;
|
|
1988
|
+
const blurRaw = resolveProp(g.props["blur-material"], ctx, void 0);
|
|
1989
|
+
const blurMat = blurRaw === null ? null : num2(blurRaw, 0.2);
|
|
1841
1990
|
const translucent = effects && !!tr?.enabled && tr.value > 0;
|
|
1842
|
-
const
|
|
1991
|
+
const refr0 = resolveProp(g.props.refractivity, ctx, null);
|
|
1992
|
+
const refr = refr0 && typeof refr0 === "object" ? { enabled: !!refr0.enabled, strength: num2(refr0.strength, 0), depth: num2(refr0.depth, 0) } : null;
|
|
1843
1993
|
const refracts = effects && !!refr?.enabled && glassEls.length > 0;
|
|
1844
1994
|
if (translucent || refracts) {
|
|
1845
1995
|
const maskSrc = translucent ? `<use href="#${contentId}"/>` : `<g${gTransform}>${glassEls.join("")}</g>`;
|
|
1846
1996
|
const mid = defs.id("gm");
|
|
1847
|
-
defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1997
|
+
defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}">${maskSrc}</mask>`);
|
|
1998
|
+
const blurPx = Math.max(0, (blurMat === null ? 0 : 4 + blurMat * 40) + (refracts ? refr.depth * 10 : 0));
|
|
1999
|
+
let filterAttr = "";
|
|
2000
|
+
if (blurPx > 0) {
|
|
2001
|
+
const fid = defs.id("bk");
|
|
2002
|
+
defs.add(`<filter id="${fid}" ${canvasRegion}><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
|
|
2003
|
+
filterAttr = ` filter="url(#${fid})"`;
|
|
2004
|
+
}
|
|
2005
|
+
const zoom = 1.02 + (refracts ? refr.strength * 0.12 : 0.015);
|
|
1852
2006
|
out.push(
|
|
1853
|
-
`<g mask="url(#${mid})"><use href="#${backdropId}"
|
|
2007
|
+
`<g mask="url(#${mid})"><use href="#${backdropId}"${filterAttr} transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
|
|
1854
2008
|
);
|
|
1855
2009
|
}
|
|
1856
|
-
const gOpacity =
|
|
2010
|
+
const gOpacity = refracts && !translucent ? 1 - Math.min(1, Math.max(0, refr.strength)) * 0.3 : 1;
|
|
1857
2011
|
let fadeMask = "";
|
|
1858
2012
|
if (translucent) {
|
|
1859
2013
|
const v = Math.min(1, Math.max(0, tr.value));
|
|
@@ -1879,15 +2033,15 @@ function renderSvg(doc, opts = {}) {
|
|
|
1879
2033
|
const fid = defs.id("sp");
|
|
1880
2034
|
if (specular === "outside") {
|
|
1881
2035
|
defs.add(
|
|
1882
|
-
`<filter id="${fid}"
|
|
2036
|
+
`<filter id="${fid}" ${groupRegion}><feMorphology in="SourceAlpha" operator="dilate" radius="6" result="d"/><feComposite in="d" in2="SourceAlpha" operator="out" result="band"/><feOffset in="SourceAlpha" dx="${n(-dx * 1.6)}" dy="${n(-dy * 1.6)}" result="lit"/><feComposite in="band" in2="lit" operator="in" result="edge"/><feGaussianBlur in="edge" stdDeviation="1.6" result="soft"/><feFlood flood-color="#fff" flood-opacity="0.8"/><feComposite in2="soft" operator="in"/></filter>`
|
|
1883
2037
|
);
|
|
1884
2038
|
} else defs.add(
|
|
1885
|
-
`<filter id="${fid}"
|
|
2039
|
+
`<filter id="${fid}" ${groupRegion}><feOffset in="SourceAlpha" dx="${dx}" dy="${dy}" result="sh"/><feComposite in="SourceAlpha" in2="sh" operator="out" result="edge"/><feGaussianBlur in="edge" stdDeviation="1.6" result="soft"/><feFlood flood-color="#fff" flood-opacity="0.85"/><feComposite in2="soft" operator="in" result="hi"/><feOffset in="SourceAlpha" dx="${n(-dx)}" dy="${n(-dy)}" result="sh2"/><feComposite in="SourceAlpha" in2="sh2" operator="out" result="edge2"/><feGaussianBlur in="edge2" stdDeviation="2" result="soft2"/><feFlood flood-color="#fff" flood-opacity="0.28"/><feComposite in2="soft2" operator="in" result="lo"/><feMerge><feMergeNode in="hi"/><feMergeNode in="lo"/></feMerge></filter>`
|
|
1886
2040
|
);
|
|
1887
2041
|
const glass = individual ? glassEls.map((e) => `<g filter="url(#${fid})">${e}</g>`).join("") : `<g filter="url(#${fid})">${glassEls.join("")}</g>`;
|
|
1888
2042
|
out.push(`<g${gTransform}>${glass}</g>`);
|
|
1889
2043
|
}
|
|
1890
|
-
return out.join("");
|
|
2044
|
+
return groupOpacity < 1 ? `<g opacity="${n(groupOpacity)}">${out.join("")}</g>` : out.join("");
|
|
1891
2045
|
}
|
|
1892
2046
|
let top = `<use href="#${stackId}"/>`;
|
|
1893
2047
|
if ((isClear || isTinted) && contentIds.length) {
|
|
@@ -1909,18 +2063,19 @@ function renderSvg(doc, opts = {}) {
|
|
|
1909
2063
|
}
|
|
1910
2064
|
const mid = defs.id("fm");
|
|
1911
2065
|
defs.add(
|
|
1912
|
-
`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="
|
|
2066
|
+
`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}">` + contentIds.map((id) => `<use href="#${id}"/>`).join("") + `</mask>`
|
|
1913
2067
|
);
|
|
1914
2068
|
top = `<use href="#${bgStackId}"/><g filter="url(#${fid})"><use href="#${stackId}" mask="url(#${mid})"/></g>`;
|
|
1915
2069
|
}
|
|
1916
2070
|
const clipId = defs.id("clip");
|
|
1917
|
-
const shape = maskPath(mask);
|
|
2071
|
+
const shape = maskPath(mask, P);
|
|
1918
2072
|
defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
|
|
1919
|
-
const
|
|
2073
|
+
const inset = Math.min(C2 / 2 - 1, Math.max(0, num2(opts.inset, 0)));
|
|
2074
|
+
const M = inset > 0 ? inset * C2 / (C2 - 2 * inset) : 0;
|
|
1920
2075
|
if (M > 0) {
|
|
1921
2076
|
const sid = defs.id("ish");
|
|
1922
2077
|
defs.add(
|
|
1923
|
-
`<filter id="${sid}" x="-
|
|
2078
|
+
`<filter id="${sid}" ${'filterUnits="userSpaceOnUse"'} x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}"><feGaussianBlur in="SourceAlpha" stdDeviation="${n(C2 * 0.012)}"/><feOffset dy="${n(C2 * 0.012)}" result="b"/><feFlood flood-color="#000" flood-opacity="0.3"/><feComposite in2="b" operator="in" result="s"/><feMerge><feMergeNode in="s"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
|
|
1924
2079
|
);
|
|
1925
2080
|
body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
|
|
1926
2081
|
} else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
|
|
@@ -1935,8 +2090,9 @@ function renderSvg(doc, opts = {}) {
|
|
|
1935
2090
|
}
|
|
1936
2091
|
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${size}" height="${size}" viewBox="${-(P + M)} ${-(P + M)} ${C2 + 2 * (P + M)} ${C2 + 2 * (P + M)}"><title>${esc(appearance)}</title><defs>${defs.toString()}</defs>${body.join("")}</svg>`;
|
|
1937
2092
|
}
|
|
2093
|
+
var KNOWN_BLEND = new Set(BLEND_MODES);
|
|
1938
2094
|
function cssBlend(mode) {
|
|
1939
|
-
if (!mode || mode === "normal") return null;
|
|
2095
|
+
if (typeof mode !== "string" || !KNOWN_BLEND.has(mode) || mode === "normal") return null;
|
|
1940
2096
|
if (mode === "plus-lighter") return "screen";
|
|
1941
2097
|
if (mode === "plus-darker") return "multiply";
|
|
1942
2098
|
return mode;
|
|
@@ -1979,6 +2135,7 @@ async function resvg() {
|
|
|
1979
2135
|
}
|
|
1980
2136
|
}
|
|
1981
2137
|
async function svgToPng(svg, width) {
|
|
2138
|
+
if (!(width >= 2)) throw new Error(`Image size must be at least 2 px (got ${width})`);
|
|
1982
2139
|
const { Resvg } = await resvg();
|
|
1983
2140
|
const r = new Resvg(svg, { fitTo: { mode: "width", value: Math.round(width) }, font: { loadSystemFonts: true } });
|
|
1984
2141
|
return new Uint8Array(r.render().asPng());
|
|
@@ -2107,7 +2264,7 @@ function convert(k, raw, flag) {
|
|
|
2107
2264
|
switch (k.type) {
|
|
2108
2265
|
case "number": {
|
|
2109
2266
|
const n2 = Number(raw);
|
|
2110
|
-
if (
|
|
2267
|
+
if (!/^\s*[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?\s*$/i.test(raw) || Number.isNaN(n2)) throw new Error(`${flag} expects a number, got "${raw}"`);
|
|
2111
2268
|
return n2;
|
|
2112
2269
|
}
|
|
2113
2270
|
case "array":
|
|
@@ -2122,59 +2279,89 @@ function convert(k, raw, flag) {
|
|
|
2122
2279
|
return raw;
|
|
2123
2280
|
}
|
|
2124
2281
|
}
|
|
2282
|
+
var BOOL_TRUE = /^(true|1|yes|on)$/i;
|
|
2283
|
+
var BOOL_FALSE = /^(false|0|no|off)$/i;
|
|
2125
2284
|
function parseToolArgv(t, argv) {
|
|
2126
2285
|
const infos = argsOf(t);
|
|
2127
2286
|
const byFlag = new Map(infos.map((a) => [a.flag, a]));
|
|
2128
2287
|
const out = {};
|
|
2129
2288
|
let base = {};
|
|
2289
|
+
const roots = [];
|
|
2290
|
+
let help = false;
|
|
2130
2291
|
const positionals = [];
|
|
2292
|
+
let optionsDone = false;
|
|
2131
2293
|
for (let i = 0; i < argv.length; i++) {
|
|
2132
2294
|
const tok = argv[i];
|
|
2133
|
-
if (
|
|
2295
|
+
if (optionsDone || tok === "-" || !tok.startsWith("-")) {
|
|
2134
2296
|
positionals.push(tok);
|
|
2135
2297
|
continue;
|
|
2136
2298
|
}
|
|
2299
|
+
if (tok === "--") {
|
|
2300
|
+
optionsDone = true;
|
|
2301
|
+
continue;
|
|
2302
|
+
}
|
|
2303
|
+
if (tok === "--help" || tok === "-h") {
|
|
2304
|
+
help = true;
|
|
2305
|
+
continue;
|
|
2306
|
+
}
|
|
2307
|
+
if (tok === "-r") {
|
|
2308
|
+
const v2 = argv[++i];
|
|
2309
|
+
if (v2 === void 0) throw new Error("-r needs a directory");
|
|
2310
|
+
roots.push(v2);
|
|
2311
|
+
continue;
|
|
2312
|
+
}
|
|
2313
|
+
if (!tok.startsWith("--")) throw new Error(`Unknown option ${tok}. Run "fresnel ${cliCommand(t)} --help".`);
|
|
2137
2314
|
const eq = tok.indexOf("=");
|
|
2138
2315
|
const flag = eq > 0 ? tok.slice(0, eq) : tok;
|
|
2139
2316
|
const inline = eq > 0 ? tok.slice(eq + 1) : void 0;
|
|
2317
|
+
const value = () => {
|
|
2318
|
+
const raw = inline ?? argv[++i];
|
|
2319
|
+
if (raw === void 0) throw new Error(`${flag} needs a value`);
|
|
2320
|
+
return raw;
|
|
2321
|
+
};
|
|
2322
|
+
if (flag === "--root") {
|
|
2323
|
+
roots.push(value());
|
|
2324
|
+
continue;
|
|
2325
|
+
}
|
|
2140
2326
|
if (flag === "--args") {
|
|
2141
|
-
const
|
|
2142
|
-
if (
|
|
2143
|
-
|
|
2144
|
-
if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error("--args must be a JSON object");
|
|
2145
|
-
base = v;
|
|
2327
|
+
const v2 = convert({ type: "json" }, value(), "--args");
|
|
2328
|
+
if (!v2 || typeof v2 !== "object" || Array.isArray(v2)) throw new Error("--args must be a JSON object");
|
|
2329
|
+
base = { ...base, ...v2 };
|
|
2146
2330
|
continue;
|
|
2147
2331
|
}
|
|
2148
2332
|
const neg = flag.startsWith("--no-") ? byFlag.get(`--${flag.slice(5)}`) : void 0;
|
|
2149
|
-
if (neg && neg.kind.type === "boolean") {
|
|
2333
|
+
if (neg && neg.kind.type === "boolean" && inline === void 0) {
|
|
2150
2334
|
out[neg.name] = false;
|
|
2151
2335
|
continue;
|
|
2152
2336
|
}
|
|
2153
2337
|
const info = byFlag.get(flag);
|
|
2154
2338
|
if (!info) throw new Error(`Unknown option ${flag} for ${t.id}. Run "fresnel ${cliCommand(t)} --help".`);
|
|
2155
2339
|
if (info.kind.type === "boolean") {
|
|
2156
|
-
|
|
2340
|
+
if (inline === void 0) out[info.name] = true;
|
|
2341
|
+
else if (BOOL_TRUE.test(inline)) out[info.name] = true;
|
|
2342
|
+
else if (BOOL_FALSE.test(inline)) out[info.name] = false;
|
|
2343
|
+
else throw new Error(`${flag} expects true or false, got "${inline}"`);
|
|
2157
2344
|
continue;
|
|
2158
2345
|
}
|
|
2159
|
-
const
|
|
2160
|
-
|
|
2161
|
-
out[info.name] = convert(info.kind, raw, flag);
|
|
2346
|
+
const v = convert(info.kind, value(), flag);
|
|
2347
|
+
out[info.name] = info.kind.type === "array" && Array.isArray(out[info.name]) ? [...out[info.name], ...v] : v;
|
|
2162
2348
|
}
|
|
2163
2349
|
const pos = t.positional ?? [];
|
|
2164
2350
|
if (positionals.length > pos.length) throw new Error(`Unexpected argument "${positionals[pos.length]}". Run "fresnel ${cliCommand(t)} --help".`);
|
|
2165
2351
|
positionals.forEach((v, i) => {
|
|
2166
2352
|
const info = infos.find((a) => a.name === pos[i]);
|
|
2167
|
-
if (out[info.name]
|
|
2353
|
+
if (out[info.name] !== void 0) throw new Error(`<${info.name}> was given twice (as an argument and as ${info.flag})`);
|
|
2354
|
+
out[info.name] = convert(info.kind, v, `<${info.name}>`);
|
|
2168
2355
|
});
|
|
2169
|
-
return { ...base, ...out };
|
|
2356
|
+
return { args: { ...base, ...out }, roots, help };
|
|
2170
2357
|
}
|
|
2171
2358
|
|
|
2172
2359
|
// ../mcp/src/contract.ts
|
|
2173
2360
|
import { z as z3 } from "zod";
|
|
2174
2361
|
|
|
2175
2362
|
// ../mcp/src/spec.ts
|
|
2176
|
-
import {
|
|
2177
|
-
import { basename, dirname as dirname2, join as join2 } from "path";
|
|
2363
|
+
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
2364
|
+
import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join2, relative as relative2, sep as sep2 } from "path";
|
|
2178
2365
|
import { z as z2 } from "zod";
|
|
2179
2366
|
var CONTRACT_VERSION = "1.0";
|
|
2180
2367
|
var tool = (def) => def;
|
|
@@ -2189,6 +2376,7 @@ var groupsOutline = z2.array(z2.looseObject({ group: z2.string(), layers: z2.arr
|
|
|
2189
2376
|
function describeProps(props) {
|
|
2190
2377
|
const base = {};
|
|
2191
2378
|
const overrides = {};
|
|
2379
|
+
const variants = /* @__PURE__ */ new Map();
|
|
2192
2380
|
for (const [k, p] of Object.entries(props)) {
|
|
2193
2381
|
const b = p.specs?.find((s) => s.appearance === void 0 && s.idiom === void 0);
|
|
2194
2382
|
if (b) base[k] = b.value;
|
|
@@ -2197,21 +2385,32 @@ function describeProps(props) {
|
|
|
2197
2385
|
const s = p.specs.find((e) => e.appearance === a && e.idiom === void 0);
|
|
2198
2386
|
(overrides[a] ??= {})[k] = s.value;
|
|
2199
2387
|
}
|
|
2388
|
+
for (const s of p.specs ?? []) {
|
|
2389
|
+
if (s.idiom === void 0) continue;
|
|
2390
|
+
const key = `${s.appearance ?? ""}|${s.idiom}`;
|
|
2391
|
+
if (!variants.has(key)) variants.set(key, { ...s.appearance ? { appearance: s.appearance } : {}, platform: s.idiom, values: {} });
|
|
2392
|
+
variants.get(key).values[k] = s.value;
|
|
2393
|
+
}
|
|
2200
2394
|
}
|
|
2201
|
-
return
|
|
2395
|
+
return {
|
|
2396
|
+
...base,
|
|
2397
|
+
...Object.keys(overrides).length ? { overrides } : {},
|
|
2398
|
+
...variants.size ? { variants: [...variants.values()] } : {}
|
|
2399
|
+
};
|
|
2202
2400
|
}
|
|
2203
2401
|
function inspect(doc) {
|
|
2204
2402
|
return {
|
|
2205
2403
|
order: "groups and layers are listed front to back (index 0 = frontmost)",
|
|
2206
2404
|
document: describeProps(doc.props),
|
|
2405
|
+
// attributes first, so an unknown key in the file (e.g. "target") can't replace these fields
|
|
2207
2406
|
groups: doc.groups.map((g, gi) => ({
|
|
2407
|
+
...describeProps(g.props),
|
|
2208
2408
|
target: nameOf(g) || `#${gi}`,
|
|
2209
2409
|
index: gi,
|
|
2210
|
-
...describeProps(g.props),
|
|
2211
2410
|
layers: g.layers.map((l, li) => ({
|
|
2411
|
+
...describeProps(l.props),
|
|
2212
2412
|
target: `${nameOf(g) || `#${gi}`}/${nameOf(l) || `#${li}`}`,
|
|
2213
|
-
index: li
|
|
2214
|
-
...describeProps(l.props)
|
|
2413
|
+
index: li
|
|
2215
2414
|
}))
|
|
2216
2415
|
})),
|
|
2217
2416
|
assets: Object.values(doc.assets).map((a) => ({ name: a.name, kind: a.kind, width: a.width, height: a.height }))
|
|
@@ -2229,9 +2428,7 @@ function targetLabel(doc, ref) {
|
|
|
2229
2428
|
}
|
|
2230
2429
|
async function saveFile(ws, out, bytes) {
|
|
2231
2430
|
const p = await ws.resolve(out);
|
|
2232
|
-
await
|
|
2233
|
-
await writeFile2(p, bytes);
|
|
2234
|
-
return p;
|
|
2431
|
+
return (await writeFiles(dirname2(p), { [basename(p)]: bytes }))[0];
|
|
2235
2432
|
}
|
|
2236
2433
|
var inspectOutput = loose({ order: z2.string(), document: z2.record(z2.string(), z2.unknown()), groups: z2.array(z2.looseObject({ target: z2.string(), index: z2.number() })), assets: z2.array(z2.looseObject({ name: z2.string() })) });
|
|
2237
2434
|
var EX_STAR = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M512 212 L594 420 L812 430 L640 566 L700 780 L512 660 L324 780 L384 566 L212 430 L430 420 Z" fill="#ffffff"/></svg>';
|
|
@@ -2256,7 +2453,9 @@ var TOOLS = [
|
|
|
2256
2453
|
},
|
|
2257
2454
|
output: loose({ created: z2.string(), dry_run: z2.boolean().optional(), groups: z2.array(z2.unknown()) }),
|
|
2258
2455
|
mutates_input: true,
|
|
2456
|
+
writes_files: true,
|
|
2259
2457
|
idempotency_hint: "not-idempotent",
|
|
2458
|
+
destructive: true,
|
|
2260
2459
|
supports_dry_run: true,
|
|
2261
2460
|
verification: ["icon_inspect shows the document fill", "icon_render_sheet shows the background in all appearances"],
|
|
2262
2461
|
requires_visual_verification: false,
|
|
@@ -2264,14 +2463,16 @@ var TOOLS = [
|
|
|
2264
2463
|
async run(ws, { path, background, dark_background, watch, overwrite, dry_run }) {
|
|
2265
2464
|
if (/\.zip$/i.test(path.trim())) throw new Error("icon_create writes an .icon folder; pass a path without .zip (use icon_export to make a zip)");
|
|
2266
2465
|
const full = await ws.iconPath(path);
|
|
2267
|
-
const exists = await stat2(full).then(() => true, () => false);
|
|
2268
|
-
if (exists && !overwrite) throw new Error(`${path} already exists. Pass overwrite: true to replace it.`);
|
|
2269
2466
|
const doc = createDocument({
|
|
2270
2467
|
...background ? { background } : {},
|
|
2271
2468
|
platforms: watch === false ? { squares: "shared" } : { circles: ["watchOS"], squares: "shared" }
|
|
2272
2469
|
});
|
|
2273
2470
|
if (dark_background) setProp(doc, { kind: "document" }, "fill", dark_background, "dark");
|
|
2274
|
-
|
|
2471
|
+
await ws.withLock(full, async () => {
|
|
2472
|
+
const exists = await stat2(full).then(() => true, () => false);
|
|
2473
|
+
if (exists && !overwrite) throw new Error(`${path} already exists. Pass overwrite: true to replace it.`);
|
|
2474
|
+
if (!dry_run) await saveIcon(full, doc);
|
|
2475
|
+
});
|
|
2275
2476
|
return { data: { created: full, ...dry_run ? { dry_run: true } : {}, ...inspect(doc) } };
|
|
2276
2477
|
}
|
|
2277
2478
|
}),
|
|
@@ -2287,6 +2488,7 @@ var TOOLS = [
|
|
|
2287
2488
|
input: { path: pathArg },
|
|
2288
2489
|
output: inspectOutput,
|
|
2289
2490
|
mutates_input: false,
|
|
2491
|
+
writes_files: false,
|
|
2290
2492
|
idempotency_hint: "idempotent",
|
|
2291
2493
|
supports_dry_run: false,
|
|
2292
2494
|
verification: [],
|
|
@@ -2315,6 +2517,7 @@ var TOOLS = [
|
|
|
2315
2517
|
},
|
|
2316
2518
|
output: loose({ added: z2.string(), groups: z2.array(z2.string()) }),
|
|
2317
2519
|
mutates_input: true,
|
|
2520
|
+
writes_files: false,
|
|
2318
2521
|
idempotency_hint: "not-idempotent",
|
|
2319
2522
|
supports_dry_run: true,
|
|
2320
2523
|
verification: ["icon_inspect lists the new group"],
|
|
@@ -2357,6 +2560,7 @@ var TOOLS = [
|
|
|
2357
2560
|
},
|
|
2358
2561
|
output: loose({ added: z2.string(), size: z2.string(), warnings: z2.array(z2.string()) }),
|
|
2359
2562
|
mutates_input: true,
|
|
2563
|
+
writes_files: false,
|
|
2360
2564
|
idempotency_hint: "not-idempotent",
|
|
2361
2565
|
supports_dry_run: true,
|
|
2362
2566
|
verification: ["Check `warnings` (V-01\u2026V-10)", "icon_render_sheet to see the layer in every appearance"],
|
|
@@ -2432,6 +2636,7 @@ var TOOLS = [
|
|
|
2432
2636
|
},
|
|
2433
2637
|
output: loose({ target: z2.string(), now: z2.record(z2.string(), z2.unknown()) }),
|
|
2434
2638
|
mutates_input: true,
|
|
2639
|
+
writes_files: false,
|
|
2435
2640
|
idempotency_hint: "idempotent",
|
|
2436
2641
|
supports_dry_run: true,
|
|
2437
2642
|
verification: ["`now` shows the stored values", "icon_render_sheet for the appearances you changed"],
|
|
@@ -2467,6 +2672,7 @@ var TOOLS = [
|
|
|
2467
2672
|
input: { path: pathArg, target: z2.string(), index: z2.number().int().min(0), to_group: z2.string().optional(), dry_run: dryRunArg },
|
|
2468
2673
|
output: loose({ groups: groupsOutline }),
|
|
2469
2674
|
mutates_input: true,
|
|
2675
|
+
writes_files: false,
|
|
2470
2676
|
idempotency_hint: "idempotent",
|
|
2471
2677
|
supports_dry_run: true,
|
|
2472
2678
|
verification: ["`groups` lists the new order (front to back)"],
|
|
@@ -2480,6 +2686,7 @@ var TOOLS = [
|
|
|
2480
2686
|
const ref = resolvePath(doc, target);
|
|
2481
2687
|
let gid;
|
|
2482
2688
|
if (to_group) {
|
|
2689
|
+
if (ref.kind !== "layer") throw new Error("to_group only applies to layers; groups are reordered with index");
|
|
2483
2690
|
const g = resolvePath(doc, to_group);
|
|
2484
2691
|
if (g.kind !== "group") throw new Error("to_group must be a group");
|
|
2485
2692
|
gid = g.id;
|
|
@@ -2511,6 +2718,7 @@ var TOOLS = [
|
|
|
2511
2718
|
},
|
|
2512
2719
|
output: loose({ moved: z2.number(), positions: z2.array(z2.looseObject({ target: z2.string() })) }),
|
|
2513
2720
|
mutates_input: true,
|
|
2721
|
+
writes_files: false,
|
|
2514
2722
|
idempotency_hint: "idempotent",
|
|
2515
2723
|
supports_dry_run: true,
|
|
2516
2724
|
verification: ["`positions` gives each bounding box in canvas points", "icon_render to look at the result"],
|
|
@@ -2546,6 +2754,7 @@ var TOOLS = [
|
|
|
2546
2754
|
input: { path: pathArg, target: z2.string(), dry_run: dryRunArg },
|
|
2547
2755
|
output: loose({ removed: z2.string(), groups: groupsOutline }),
|
|
2548
2756
|
mutates_input: true,
|
|
2757
|
+
writes_files: false,
|
|
2549
2758
|
idempotency_hint: "not-idempotent",
|
|
2550
2759
|
destructive: true,
|
|
2551
2760
|
supports_dry_run: true,
|
|
@@ -2582,6 +2791,7 @@ var TOOLS = [
|
|
|
2582
2791
|
issues: z2.array(z2.looseObject({ id: z2.string(), severity: z2.enum(["error", "warning", "info"]), message: z2.string() }))
|
|
2583
2792
|
}),
|
|
2584
2793
|
mutates_input: false,
|
|
2794
|
+
writes_files: false,
|
|
2585
2795
|
idempotency_hint: "idempotent",
|
|
2586
2796
|
supports_dry_run: false,
|
|
2587
2797
|
verification: ['No issue with severity "error"'],
|
|
@@ -2608,12 +2818,13 @@ var TOOLS = [
|
|
|
2608
2818
|
platform: z2.enum(["iOS", "macOS", "watchOS"]).optional().describe("Default: iOS. watchOS renders a circle"),
|
|
2609
2819
|
size: z2.number().int().min(16).max(2048).optional().describe("Pixels, default 512"),
|
|
2610
2820
|
mask: maskEnum.optional(),
|
|
2611
|
-
tint: z2.string().optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
|
|
2821
|
+
tint: z2.string().regex(/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, 'tint must be a color like "#33AAFF"').optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
|
|
2612
2822
|
light_angle: z2.number().min(-180).max(180).optional().describe("Light direction in degrees, 0 = top (default), -45 = top-left"),
|
|
2613
2823
|
out: z2.string().optional().describe("Save the PNG to this path")
|
|
2614
2824
|
},
|
|
2615
2825
|
output: loose({ appearance: z2.string(), saved: z2.string().optional() }),
|
|
2616
2826
|
mutates_input: false,
|
|
2827
|
+
writes_files: true,
|
|
2617
2828
|
idempotency_hint: "idempotent",
|
|
2618
2829
|
supports_dry_run: false,
|
|
2619
2830
|
verification: ["Look at the returned image"],
|
|
@@ -2646,11 +2857,12 @@ var TOOLS = [
|
|
|
2646
2857
|
input: {
|
|
2647
2858
|
path: pathArg,
|
|
2648
2859
|
cell: z2.number().int().min(96).max(600).optional().describe("Cell size in pixels, default 240"),
|
|
2649
|
-
tint: z2.string().optional(),
|
|
2860
|
+
tint: z2.string().regex(/^#(?:[0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i, 'tint must be a color like "#33AAFF"').optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
|
|
2650
2861
|
out: z2.string().optional()
|
|
2651
2862
|
},
|
|
2652
2863
|
output: loose({ appearances: z2.array(z2.string()), saved: z2.string().optional() }),
|
|
2653
2864
|
mutates_input: false,
|
|
2865
|
+
writes_files: true,
|
|
2654
2866
|
idempotency_hint: "idempotent",
|
|
2655
2867
|
supports_dry_run: false,
|
|
2656
2868
|
verification: ["Look at the returned image: every appearance should stay legible"],
|
|
@@ -2676,12 +2888,14 @@ var TOOLS = [
|
|
|
2676
2888
|
path: pathArg,
|
|
2677
2889
|
out_dir: z2.string(),
|
|
2678
2890
|
formats: z2.array(z2.enum(["icon", "icns", "appiconset", "png"])).min(1),
|
|
2679
|
-
sizes: z2.array(z2.number().int().min(16).max(2048)).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
|
|
2680
|
-
appearances: z2.array(appearanceEnum).optional().describe("PNG appearances, default all six")
|
|
2891
|
+
sizes: z2.array(z2.number().int().min(16).max(2048)).min(1).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
|
|
2892
|
+
appearances: z2.array(appearanceEnum).min(1).optional().describe("PNG appearances, default all six")
|
|
2681
2893
|
},
|
|
2682
2894
|
output: loose({ out_dir: z2.string(), files: z2.number(), written: z2.array(z2.string()) }),
|
|
2683
2895
|
mutates_input: false,
|
|
2896
|
+
writes_files: true,
|
|
2684
2897
|
idempotency_hint: "idempotent",
|
|
2898
|
+
destructive: true,
|
|
2685
2899
|
supports_dry_run: false,
|
|
2686
2900
|
verification: ["`written` lists every file", "Open the .icon in Icon Composer / Xcode 26 on a Mac for the final check"],
|
|
2687
2901
|
requires_visual_verification: false,
|
|
@@ -2689,6 +2903,9 @@ var TOOLS = [
|
|
|
2689
2903
|
async run(ws, { path, out_dir, formats, sizes, appearances }) {
|
|
2690
2904
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2691
2905
|
const outDir = await ws.resolve(out_dir);
|
|
2906
|
+
const inside = relative2(full, outDir);
|
|
2907
|
+
if (inside === "" || !inside.startsWith(`..${sep2}`) && inside !== ".." && !isAbsolute2(inside))
|
|
2908
|
+
throw new Error("out_dir is inside the icon bundle; choose a folder outside it");
|
|
2692
2909
|
const doc = await ws.read(full);
|
|
2693
2910
|
const name = basename(full).replace(/\.icon(\.zip)?$/, "");
|
|
2694
2911
|
const raster = nodeRasterizer(doc);
|
|
@@ -2697,16 +2914,9 @@ var TOOLS = [
|
|
|
2697
2914
|
throw new Error(`These visible layers can't be rendered and would be missing from the images: ${missing.join(", ")}. Fix them first (icon_validate).`);
|
|
2698
2915
|
const written = [];
|
|
2699
2916
|
if (formats.includes("icon")) {
|
|
2700
|
-
|
|
2701
|
-
await saveIcon(dest, doc);
|
|
2702
|
-
written.push(join2(dest, "icon.json"), ...Object.keys(doc.assets).map((a) => join2(dest, "Assets", a)));
|
|
2703
|
-
}
|
|
2704
|
-
if (formats.includes("icns")) {
|
|
2705
|
-
const p = join2(outDir, `${name}.icns`);
|
|
2706
|
-
await mkdir2(dirname2(p), { recursive: true });
|
|
2707
|
-
await writeFile2(p, await buildIcns(raster));
|
|
2708
|
-
written.push(p);
|
|
2917
|
+
written.push(...await saveIcon(join2(outDir, `${name}.icon`), doc));
|
|
2709
2918
|
}
|
|
2919
|
+
if (formats.includes("icns")) written.push(...await writeFiles(outDir, { [`${name}.icns`]: await buildIcns(raster) }));
|
|
2710
2920
|
if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
|
|
2711
2921
|
if (formats.includes("png"))
|
|
2712
2922
|
written.push(
|
|
@@ -2725,7 +2935,7 @@ async function runTool(ws, t, args) {
|
|
|
2725
2935
|
}
|
|
2726
2936
|
|
|
2727
2937
|
// ../mcp/src/version.ts
|
|
2728
|
-
var VERSION = "0.2.
|
|
2938
|
+
var VERSION = "0.2.1";
|
|
2729
2939
|
|
|
2730
2940
|
// ../mcp/src/contract.ts
|
|
2731
2941
|
var RESOURCES = [
|
|
@@ -2762,6 +2972,7 @@ function toolSpec(t) {
|
|
|
2762
2972
|
input_schema: jsonSchema(z3.object(t.input).strict(), "input"),
|
|
2763
2973
|
output_schema: jsonSchema(t.output, "output"),
|
|
2764
2974
|
mutates_input: t.mutates_input,
|
|
2975
|
+
writes_files: t.writes_files,
|
|
2765
2976
|
idempotency_hint: t.idempotency_hint,
|
|
2766
2977
|
destructive: !!t.destructive,
|
|
2767
2978
|
supports_dry_run: t.supports_dry_run,
|
|
@@ -2794,7 +3005,7 @@ function buildContract() {
|
|
|
2794
3005
|
rule: "Breaking changes bump contract_version to the next major. The frozen surface (packages/mcp/spec/surface.json) makes CI fail otherwise."
|
|
2795
3006
|
},
|
|
2796
3007
|
tools: TOOLS.map(toolSpec),
|
|
2797
|
-
capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a
|
|
3008
|
+
capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)),
|
|
2798
3009
|
resources: RESOURCES,
|
|
2799
3010
|
prompts: PROMPTS,
|
|
2800
3011
|
icon_json: { document: attrTable(DOCUMENT_ATTRS), group: attrTable(GROUP_ATTRS), layer: attrTable(LAYER_ATTRS) },
|
|
@@ -2892,10 +3103,11 @@ function createServer(ws) {
|
|
|
2892
3103
|
{
|
|
2893
3104
|
title: t.title,
|
|
2894
3105
|
description: t.description,
|
|
2895
|
-
|
|
3106
|
+
// strict: a misspelt argument (dryRun for dry_run) is an error, not silently dropped
|
|
3107
|
+
inputSchema: z4.object(t.input).strict(),
|
|
2896
3108
|
outputSchema: t.output,
|
|
2897
3109
|
annotations: {
|
|
2898
|
-
readOnlyHint: !t.mutates_input,
|
|
3110
|
+
readOnlyHint: !t.mutates_input && !t.writes_files,
|
|
2899
3111
|
destructiveHint: !!t.destructive,
|
|
2900
3112
|
idempotentHint: t.idempotency_hint === "idempotent",
|
|
2901
3113
|
openWorldHint: false
|
|
@@ -2969,8 +3181,8 @@ Use the fresnel tools:
|
|
|
2969
3181
|
}
|
|
2970
3182
|
|
|
2971
3183
|
// ../mcp/src/workspace.ts
|
|
2972
|
-
import { realpath, stat as stat3 } from "fs/promises";
|
|
2973
|
-
import { dirname as dirname3, isAbsolute as
|
|
3184
|
+
import { lstat as lstat2, realpath, stat as stat3 } from "fs/promises";
|
|
3185
|
+
import { dirname as dirname3, isAbsolute as isAbsolute3, relative as relative3, resolve as resolve2, sep as sep3 } from "path";
|
|
2974
3186
|
var Workspace = class {
|
|
2975
3187
|
roots;
|
|
2976
3188
|
locks = /* @__PURE__ */ new Map();
|
|
@@ -2983,13 +3195,17 @@ var Workspace = class {
|
|
|
2983
3195
|
/** Resolves a user path (relative paths are relative to the first root) and checks it's allowed. */
|
|
2984
3196
|
async resolve(p, opts = {}) {
|
|
2985
3197
|
if (!p || typeof p !== "string") throw new Error("A path is required");
|
|
2986
|
-
const abs =
|
|
3198
|
+
const abs = isAbsolute3(p) ? resolve2(p) : resolve2(this.roots[0], p);
|
|
2987
3199
|
let probe = abs;
|
|
2988
3200
|
let suffix = "";
|
|
2989
3201
|
for (; ; ) {
|
|
2990
3202
|
try {
|
|
2991
3203
|
const real = await realpath(probe);
|
|
2992
3204
|
const full = suffix ? resolve2(real, suffix) : real;
|
|
3205
|
+
if (suffix) {
|
|
3206
|
+
const first = resolve2(real, suffix.split(sep3)[0]);
|
|
3207
|
+
if (await lstat2(first).then(() => true, () => false)) throw new Error(`Access denied: ${p} goes through a symbolic link that points nowhere`);
|
|
3208
|
+
}
|
|
2993
3209
|
if (!await this.allowed(full)) throw new Error(`Access denied: ${p} is outside the allowed roots (${this.roots.join(", ")})`);
|
|
2994
3210
|
if (opts.mustExist && full !== real) throw new Error(`Not found: ${p}`);
|
|
2995
3211
|
return full;
|
|
@@ -2998,7 +3214,7 @@ var Workspace = class {
|
|
|
2998
3214
|
if (opts.mustExist) throw new Error(`Not found: ${p}`);
|
|
2999
3215
|
const parent = dirname3(probe);
|
|
3000
3216
|
if (parent === probe) throw new Error(`Invalid path: ${p}`);
|
|
3001
|
-
suffix = suffix ? `${probe.slice(parent.length + 1)}${
|
|
3217
|
+
suffix = suffix ? `${probe.slice(parent.length + 1)}${sep3}${suffix}` : probe.slice(parent.length + 1);
|
|
3002
3218
|
probe = parent;
|
|
3003
3219
|
}
|
|
3004
3220
|
}
|
|
@@ -3011,15 +3227,17 @@ var Workspace = class {
|
|
|
3011
3227
|
root = await realpath(r);
|
|
3012
3228
|
} catch {
|
|
3013
3229
|
}
|
|
3014
|
-
const rel =
|
|
3015
|
-
if (rel === "" || !rel.startsWith(
|
|
3230
|
+
const rel = relative3(root, full);
|
|
3231
|
+
if (rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute3(rel)) return true;
|
|
3016
3232
|
}
|
|
3017
3233
|
return false;
|
|
3018
3234
|
}
|
|
3019
3235
|
/** Normalizes an icon path: accepts "Foo", "Foo.icon", ".../Foo.icon/icon.json". */
|
|
3020
3236
|
async iconPath(p, opts = {}) {
|
|
3021
|
-
let q = p.trim();
|
|
3237
|
+
let q = (typeof p === "string" ? p : "").trim().replace(/[\\/]+$/, "");
|
|
3022
3238
|
if (q.endsWith("/icon.json") || q.endsWith("\\icon.json")) q = q.slice(0, -"/icon.json".length);
|
|
3239
|
+
const base = q.split(/[\\/]/).pop() ?? "";
|
|
3240
|
+
if (!base || base === ".icon" || base === ".icon.zip" || base === "." || base === "..") throw new Error(`Invalid icon path "${p}": give a name such as "MyApp.icon"`);
|
|
3023
3241
|
if (!q.endsWith(".icon") && !q.endsWith(".icon.zip")) q = `${q}.icon`;
|
|
3024
3242
|
const full = await this.resolve(q, opts);
|
|
3025
3243
|
if (opts.mustExist) {
|
|
@@ -3081,23 +3299,13 @@ Appearances: ${APPEARANCES.join(", ")}
|
|
|
3081
3299
|
Docs: https://github.com/kajisho5/fresnel
|
|
3082
3300
|
`;
|
|
3083
3301
|
async function callTool(t, argv) {
|
|
3084
|
-
|
|
3302
|
+
const { args, roots, help } = parseToolArgv(t, argv);
|
|
3303
|
+
if (help) {
|
|
3085
3304
|
process.stdout.write(toolHelp(t));
|
|
3086
3305
|
return;
|
|
3087
3306
|
}
|
|
3088
|
-
const roots = [];
|
|
3089
|
-
const rest = [];
|
|
3090
|
-
for (let i = 0; i < argv.length; i++) {
|
|
3091
|
-
const a = argv[i];
|
|
3092
|
-
if (a === "--root" || a === "-r") {
|
|
3093
|
-
const v = argv[++i];
|
|
3094
|
-
if (!v) throw new Error("--root needs a directory");
|
|
3095
|
-
roots.push(v);
|
|
3096
|
-
} else if (a.startsWith("--root=")) roots.push(a.slice(7));
|
|
3097
|
-
else rest.push(a);
|
|
3098
|
-
}
|
|
3099
3307
|
const ws = new Workspace(roots, { unrestricted: roots.length === 0 });
|
|
3100
|
-
const { data, image } = await runTool(ws, t,
|
|
3308
|
+
const { data, image } = await runTool(ws, t, args);
|
|
3101
3309
|
if (image && !data.saved) process.stderr.write("fresnel: the PNG was not saved; pass --out <file.png> to keep it\n");
|
|
3102
3310
|
process.stdout.write(`${JSON.stringify(data, null, 2)}
|
|
3103
3311
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fresnel-icon",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "fresnel CLI: build, validate, render and export Liquid Glass app icons (.icon bundles) from the command line or CI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"tsup": "9.0.0",
|
|
40
40
|
"tsx": "4.23.15",
|
|
41
|
-
"@fresnel/core": "0.2.
|
|
42
|
-
"@fresnel/render": "0.2.
|
|
41
|
+
"@fresnel/core": "0.2.1",
|
|
42
|
+
"@fresnel/render": "0.2.1"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsup",
|