fresnel-mcp 0.2.0 → 0.2.2
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/server.js +324 -127
- package/package.json +3 -3
package/dist/server.js
CHANGED
|
@@ -23,8 +23,12 @@ var APPEARANCE_LABEL = {
|
|
|
23
23
|
function appearanceKeys(a) {
|
|
24
24
|
switch (a) {
|
|
25
25
|
case "dark":
|
|
26
|
-
case "clear-dark":
|
|
27
26
|
return ["dark"];
|
|
27
|
+
// Clear and Tinted (light and dark) are all rendered from Icon Composer's Mono, written as "tinted".
|
|
28
|
+
// Apple: "For mono, you can preview clear and tinted variants"; Assets.car keeps only light / dark /
|
|
29
|
+
// tintable renditions. A dark value does not carry over to Clear dark [推測: strong].
|
|
30
|
+
case "clear-light":
|
|
31
|
+
case "clear-dark":
|
|
28
32
|
case "tinted-light":
|
|
29
33
|
case "tinted-dark":
|
|
30
34
|
return ["tinted"];
|
|
@@ -140,6 +144,7 @@ function readProps(obj) {
|
|
|
140
144
|
function writeProps(props, order, structural = {}) {
|
|
141
145
|
const out = {};
|
|
142
146
|
const emitted = /* @__PURE__ */ new Set();
|
|
147
|
+
let fromFile = true;
|
|
143
148
|
const emit = (rawKey) => {
|
|
144
149
|
if (emitted.has(rawKey)) return;
|
|
145
150
|
if (rawKey in structural) {
|
|
@@ -147,17 +152,28 @@ function writeProps(props, order, structural = {}) {
|
|
|
147
152
|
emitted.add(rawKey);
|
|
148
153
|
return;
|
|
149
154
|
}
|
|
155
|
+
const literal = props[rawKey];
|
|
156
|
+
if (rawKey.endsWith(SPEC_SUFFIX) && literal?.plain) {
|
|
157
|
+
out[rawKey] = structuredClone(literal.plain.value);
|
|
158
|
+
emitted.add(rawKey);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
150
161
|
const isSpec = rawKey.endsWith(SPEC_SUFFIX);
|
|
151
162
|
const base = isSpec ? rawKey.slice(0, -SPEC_SUFFIX.length) : rawKey;
|
|
152
163
|
const p = props[base];
|
|
153
164
|
if (!p) return;
|
|
154
|
-
if (isSpec ? p.specs && p.specs.length > 0 : p.plain) {
|
|
165
|
+
if (isSpec ? p.specs && (p.specs.length > 0 || fromFile) : p.plain) {
|
|
155
166
|
out[rawKey] = structuredClone(isSpec ? p.specs : p.plain.value);
|
|
156
167
|
emitted.add(rawKey);
|
|
157
168
|
}
|
|
158
169
|
};
|
|
159
170
|
for (const k of order) emit(k);
|
|
171
|
+
fromFile = false;
|
|
160
172
|
for (const [base, p] of Object.entries(props)) {
|
|
173
|
+
if (base.endsWith(SPEC_SUFFIX) && p.plain) {
|
|
174
|
+
emit(base);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
161
177
|
if (p.plain) emit(base);
|
|
162
178
|
if (p.specs?.length) emit(base + SPEC_SUFFIX);
|
|
163
179
|
}
|
|
@@ -205,6 +221,11 @@ function baseValue(p) {
|
|
|
205
221
|
if (b) return b.value;
|
|
206
222
|
return p.plain?.value;
|
|
207
223
|
}
|
|
224
|
+
function variantValue(p, v) {
|
|
225
|
+
const vv = toVariant(v);
|
|
226
|
+
if (isEmpty(vv)) return baseValue(p);
|
|
227
|
+
return p?.specs?.find((e) => sameVariant(e, vv))?.value;
|
|
228
|
+
}
|
|
208
229
|
function variantsOf(p) {
|
|
209
230
|
return (p?.specs ?? []).filter((e) => !isBaseEntry(e)).map((e) => ({ ...e.appearance ? { appearance: e.appearance } : {}, ...e.idiom ? { idiom: e.idiom } : {} }));
|
|
210
231
|
}
|
|
@@ -397,6 +418,7 @@ function svgSize(svg) {
|
|
|
397
418
|
return { width: 1024, height: 1024 };
|
|
398
419
|
}
|
|
399
420
|
function pngSize(b) {
|
|
421
|
+
if (b.length < 24) return { width: 0, height: 0 };
|
|
400
422
|
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
401
423
|
return { width: dv.getUint32(16), height: dv.getUint32(20) };
|
|
402
424
|
}
|
|
@@ -408,6 +430,10 @@ function jpgSize(b) {
|
|
|
408
430
|
continue;
|
|
409
431
|
}
|
|
410
432
|
const marker = b[i + 1];
|
|
433
|
+
if (marker === 255) {
|
|
434
|
+
i++;
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
411
437
|
const len = b[i + 2] << 8 | b[i + 3];
|
|
412
438
|
if (marker >= 192 && marker <= 207 && ![196, 200, 204].includes(marker)) {
|
|
413
439
|
return { height: b[i + 5] << 8 | b[i + 6], width: b[i + 7] << 8 | b[i + 8] };
|
|
@@ -447,15 +473,26 @@ function expandInternalEntities(svg) {
|
|
|
447
473
|
if (v.length <= 2048 && !/[&<]/.test(v)) ents.set(m[1], v);
|
|
448
474
|
}
|
|
449
475
|
const body = svg.slice(0, doctype.index) + svg.slice(doctype.index + doctype[0].length);
|
|
450
|
-
|
|
476
|
+
let budget = Math.max(1 << 20, body.length * 4);
|
|
477
|
+
return body.replace(/&([\w.-]+);/g, (m, name) => {
|
|
478
|
+
const v = ents.get(name);
|
|
479
|
+
if (v === void 0 || v.length > budget) return m;
|
|
480
|
+
budget -= v.length;
|
|
481
|
+
return v;
|
|
482
|
+
});
|
|
451
483
|
}
|
|
484
|
+
var DATA_IMAGE = /^data:image\/(?:png|jpe?g|gif|webp|svg\+xml)(?:;[\w.+-]+=[\w.+-]+)*;base64,[a-z0-9+/=\s]*$/i;
|
|
452
485
|
function safeUrlRef(v) {
|
|
453
486
|
const t = v.trim().replace(/^['"]|['"]$/g, "").trim();
|
|
454
|
-
return t.startsWith("#") ||
|
|
487
|
+
return t.startsWith("#") || DATA_IMAGE.test(t);
|
|
455
488
|
}
|
|
489
|
+
function decodeXmlEntities(v) {
|
|
490
|
+
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]);
|
|
491
|
+
}
|
|
492
|
+
var escapeXmlText = (v) => v.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
456
493
|
function cleanCss(css0) {
|
|
457
494
|
const css = css0.replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1");
|
|
458
|
-
return css.replace(/@import\b[^;]*;?/gi, "").replace(/url\s*\(([^)]*)\)/gi, (m, inner) => safeUrlRef(inner) ? m : "none").replace(/expression\s*\(/gi, "(").replace(/javascript\s*:/gi, "");
|
|
495
|
+
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, "");
|
|
459
496
|
}
|
|
460
497
|
function cleanAttrs(raw) {
|
|
461
498
|
const out = [];
|
|
@@ -476,8 +513,10 @@ function cleanAttrs(raw) {
|
|
|
476
513
|
if (/javascript:|vbscript:/.test(plain) || lname === "xml:base") continue;
|
|
477
514
|
let v = value;
|
|
478
515
|
if (lname === "style" || /url\(/.test(plain)) {
|
|
479
|
-
|
|
480
|
-
|
|
516
|
+
const css = cleanCss(decodeXmlEntities(v));
|
|
517
|
+
const refs = [...decodeForCheck(css).matchAll(/url\(([^)]*)\)/g)].map((m2) => m2[1]);
|
|
518
|
+
if (refs.some((r) => !safeUrlRef(r))) continue;
|
|
519
|
+
v = escapeXmlText(css);
|
|
481
520
|
}
|
|
482
521
|
out.push(`${name}="${v.replace(/"/g, """)}"`);
|
|
483
522
|
}
|
|
@@ -491,7 +530,7 @@ function sanitizeOnce(svg) {
|
|
|
491
530
|
while (i < svg.length) {
|
|
492
531
|
const lt = svg.indexOf("<", i);
|
|
493
532
|
const text2 = lt < 0 ? svg.slice(i) : svg.slice(i, lt);
|
|
494
|
-
if (!skip.length) out += inStyle ? cleanCss(text2) : text2;
|
|
533
|
+
if (!skip.length) out += inStyle ? escapeXmlText(cleanCss(decodeXmlEntities(text2))) : text2;
|
|
495
534
|
if (lt < 0) break;
|
|
496
535
|
if (svg.startsWith("<!--", lt)) {
|
|
497
536
|
const e = svg.indexOf("-->", lt + 4);
|
|
@@ -501,7 +540,7 @@ function sanitizeOnce(svg) {
|
|
|
501
540
|
if (svg.startsWith("<![CDATA[", lt)) {
|
|
502
541
|
const e = svg.indexOf("]]>", lt);
|
|
503
542
|
const body = svg.slice(lt + 9, e < 0 ? svg.length : e);
|
|
504
|
-
if (!skip.length) out += inStyle ?
|
|
543
|
+
if (!skip.length) out += inStyle ? escapeXmlText(cleanCss(body)) : `<![CDATA[${body}]]>`;
|
|
505
544
|
i = e < 0 ? svg.length : e + 3;
|
|
506
545
|
continue;
|
|
507
546
|
}
|
|
@@ -574,12 +613,12 @@ function sanitizeOnce(svg) {
|
|
|
574
613
|
}
|
|
575
614
|
function sanitizeSvg(svg) {
|
|
576
615
|
let s = expandInternalEntities(svg);
|
|
577
|
-
for (let n2 = 0; n2 <
|
|
616
|
+
for (let n2 = 0; n2 < 8; n2++) {
|
|
578
617
|
const next = sanitizeOnce(s);
|
|
579
|
-
if (next === s)
|
|
618
|
+
if (next === s) return s;
|
|
580
619
|
s = next;
|
|
581
620
|
}
|
|
582
|
-
return
|
|
621
|
+
return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"/>';
|
|
583
622
|
}
|
|
584
623
|
function svgFacts(svg) {
|
|
585
624
|
const widths = [...svg.matchAll(/stroke-width\s*[:=]\s*["']?\s*([0-9.]+)/gi)].map((m) => Number(m[1])).filter((n2) => n2 > 0);
|
|
@@ -606,7 +645,15 @@ function assetText(a) {
|
|
|
606
645
|
function mimeOf(a) {
|
|
607
646
|
return a.kind === "svg" ? "image/svg+xml" : a.kind === "png" ? "image/png" : a.kind === "jpg" ? "image/jpeg" : "application/octet-stream";
|
|
608
647
|
}
|
|
648
|
+
var MAX_DECODE_PIXELS = 64e6;
|
|
609
649
|
function decodePng(b) {
|
|
650
|
+
try {
|
|
651
|
+
return decodePngUnchecked(b);
|
|
652
|
+
} catch {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function decodePngUnchecked(b) {
|
|
610
657
|
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
|
|
611
658
|
let off = 8;
|
|
612
659
|
let width = 0;
|
|
@@ -634,6 +681,7 @@ function decodePng(b) {
|
|
|
634
681
|
off += 12 + len;
|
|
635
682
|
}
|
|
636
683
|
if (depth !== 8 || interlace !== 0) return null;
|
|
684
|
+
if (!width || !height || width * height > MAX_DECODE_PIXELS) return null;
|
|
637
685
|
const ch = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }[ctype];
|
|
638
686
|
if (!ch) return null;
|
|
639
687
|
const total = idat.reduce((a, x) => a + x.length, 0);
|
|
@@ -811,7 +859,7 @@ function isSafeBundlePath(rel) {
|
|
|
811
859
|
return rel.split("/").every((p) => p !== "" && p !== "." && p !== ".." && !p.startsWith(".") && p.length <= 255);
|
|
812
860
|
}
|
|
813
861
|
function readBundle(files) {
|
|
814
|
-
const jsonKey = Object.keys(files).
|
|
862
|
+
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];
|
|
815
863
|
if (!jsonKey) throw new IconFormatError("icon.json not found in the bundle");
|
|
816
864
|
const prefix = jsonKey.slice(0, -"icon.json".length);
|
|
817
865
|
const base = parseIconJson(strFromU8(files[jsonKey]));
|
|
@@ -820,7 +868,7 @@ function readBundle(files) {
|
|
|
820
868
|
for (const [k, v] of Object.entries(files)) {
|
|
821
869
|
if (!k.startsWith(prefix) || k === jsonKey || k.endsWith("/")) continue;
|
|
822
870
|
const rel = k.slice(prefix.length);
|
|
823
|
-
if (!isSafeBundlePath(rel)) continue;
|
|
871
|
+
if (!isSafeBundlePath(rel) || rel.endsWith(".fresnel-tmp")) continue;
|
|
824
872
|
if (rel.startsWith("Assets/") && rel.split("/").length === 2) {
|
|
825
873
|
const name = rel.slice("Assets/".length);
|
|
826
874
|
if (name) assets[name] = makeAsset(name, v);
|
|
@@ -855,10 +903,12 @@ function syncFeatures(doc) {
|
|
|
855
903
|
}
|
|
856
904
|
function writeBundle(doc, extra = {}) {
|
|
857
905
|
const synced = { props: { ...doc.props }, groups: doc.groups };
|
|
906
|
+
if (synced.props.features) synced.props.features = structuredClone(synced.props.features);
|
|
858
907
|
syncFeatures(synced);
|
|
859
908
|
const files = { ...extra };
|
|
860
|
-
files["icon.json"] = strToU8(serializeIconJson({ ...doc, props: synced.props }));
|
|
861
909
|
for (const a of Object.values(doc.assets)) files[`Assets/${a.name}`] = a.bytes;
|
|
910
|
+
delete files["icon.json"];
|
|
911
|
+
files["icon.json"] = strToU8(serializeIconJson({ ...doc, props: synced.props }));
|
|
862
912
|
return files;
|
|
863
913
|
}
|
|
864
914
|
function unzipBundle(zip) {
|
|
@@ -907,8 +957,13 @@ function resolvePath(doc, path) {
|
|
|
907
957
|
const p = path.trim();
|
|
908
958
|
if (p === "" || p === "document" || p === "/") return { kind: "document" };
|
|
909
959
|
const whole = doc.groups.filter((g2) => nameOf(g2).toLowerCase() === p.toLowerCase());
|
|
910
|
-
if (whole.length === 1) return { kind: "group", id: whole[0].id };
|
|
911
960
|
const [gp, lp, ...rest] = p.split("/");
|
|
961
|
+
if (whole.length === 1) {
|
|
962
|
+
const g2 = lp !== void 0 && !rest.length ? doc.groups.find((x) => nameOf(x).toLowerCase() === gp.trim().toLowerCase()) : void 0;
|
|
963
|
+
if (g2?.layers.some((l2) => nameOf(l2).toLowerCase() === lp.trim().toLowerCase()))
|
|
964
|
+
throw new OpError(`"${path}" matches both a group and a layer; use #index (e.g. "#${doc.groups.indexOf(whole[0])}")`);
|
|
965
|
+
return { kind: "group", id: whole[0].id };
|
|
966
|
+
}
|
|
912
967
|
if (rest.length) throw new OpError(`Invalid target "${path}". Use "Group" or "Group/Layer".`);
|
|
913
968
|
const pick = (list, key, what) => {
|
|
914
969
|
const k = key.trim();
|
|
@@ -945,9 +1000,10 @@ function cleanName(name) {
|
|
|
945
1000
|
}
|
|
946
1001
|
function uniqueName(existing, wanted0) {
|
|
947
1002
|
const wanted = cleanName(wanted0) || "Untitled";
|
|
948
|
-
|
|
1003
|
+
const taken = new Set(existing.map((n2) => n2.toLowerCase()));
|
|
1004
|
+
if (!taken.has(wanted.toLowerCase())) return wanted;
|
|
949
1005
|
let i = 2;
|
|
950
|
-
while (
|
|
1006
|
+
while (taken.has(`${wanted} ${i}`.toLowerCase())) i++;
|
|
951
1007
|
return `${wanted} ${i}`;
|
|
952
1008
|
}
|
|
953
1009
|
function addGroup(doc, opts = {}) {
|
|
@@ -968,10 +1024,15 @@ function clampIndex(i, len) {
|
|
|
968
1024
|
function addAsset(doc, asset) {
|
|
969
1025
|
let name = asset.name.replace(/[\\/\u0000-\u001f:]/g, "_").replace(/^[.\s]+/, "").trim() || "image";
|
|
970
1026
|
const dot = name.lastIndexOf(".");
|
|
971
|
-
const
|
|
972
|
-
|
|
1027
|
+
const ext = dot > 0 ? name.slice(dot, dot + 16) : "";
|
|
1028
|
+
let stem = dot > 0 ? name.slice(0, dot) : name;
|
|
1029
|
+
while (new TextEncoder().encode(stem).length > 200) stem = stem.slice(0, -1);
|
|
1030
|
+
name = stem + ext;
|
|
1031
|
+
const clash = (n2) => Object.values(doc.assets).find((a) => a.name.toLowerCase() === n2.toLowerCase());
|
|
973
1032
|
let i = 2;
|
|
974
|
-
|
|
1033
|
+
for (let c = clash(name); c && !sameBytes(c.bytes, asset.bytes); c = clash(name)) name = `${stem} ${i++}${ext}`;
|
|
1034
|
+
const same = clash(name);
|
|
1035
|
+
if (same) return same.name;
|
|
975
1036
|
doc.assets[name] = { ...asset, name };
|
|
976
1037
|
return name;
|
|
977
1038
|
}
|
|
@@ -995,6 +1056,11 @@ function addLayer(doc, groupId, asset, opts = {}) {
|
|
|
995
1056
|
return l;
|
|
996
1057
|
}
|
|
997
1058
|
function setProp(doc, ref, key, value, appearance, detached) {
|
|
1059
|
+
const old = key === "image-name" && !detached && ref.kind === "layer" ? imagesOf([findLayer(doc, ref.id)?.layer].filter((l) => !!l)) : null;
|
|
1060
|
+
setPropRaw(doc, ref, key, value, appearance, detached);
|
|
1061
|
+
if (old?.size) pruneAssets(doc, old);
|
|
1062
|
+
}
|
|
1063
|
+
function setPropRaw(doc, ref, key, value, appearance, detached) {
|
|
998
1064
|
const node = detached ? { kind: "layer", props: detached.props } : nodeOf(doc, ref);
|
|
999
1065
|
if (key === "layers" || key === "groups") throw new OpError(`"${key}" can't be set directly`);
|
|
1000
1066
|
if (key.endsWith("-specializations")) throw new OpError(`Set "${key.replace(/-specializations$/, "")}" with an appearance instead`);
|
|
@@ -1023,31 +1089,40 @@ function normalizeFillColors(f) {
|
|
|
1023
1089
|
const o = { ...f };
|
|
1024
1090
|
if (typeof o.solid === "string") o.solid = normalizeColorInput(o.solid);
|
|
1025
1091
|
if (typeof o["automatic-gradient"] === "string") o["automatic-gradient"] = normalizeColorInput(o["automatic-gradient"]);
|
|
1026
|
-
if (Array.isArray(o["linear-gradient"]))
|
|
1092
|
+
if (Array.isArray(o["linear-gradient"])) {
|
|
1093
|
+
if (!o["linear-gradient"].every((c) => typeof c === "string")) throw new OpError("linear-gradient takes a list of color strings");
|
|
1094
|
+
o["linear-gradient"] = o["linear-gradient"].map(normalizeColorInput);
|
|
1095
|
+
}
|
|
1027
1096
|
return o;
|
|
1028
1097
|
}
|
|
1029
1098
|
function removeNode(doc, ref) {
|
|
1030
1099
|
if (ref.kind === "document") throw new OpError("The document can't be removed");
|
|
1100
|
+
let removedImages;
|
|
1031
1101
|
if (ref.kind === "group") {
|
|
1032
1102
|
const i = doc.groups.findIndex((g) => g.id === ref.id);
|
|
1033
1103
|
if (i < 0) throw new OpError("Group not found");
|
|
1104
|
+
removedImages = imagesOf(doc.groups[i].layers);
|
|
1034
1105
|
doc.groups.splice(i, 1);
|
|
1035
1106
|
} else {
|
|
1036
1107
|
const f = findLayer(doc, ref.id);
|
|
1037
1108
|
if (!f) throw new OpError("Layer not found");
|
|
1109
|
+
removedImages = imagesOf([f.layer]);
|
|
1038
1110
|
f.group.layers.splice(f.index, 1);
|
|
1039
1111
|
}
|
|
1040
|
-
pruneAssets(doc);
|
|
1112
|
+
pruneAssets(doc, removedImages);
|
|
1041
1113
|
}
|
|
1042
|
-
function
|
|
1114
|
+
function imagesOf(layers) {
|
|
1043
1115
|
const used = /* @__PURE__ */ new Set();
|
|
1044
|
-
for (const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1116
|
+
for (const l of layers) {
|
|
1117
|
+
const p = l.props["image-name"];
|
|
1118
|
+
if (p?.plain && typeof p.plain.value === "string") used.add(p.plain.value);
|
|
1119
|
+
for (const s of p?.specs ?? []) if (typeof s.value === "string") used.add(s.value);
|
|
1120
|
+
}
|
|
1121
|
+
return used;
|
|
1122
|
+
}
|
|
1123
|
+
function pruneAssets(doc, only) {
|
|
1124
|
+
const used = imagesOf(doc.groups.flatMap((g) => g.layers));
|
|
1125
|
+
for (const k of Object.keys(doc.assets)) if (!used.has(k) && (!only || only.has(k))) delete doc.assets[k];
|
|
1051
1126
|
}
|
|
1052
1127
|
function moveNode(doc, ref, index, toGroupId) {
|
|
1053
1128
|
if (ref.kind === "document") throw new OpError("The document can't be moved");
|
|
@@ -1114,9 +1189,10 @@ function validate(doc) {
|
|
|
1114
1189
|
let anyTinted = false;
|
|
1115
1190
|
const scan = (props) => {
|
|
1116
1191
|
for (const p of Object.values(props)) {
|
|
1117
|
-
const
|
|
1118
|
-
|
|
1119
|
-
|
|
1192
|
+
for (const v of variantsOf(p)) {
|
|
1193
|
+
if (v.appearance === "dark") anyDark = true;
|
|
1194
|
+
if (v.appearance === "tinted") anyTinted = true;
|
|
1195
|
+
}
|
|
1120
1196
|
}
|
|
1121
1197
|
};
|
|
1122
1198
|
scan(doc.props);
|
|
@@ -1147,19 +1223,22 @@ function validate(doc) {
|
|
|
1147
1223
|
}
|
|
1148
1224
|
if (Math.abs(a.width - a.height) > 0.5) {
|
|
1149
1225
|
push("V-03", `"${n2}" is ${round(a.width)}\xD7${round(a.height)}. Apple recommends square, unmasked layers.`, lName, lref);
|
|
1150
|
-
} else if (a.kind !== "svg" &&
|
|
1151
|
-
push("V-03", `"${n2}"
|
|
1226
|
+
} else if (a.kind !== "svg" && pos.scale > 1.01) {
|
|
1227
|
+
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);
|
|
1152
1228
|
}
|
|
1153
1229
|
if (a.kind === "png" && a.width === a.height && a.width >= 256) {
|
|
1154
1230
|
const bmp = safeDecode(a.bytes);
|
|
1155
1231
|
if (bmp && looksPreMasked(bmp)) push("V-04", `"${n2}" seems to have rounded corners baked in. The system masks icons itself.`, lName, lref);
|
|
1156
1232
|
}
|
|
1157
1233
|
if (a.kind === "svg") {
|
|
1158
|
-
const
|
|
1234
|
+
const text2 = assetText(a);
|
|
1235
|
+
const f = svgFacts(text2);
|
|
1236
|
+
const vb = /<svg\b[^>]*\sviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)/i.exec(text2);
|
|
1237
|
+
const unit2 = vb && Number(vb[1]) > 0 ? svgSize(text2).width / Number(vb[1]) : 1;
|
|
1159
1238
|
if (f.hasText) push("V-07", `"${n2}" contains <text>. Convert text to outlines.`, lName, lref);
|
|
1160
1239
|
if (f.hasFilter) push("V-07", `"${n2}" uses SVG filters (blur, glow, shadow). Let the system draw effects.`, lName, lref);
|
|
1161
|
-
if (f.minStrokeWidth !== null && f.minStrokeWidth * pos.scale < 10)
|
|
1162
|
-
push("V-06", `"${n2}" has strokes as thin as ${f.minStrokeWidth}. They'll be under 0.16 px at 16 px.`, lName, lref);
|
|
1240
|
+
if (f.minStrokeWidth !== null && f.minStrokeWidth * unit2 * pos.scale < 10)
|
|
1241
|
+
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);
|
|
1163
1242
|
}
|
|
1164
1243
|
}
|
|
1165
1244
|
}
|
|
@@ -1375,10 +1454,24 @@ function moveBy(doc, ref, dx, dy, ctx, variant) {
|
|
|
1375
1454
|
props = f.layer.props;
|
|
1376
1455
|
scale = resolveProp(f.group.props.position, ctx, null)?.scale ?? 1;
|
|
1377
1456
|
}
|
|
1378
|
-
const p = resolveProp(props.position, ctx, { scale: 1, "translation-in-points": [0, 0] }) ?? { scale: 1, "translation-in-points": [0, 0] };
|
|
1379
|
-
const [x, y] = p["translation-in-points"] ?? [0, 0];
|
|
1380
1457
|
const round2 = (v) => Math.round(v * 100) / 100;
|
|
1381
|
-
|
|
1458
|
+
const shift = (p) => {
|
|
1459
|
+
const q = p ?? { scale: 1, "translation-in-points": [0, 0] };
|
|
1460
|
+
const [x, y] = q["translation-in-points"] ?? [0, 0];
|
|
1461
|
+
return { ...q, "translation-in-points": [round2(x + dx / scale), round2(y + dy / scale)] };
|
|
1462
|
+
};
|
|
1463
|
+
const vv = toVariant(variant);
|
|
1464
|
+
if (vv.appearance !== void 0 || vv.idiom !== void 0) {
|
|
1465
|
+
const cur = variantValue(props.position, vv) ?? resolveProp(props.position, ctx, null);
|
|
1466
|
+
setProp(doc, ref, "position", shift(cur), variant);
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
const pos = props.position;
|
|
1470
|
+
setProp(doc, ref, "position", shift(baseValue(pos)), void 0);
|
|
1471
|
+
for (const e of pos?.specs ?? []) {
|
|
1472
|
+
if (e.appearance === void 0 && e.idiom === void 0) continue;
|
|
1473
|
+
if (e.value && typeof e.value === "object") e.value = shift(e.value);
|
|
1474
|
+
}
|
|
1382
1475
|
}
|
|
1383
1476
|
function alignNodes(doc, refs, how, ctx, opts = {}) {
|
|
1384
1477
|
const boxes = refs.map((r) => ({ ref: r, box: boxOf(doc, r, ctx) })).filter((x) => !!x.box);
|
|
@@ -1495,8 +1588,8 @@ function exampleCommand(t, args) {
|
|
|
1495
1588
|
}
|
|
1496
1589
|
|
|
1497
1590
|
// src/spec.ts
|
|
1498
|
-
import {
|
|
1499
|
-
import { basename, dirname as dirname2, join as join2 } from "path";
|
|
1591
|
+
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
1592
|
+
import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join2, relative as relative2, sep as sep2 } from "path";
|
|
1500
1593
|
import { z as z2 } from "zod";
|
|
1501
1594
|
|
|
1502
1595
|
// ../core/src/node.ts
|
|
@@ -1540,6 +1633,7 @@ async function saveIcon(path, doc) {
|
|
|
1540
1633
|
const assetsDir = join(root, "Assets");
|
|
1541
1634
|
await assertNoSymlink(root, assetsDir);
|
|
1542
1635
|
await mkdir(assetsDir, { recursive: true });
|
|
1636
|
+
const written = [];
|
|
1543
1637
|
for (const [rel, bytes] of Object.entries(files)) {
|
|
1544
1638
|
const target = resolve(root, ...rel.split("/"));
|
|
1545
1639
|
await assertNoSymlink(root, target);
|
|
@@ -1549,16 +1643,20 @@ async function saveIcon(path, doc) {
|
|
|
1549
1643
|
await rm(tmp, { force: true });
|
|
1550
1644
|
await writeFile(tmp, bytes, { flag: "wx" });
|
|
1551
1645
|
await rename(tmp, target);
|
|
1646
|
+
written.push(target);
|
|
1552
1647
|
}
|
|
1553
1648
|
const keep = new Set(Object.keys(doc.assets));
|
|
1554
1649
|
await assertNoSymlink(root, assetsDir);
|
|
1555
1650
|
for (const e of await readdir(assetsDir, { withFileTypes: true })) {
|
|
1556
|
-
if (e.isFile()
|
|
1651
|
+
if (!e.isFile() || keep.has(e.name)) continue;
|
|
1652
|
+
if (e.name.endsWith(".fresnel-tmp") || isSafeBundlePath(`Assets/${e.name}`) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
|
|
1557
1653
|
}
|
|
1654
|
+
return written;
|
|
1558
1655
|
}
|
|
1559
1656
|
async function writeFiles(dir, files) {
|
|
1560
1657
|
const written = [];
|
|
1561
1658
|
const root = resolve(dir);
|
|
1659
|
+
if ((await lstat(root).catch(() => null))?.isSymbolicLink()) throw new Error(`Refusing to write through a symbolic link: ${dir}`);
|
|
1562
1660
|
for (const [rel, bytes] of Object.entries(files)) {
|
|
1563
1661
|
const target = resolve(root, ...rel.split("/"));
|
|
1564
1662
|
const r = relative(root, target);
|
|
@@ -1609,7 +1707,7 @@ function contentBoxOf(asset) {
|
|
|
1609
1707
|
}
|
|
1610
1708
|
function svgAssetsNeedingBounds(doc) {
|
|
1611
1709
|
const out = /* @__PURE__ */ new Set();
|
|
1612
|
-
const hasGradient = (p) => [p?.plain?.value, ...(p?.specs ?? []).map((s) => s.value)].some((v) => !!v && typeof v === "object" && "linear-gradient" in v);
|
|
1710
|
+
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));
|
|
1613
1711
|
for (const g of doc.groups)
|
|
1614
1712
|
for (const l of g.layers) {
|
|
1615
1713
|
if (!hasGradient(l.props.fill)) continue;
|
|
@@ -1638,12 +1736,12 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
|
|
|
1638
1736
|
}
|
|
1639
1737
|
var SQUIRCLE = squirclePath();
|
|
1640
1738
|
var C = CANVAS;
|
|
1641
|
-
function maskPath(shape) {
|
|
1739
|
+
function maskPath(shape, pad = 0) {
|
|
1642
1740
|
if (shape === "circle") {
|
|
1643
1741
|
const r = C / 2 + 32;
|
|
1644
1742
|
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`;
|
|
1645
1743
|
}
|
|
1646
|
-
if (shape === "none") return `
|
|
1744
|
+
if (shape === "none") return `M${-pad},${-pad} H${C + pad} V${C + pad} H${-pad}Z`;
|
|
1647
1745
|
return SQUIRCLE;
|
|
1648
1746
|
}
|
|
1649
1747
|
|
|
@@ -1669,6 +1767,17 @@ function hexLuma(hex) {
|
|
|
1669
1767
|
const v = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
|
|
1670
1768
|
return luminance({ r: v(0), g: v(2), b: v(4), a: 1 });
|
|
1671
1769
|
}
|
|
1770
|
+
var NAMED = { white: 1, black: 0 };
|
|
1771
|
+
function paintLuma(v) {
|
|
1772
|
+
if (v.startsWith("#")) return hexLuma(v);
|
|
1773
|
+
const m = /^rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)/i.exec(v);
|
|
1774
|
+
if (m) {
|
|
1775
|
+
const ch = (x) => Math.min(1, Math.max(0, x.endsWith("%") ? Number(x.slice(0, -1)) / 100 : Number(x) / 255));
|
|
1776
|
+
return luminance({ r: ch(m[1]), g: ch(m[2]), b: ch(m[3]), a: 1 });
|
|
1777
|
+
}
|
|
1778
|
+
const named = NAMED[v.toLowerCase()];
|
|
1779
|
+
return named ?? null;
|
|
1780
|
+
}
|
|
1672
1781
|
function assetLuma(a) {
|
|
1673
1782
|
const hit = cache2.get(a.bytes);
|
|
1674
1783
|
if (hit !== void 0) return hit;
|
|
@@ -1689,10 +1798,11 @@ function assetLuma(a) {
|
|
|
1689
1798
|
}
|
|
1690
1799
|
} else if (a.kind === "svg") {
|
|
1691
1800
|
const text2 = new TextDecoder().decode(a.display ?? a.bytes);
|
|
1692
|
-
const
|
|
1693
|
-
const
|
|
1694
|
-
const
|
|
1695
|
-
if (
|
|
1801
|
+
const paint = /(?:fill|stop-color)\s*[:=]\s*["']?\s*(rgba?\([^)]*\)|[^"';>\s]+)/gi;
|
|
1802
|
+
const values = [...text2.matchAll(paint)].map((m) => m[1].trim()).filter((v) => !/^(none|transparent|inherit|currentcolor|url\()/i.test(v));
|
|
1803
|
+
const ls = values.map(paintLuma).filter((x) => x !== null);
|
|
1804
|
+
if (ls.length) l = ls.reduce((x, y) => x + y, 0) / ls.length;
|
|
1805
|
+
else l = values.length ? 0.5 : 0;
|
|
1696
1806
|
}
|
|
1697
1807
|
cache2.set(a.bytes, l);
|
|
1698
1808
|
return l;
|
|
@@ -1710,6 +1820,7 @@ function rasterRenderOptions(ro) {
|
|
|
1710
1820
|
};
|
|
1711
1821
|
}
|
|
1712
1822
|
var C2 = CANVAS;
|
|
1823
|
+
var PAD = C2 / 4;
|
|
1713
1824
|
var H2 = C2 / 2;
|
|
1714
1825
|
var b64Cache = /* @__PURE__ */ new WeakMap();
|
|
1715
1826
|
var b64 = (bytes) => {
|
|
@@ -1728,11 +1839,32 @@ var b64 = (bytes) => {
|
|
|
1728
1839
|
};
|
|
1729
1840
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
1730
1841
|
var n = (x) => +x.toFixed(3);
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1842
|
+
var DEFAULT_TINT = { r: 0.25, g: 0.65, b: 0.63, a: 1 };
|
|
1843
|
+
function tintToRGBA(input) {
|
|
1844
|
+
try {
|
|
1845
|
+
const c = parseColor(normalizeColorInput(input));
|
|
1846
|
+
return c ? { ...toRGBA(c), a: 1 } : DEFAULT_TINT;
|
|
1847
|
+
} catch {
|
|
1848
|
+
return DEFAULT_TINT;
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
var num2 = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
1852
|
+
function safePosition(p) {
|
|
1853
|
+
if (!p || typeof p !== "object") return null;
|
|
1854
|
+
const o = p;
|
|
1855
|
+
const t = Array.isArray(o["translation-in-points"]) ? o["translation-in-points"] : [];
|
|
1856
|
+
const scale = num2(o.scale, 1);
|
|
1857
|
+
return { scale: scale > 0 ? scale : 1, tx: num2(t[0], 0), ty: num2(t[1], 0) };
|
|
1858
|
+
}
|
|
1859
|
+
function safeOrientation(o) {
|
|
1860
|
+
const pt = (v) => {
|
|
1861
|
+
const q = v;
|
|
1862
|
+
return q && typeof q === "object" && Number.isFinite(q.x) && Number.isFinite(q.y) ? { x: q.x, y: q.y } : null;
|
|
1863
|
+
};
|
|
1864
|
+
const r = o;
|
|
1865
|
+
const start = r && typeof r === "object" ? pt(r.start) : null;
|
|
1866
|
+
const stop = r && typeof r === "object" ? pt(r.stop) : null;
|
|
1867
|
+
return start && stop ? { start, stop } : void 0;
|
|
1736
1868
|
}
|
|
1737
1869
|
var colorOf = (s) => {
|
|
1738
1870
|
const c = typeof s === "string" ? parseColor(s) : null;
|
|
@@ -1779,10 +1911,10 @@ function paintFor(fill, defs, box, darkSystem) {
|
|
|
1779
1911
|
const c = colorOf(f["automatic-gradient"]);
|
|
1780
1912
|
return lin(shade(c, 0.22), shade(c, -0.08));
|
|
1781
1913
|
}
|
|
1782
|
-
if (Array.isArray(f["linear-gradient"])) {
|
|
1914
|
+
if (Array.isArray(f["linear-gradient"]) && f["linear-gradient"].length) {
|
|
1783
1915
|
const [a, b] = f["linear-gradient"];
|
|
1784
|
-
const o = f.orientation;
|
|
1785
|
-
return lin(colorOf(a), colorOf(b), o?.start, o?.stop);
|
|
1916
|
+
const o = safeOrientation(f.orientation);
|
|
1917
|
+
return lin(colorOf(a), colorOf(b ?? a), o?.start, o?.stop);
|
|
1786
1918
|
}
|
|
1787
1919
|
return null;
|
|
1788
1920
|
}
|
|
@@ -1795,22 +1927,25 @@ function renderSvg(doc, opts = {}) {
|
|
|
1795
1927
|
const dark = isDarkAppearance(appearance);
|
|
1796
1928
|
const isClear = appearance === "clear-light" || appearance === "clear-dark";
|
|
1797
1929
|
const isTinted = appearance === "tinted-light" || appearance === "tinted-dark";
|
|
1798
|
-
const tint =
|
|
1930
|
+
const tint = opts.tint ? tintToRGBA(opts.tint) : DEFAULT_TINT;
|
|
1931
|
+
const idioms = idiomsFor(ctx.idiom);
|
|
1932
|
+
const forPlatform = (s) => s.idiom === void 0 || idioms.includes(s.idiom);
|
|
1799
1933
|
const angle = (opts.lightAngle ?? 0) * Math.PI / 180;
|
|
1800
1934
|
const toLight = { x: Math.sin(angle), y: -Math.cos(angle) };
|
|
1801
1935
|
const defs = new Defs(opts.idPrefix ?? "");
|
|
1802
1936
|
const body = [];
|
|
1803
1937
|
const fillProp = doc.props.fill;
|
|
1804
1938
|
let bgFill = resolveProp(fillProp, ctx, "automatic");
|
|
1805
|
-
const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark");
|
|
1939
|
+
const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark" && forPlatform(s));
|
|
1806
1940
|
let darken = 0;
|
|
1807
1941
|
if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
|
|
1808
1942
|
let bgPaint;
|
|
1809
|
-
const P = mask === "circle" ? 32 : 0;
|
|
1943
|
+
const P = mask === "circle" || mask === "none" && ctx.idiom === "watchOS" ? 32 : 0;
|
|
1810
1944
|
const full = { x: -P, y: -P, w: C2 + 2 * P, h: C2 + 2 * P };
|
|
1811
|
-
const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted"
|
|
1945
|
+
const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" && forPlatform(s));
|
|
1812
1946
|
if (monoOverride) {
|
|
1813
|
-
|
|
1947
|
+
const mono = resolveProp(fillProp, { appearance: dark ? "tinted-dark" : "tinted-light", idiom: ctx.idiom }, "automatic");
|
|
1948
|
+
bgPaint = paintFor(mono ?? "automatic", defs, full, dark);
|
|
1814
1949
|
} else if (isClear) {
|
|
1815
1950
|
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);
|
|
1816
1951
|
} else if (isTinted) {
|
|
@@ -1851,7 +1986,14 @@ function renderSvg(doc, opts = {}) {
|
|
|
1851
1986
|
plainId = plain;
|
|
1852
1987
|
}
|
|
1853
1988
|
function drawGroup(g, backdropId) {
|
|
1854
|
-
const
|
|
1989
|
+
const gp = safePosition(resolveProp(g.props.position, ctx, null));
|
|
1990
|
+
const gs = gp?.scale ?? 1;
|
|
1991
|
+
const gtx = gp?.tx ?? 0;
|
|
1992
|
+
const gty = gp?.ty ?? 0;
|
|
1993
|
+
const gx0 = H2 + (-PAD - H2 - gtx) / gs;
|
|
1994
|
+
const gy0 = H2 + (-PAD - H2 - gty) / gs;
|
|
1995
|
+
const gw = (C2 + 2 * PAD) / gs;
|
|
1996
|
+
const localRegion = `x="${n(gx0)}" y="${n(gy0)}" width="${n(gw)}" height="${n(gw)}"`;
|
|
1855
1997
|
const layers = FRONT_FIRST ? [...g.layers].reverse() : [...g.layers];
|
|
1856
1998
|
const placed = [];
|
|
1857
1999
|
for (const l of layers) {
|
|
@@ -1859,14 +2001,23 @@ function renderSvg(doc, opts = {}) {
|
|
|
1859
2001
|
const name = resolveProp(l.props["image-name"], ctx, "");
|
|
1860
2002
|
const asset = doc.assets[name];
|
|
1861
2003
|
if (!asset || asset.kind === "pdf" || asset.kind === "other") continue;
|
|
1862
|
-
|
|
2004
|
+
if (!(asset.width > 0 && asset.height > 0 && Number.isFinite(asset.width) && Number.isFinite(asset.height))) continue;
|
|
2005
|
+
const pos = safePosition(resolveProp(l.props.position, ctx, null));
|
|
1863
2006
|
const s = pos?.scale ?? 1;
|
|
1864
|
-
const [tx, ty] = pos?.["translation-in-points"] ?? [0, 0];
|
|
1865
2007
|
const w = asset.width * s;
|
|
1866
2008
|
const h = asset.height * s;
|
|
1867
|
-
|
|
2009
|
+
const x = H2 + (pos?.tx ?? 0) - w / 2;
|
|
2010
|
+
const y = H2 + (pos?.ty ?? 0) - h / 2;
|
|
2011
|
+
const cx0 = H2 + gtx + (x - H2) * gs;
|
|
2012
|
+
const cy0 = H2 + gty + (y - H2) * gs;
|
|
2013
|
+
const cx1 = cx0 + w * gs;
|
|
2014
|
+
const cy1 = cy0 + h * gs;
|
|
2015
|
+
if (cx1 < -P || cy1 < -P || cx0 > C2 + P || cy0 > C2 + P) continue;
|
|
2016
|
+
placed.push({ layer: l, asset, x, y, w, h });
|
|
1868
2017
|
}
|
|
1869
2018
|
if (!placed.length) return null;
|
|
2019
|
+
const groupOpacity = Math.min(1, Math.max(0, num2(resolveProp(g.props.opacity, ctx, 1), 1)));
|
|
2020
|
+
if (groupOpacity <= 0) return null;
|
|
1870
2021
|
const imgId = /* @__PURE__ */ new Map();
|
|
1871
2022
|
const layerEls = [];
|
|
1872
2023
|
const glassEls = [];
|
|
@@ -1883,7 +2034,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1883
2034
|
const s = p.w / p.asset.width;
|
|
1884
2035
|
const place = `transform="translate(${n(p.x)} ${n(p.y)}) scale(${n(s)})"`;
|
|
1885
2036
|
const fill = resolveProp(p.layer.props.fill, ctx, null);
|
|
1886
|
-
const opacity = resolveProp(p.layer.props.opacity, ctx, 1);
|
|
2037
|
+
const opacity = Math.min(1, Math.max(0, num2(resolveProp(p.layer.props.opacity, ctx, 1), 1)));
|
|
1887
2038
|
const blend = cssBlend(resolveProp(p.layer.props["blend-mode"], ctx, "normal"));
|
|
1888
2039
|
const cb = contentBoxOf(p.asset);
|
|
1889
2040
|
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 };
|
|
@@ -1891,7 +2042,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1891
2042
|
let el;
|
|
1892
2043
|
if (paint) {
|
|
1893
2044
|
const mid = defs.id("lm");
|
|
1894
|
-
defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse"
|
|
2045
|
+
defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" ${localRegion}><use href="#${iid}" ${place}/></mask>`);
|
|
1895
2046
|
el = `<rect x="${n(p.x)}" y="${n(p.y)}" width="${n(p.w)}" height="${n(p.h)}" fill="${paint}" mask="url(#${mid})"/>`;
|
|
1896
2047
|
} else {
|
|
1897
2048
|
el = `<use href="#${iid}" ${place}/>`;
|
|
@@ -1903,43 +2054,53 @@ function renderSvg(doc, opts = {}) {
|
|
|
1903
2054
|
glassLumas.push(fill && fill !== "automatic" ? fillLuma(fill, dark) ?? assetLuma(p.asset) : assetLuma(p.asset));
|
|
1904
2055
|
}
|
|
1905
2056
|
}
|
|
1906
|
-
const gTransform =
|
|
2057
|
+
const gTransform = gp ? ` transform="translate(${n(H2 + gtx)} ${n(H2 + gty)}) scale(${n(gs)}) translate(${-H2} ${-H2})"` : "";
|
|
2058
|
+
const canvasRegion = `filterUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}"`;
|
|
2059
|
+
const groupRegion = `filterUnits="userSpaceOnUse" ${localRegion}`;
|
|
1907
2060
|
const contentId = defs.id("gc");
|
|
1908
2061
|
defs.add(`<g id="${contentId}"${gTransform}>${layerEls.join("")}</g>`);
|
|
1909
2062
|
contentIds.push(contentId);
|
|
1910
2063
|
const out = [];
|
|
1911
|
-
const
|
|
2064
|
+
const shadow0 = resolveProp(g.props.shadow, ctx, { kind: "neutral", opacity: 0.5 });
|
|
2065
|
+
const shadow = shadow0 && typeof shadow0 === "object" ? { kind: shadow0.kind, opacity: Math.min(1, Math.max(0, num2(shadow0.opacity, 0.5))) } : null;
|
|
1912
2066
|
if (effects && shadow && shadow.kind !== "none" && shadow.opacity > 0) {
|
|
1913
2067
|
const fid = defs.id("sh");
|
|
1914
2068
|
if (shadow.kind === "neutral") {
|
|
1915
2069
|
defs.add(
|
|
1916
|
-
`<filter id="${fid}"
|
|
2070
|
+
`<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>`
|
|
1917
2071
|
);
|
|
1918
2072
|
} else {
|
|
1919
2073
|
defs.add(
|
|
1920
|
-
`<filter id="${fid}"
|
|
2074
|
+
`<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>`
|
|
1921
2075
|
);
|
|
1922
2076
|
}
|
|
1923
2077
|
out.push(`<use href="#${contentId}" filter="url(#${fid})" opacity="${n(shadow.opacity)}"/>`);
|
|
1924
2078
|
}
|
|
1925
|
-
const
|
|
1926
|
-
const
|
|
2079
|
+
const tr0 = resolveProp(g.props.translucency, ctx, { enabled: true, value: 0.5 });
|
|
2080
|
+
const tr = tr0 && typeof tr0 === "object" ? { enabled: !!tr0.enabled, value: num2(tr0.value, 0.5) } : null;
|
|
2081
|
+
const blurRaw = resolveProp(g.props["blur-material"], ctx, void 0);
|
|
2082
|
+
const blurMat = blurRaw === null ? null : num2(blurRaw, 0.2);
|
|
1927
2083
|
const translucent = effects && !!tr?.enabled && tr.value > 0;
|
|
1928
|
-
const
|
|
2084
|
+
const refr0 = resolveProp(g.props.refractivity, ctx, null);
|
|
2085
|
+
const refr = refr0 && typeof refr0 === "object" ? { enabled: !!refr0.enabled, strength: num2(refr0.strength, 0), depth: num2(refr0.depth, 0) } : null;
|
|
1929
2086
|
const refracts = effects && !!refr?.enabled && glassEls.length > 0;
|
|
1930
2087
|
if (translucent || refracts) {
|
|
1931
2088
|
const maskSrc = translucent ? `<use href="#${contentId}"/>` : `<g${gTransform}>${glassEls.join("")}</g>`;
|
|
1932
2089
|
const mid = defs.id("gm");
|
|
1933
|
-
defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="
|
|
1934
|
-
const
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2090
|
+
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>`);
|
|
2091
|
+
const blurPx = Math.max(0, (blurMat === null ? 0 : 4 + blurMat * 40) + (refracts ? refr.depth * 10 : 0));
|
|
2092
|
+
let filterAttr = "";
|
|
2093
|
+
if (blurPx > 0) {
|
|
2094
|
+
const fid = defs.id("bk");
|
|
2095
|
+
defs.add(`<filter id="${fid}" ${canvasRegion}><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
|
|
2096
|
+
filterAttr = ` filter="url(#${fid})"`;
|
|
2097
|
+
}
|
|
2098
|
+
const zoom = 1.02 + (refracts ? refr.strength * 0.12 : 0.015);
|
|
1938
2099
|
out.push(
|
|
1939
|
-
`<g mask="url(#${mid})"><use href="#${backdropId}"
|
|
2100
|
+
`<g mask="url(#${mid})"><use href="#${backdropId}"${filterAttr} transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
|
|
1940
2101
|
);
|
|
1941
2102
|
}
|
|
1942
|
-
const gOpacity =
|
|
2103
|
+
const gOpacity = refracts && !translucent ? 1 - Math.min(1, Math.max(0, refr.strength)) * 0.3 : 1;
|
|
1943
2104
|
let fadeMask = "";
|
|
1944
2105
|
if (translucent) {
|
|
1945
2106
|
const v = Math.min(1, Math.max(0, tr.value));
|
|
@@ -1965,15 +2126,15 @@ function renderSvg(doc, opts = {}) {
|
|
|
1965
2126
|
const fid = defs.id("sp");
|
|
1966
2127
|
if (specular === "outside") {
|
|
1967
2128
|
defs.add(
|
|
1968
|
-
`<filter id="${fid}"
|
|
2129
|
+
`<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>`
|
|
1969
2130
|
);
|
|
1970
2131
|
} else defs.add(
|
|
1971
|
-
`<filter id="${fid}"
|
|
2132
|
+
`<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>`
|
|
1972
2133
|
);
|
|
1973
2134
|
const glass = individual ? glassEls.map((e) => `<g filter="url(#${fid})">${e}</g>`).join("") : `<g filter="url(#${fid})">${glassEls.join("")}</g>`;
|
|
1974
2135
|
out.push(`<g${gTransform}>${glass}</g>`);
|
|
1975
2136
|
}
|
|
1976
|
-
return out.join("");
|
|
2137
|
+
return groupOpacity < 1 ? `<g opacity="${n(groupOpacity)}">${out.join("")}</g>` : out.join("");
|
|
1977
2138
|
}
|
|
1978
2139
|
let top = `<use href="#${stackId}"/>`;
|
|
1979
2140
|
if ((isClear || isTinted) && contentIds.length) {
|
|
@@ -1995,18 +2156,19 @@ function renderSvg(doc, opts = {}) {
|
|
|
1995
2156
|
}
|
|
1996
2157
|
const mid = defs.id("fm");
|
|
1997
2158
|
defs.add(
|
|
1998
|
-
`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="
|
|
2159
|
+
`<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>`
|
|
1999
2160
|
);
|
|
2000
2161
|
top = `<use href="#${bgStackId}"/><g filter="url(#${fid})"><use href="#${stackId}" mask="url(#${mid})"/></g>`;
|
|
2001
2162
|
}
|
|
2002
2163
|
const clipId = defs.id("clip");
|
|
2003
|
-
const shape = maskPath(mask);
|
|
2164
|
+
const shape = maskPath(mask, P);
|
|
2004
2165
|
defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
|
|
2005
|
-
const
|
|
2166
|
+
const inset = Math.min(C2 / 2 - 1, Math.max(0, num2(opts.inset, 0)));
|
|
2167
|
+
const M = inset > 0 ? inset * C2 / (C2 - 2 * inset) : 0;
|
|
2006
2168
|
if (M > 0) {
|
|
2007
2169
|
const sid = defs.id("ish");
|
|
2008
2170
|
defs.add(
|
|
2009
|
-
`<filter id="${sid}" x="-
|
|
2171
|
+
`<filter id="${sid}" filterUnits="userSpaceOnUse" x="${-(M + 64)}" y="${-(M + 64)}" width="${C2 + 2 * (M + 64)}" height="${C2 + 2 * (M + 64)}"><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>`
|
|
2010
2172
|
);
|
|
2011
2173
|
body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
|
|
2012
2174
|
} else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
|
|
@@ -2021,8 +2183,9 @@ function renderSvg(doc, opts = {}) {
|
|
|
2021
2183
|
}
|
|
2022
2184
|
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>`;
|
|
2023
2185
|
}
|
|
2186
|
+
var KNOWN_BLEND = new Set(BLEND_MODES);
|
|
2024
2187
|
function cssBlend(mode) {
|
|
2025
|
-
if (!mode || mode === "normal") return null;
|
|
2188
|
+
if (typeof mode !== "string" || !KNOWN_BLEND.has(mode) || mode === "normal") return null;
|
|
2026
2189
|
if (mode === "plus-lighter") return "screen";
|
|
2027
2190
|
if (mode === "plus-darker") return "multiply";
|
|
2028
2191
|
return mode;
|
|
@@ -2065,6 +2228,7 @@ async function resvg() {
|
|
|
2065
2228
|
}
|
|
2066
2229
|
}
|
|
2067
2230
|
async function svgToPng(svg, width) {
|
|
2231
|
+
if (!(width >= 2)) throw new Error(`Image size must be at least 2 px (got ${width})`);
|
|
2068
2232
|
const { Resvg } = await resvg();
|
|
2069
2233
|
const r = new Resvg(svg, { fitTo: { mode: "width", value: Math.round(width) }, font: { loadSystemFonts: true } });
|
|
2070
2234
|
return new Uint8Array(r.render().asPng());
|
|
@@ -2112,6 +2276,7 @@ var groupsOutline = z2.array(z2.looseObject({ group: z2.string(), layers: z2.arr
|
|
|
2112
2276
|
function describeProps(props) {
|
|
2113
2277
|
const base = {};
|
|
2114
2278
|
const overrides = {};
|
|
2279
|
+
const variants = /* @__PURE__ */ new Map();
|
|
2115
2280
|
for (const [k, p] of Object.entries(props)) {
|
|
2116
2281
|
const b = p.specs?.find((s) => s.appearance === void 0 && s.idiom === void 0);
|
|
2117
2282
|
if (b) base[k] = b.value;
|
|
@@ -2120,21 +2285,32 @@ function describeProps(props) {
|
|
|
2120
2285
|
const s = p.specs.find((e) => e.appearance === a && e.idiom === void 0);
|
|
2121
2286
|
(overrides[a] ??= {})[k] = s.value;
|
|
2122
2287
|
}
|
|
2288
|
+
for (const s of p.specs ?? []) {
|
|
2289
|
+
if (s.idiom === void 0) continue;
|
|
2290
|
+
const key = `${s.appearance ?? ""}|${s.idiom}`;
|
|
2291
|
+
if (!variants.has(key)) variants.set(key, { ...s.appearance ? { appearance: s.appearance } : {}, platform: s.idiom, values: {} });
|
|
2292
|
+
variants.get(key).values[k] = s.value;
|
|
2293
|
+
}
|
|
2123
2294
|
}
|
|
2124
|
-
return
|
|
2295
|
+
return {
|
|
2296
|
+
...base,
|
|
2297
|
+
...Object.keys(overrides).length ? { overrides } : {},
|
|
2298
|
+
...variants.size ? { variants: [...variants.values()] } : {}
|
|
2299
|
+
};
|
|
2125
2300
|
}
|
|
2126
2301
|
function inspect(doc) {
|
|
2127
2302
|
return {
|
|
2128
2303
|
order: "groups and layers are listed front to back (index 0 = frontmost)",
|
|
2129
2304
|
document: describeProps(doc.props),
|
|
2305
|
+
// attributes first, so an unknown key in the file (e.g. "target") can't replace these fields
|
|
2130
2306
|
groups: doc.groups.map((g, gi) => ({
|
|
2307
|
+
...describeProps(g.props),
|
|
2131
2308
|
target: nameOf(g) || `#${gi}`,
|
|
2132
2309
|
index: gi,
|
|
2133
|
-
...describeProps(g.props),
|
|
2134
2310
|
layers: g.layers.map((l, li) => ({
|
|
2311
|
+
...describeProps(l.props),
|
|
2135
2312
|
target: `${nameOf(g) || `#${gi}`}/${nameOf(l) || `#${li}`}`,
|
|
2136
|
-
index: li
|
|
2137
|
-
...describeProps(l.props)
|
|
2313
|
+
index: li
|
|
2138
2314
|
}))
|
|
2139
2315
|
})),
|
|
2140
2316
|
assets: Object.values(doc.assets).map((a) => ({ name: a.name, kind: a.kind, width: a.width, height: a.height }))
|
|
@@ -2152,9 +2328,7 @@ function targetLabel(doc, ref) {
|
|
|
2152
2328
|
}
|
|
2153
2329
|
async function saveFile(ws, out, bytes) {
|
|
2154
2330
|
const p = await ws.resolve(out);
|
|
2155
|
-
await
|
|
2156
|
-
await writeFile2(p, bytes);
|
|
2157
|
-
return p;
|
|
2331
|
+
return (await writeFiles(dirname2(p), { [basename(p)]: bytes }))[0];
|
|
2158
2332
|
}
|
|
2159
2333
|
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() })) });
|
|
2160
2334
|
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>';
|
|
@@ -2179,7 +2353,9 @@ var TOOLS = [
|
|
|
2179
2353
|
},
|
|
2180
2354
|
output: loose({ created: z2.string(), dry_run: z2.boolean().optional(), groups: z2.array(z2.unknown()) }),
|
|
2181
2355
|
mutates_input: true,
|
|
2356
|
+
writes_files: true,
|
|
2182
2357
|
idempotency_hint: "not-idempotent",
|
|
2358
|
+
destructive: true,
|
|
2183
2359
|
supports_dry_run: true,
|
|
2184
2360
|
verification: ["icon_inspect shows the document fill", "icon_render_sheet shows the background in all appearances"],
|
|
2185
2361
|
requires_visual_verification: false,
|
|
@@ -2187,14 +2363,16 @@ var TOOLS = [
|
|
|
2187
2363
|
async run(ws, { path, background, dark_background, watch, overwrite, dry_run }) {
|
|
2188
2364
|
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)");
|
|
2189
2365
|
const full = await ws.iconPath(path);
|
|
2190
|
-
const exists = await stat2(full).then(() => true, () => false);
|
|
2191
|
-
if (exists && !overwrite) throw new Error(`${path} already exists. Pass overwrite: true to replace it.`);
|
|
2192
2366
|
const doc = createDocument({
|
|
2193
2367
|
...background ? { background } : {},
|
|
2194
2368
|
platforms: watch === false ? { squares: "shared" } : { circles: ["watchOS"], squares: "shared" }
|
|
2195
2369
|
});
|
|
2196
2370
|
if (dark_background) setProp(doc, { kind: "document" }, "fill", dark_background, "dark");
|
|
2197
|
-
|
|
2371
|
+
await ws.withLock(full, async () => {
|
|
2372
|
+
const exists = await stat2(full).then(() => true, () => false);
|
|
2373
|
+
if (exists && !overwrite) throw new Error(`${path} already exists. Pass overwrite: true to replace it.`);
|
|
2374
|
+
if (!dry_run) await saveIcon(full, doc);
|
|
2375
|
+
});
|
|
2198
2376
|
return { data: { created: full, ...dry_run ? { dry_run: true } : {}, ...inspect(doc) } };
|
|
2199
2377
|
}
|
|
2200
2378
|
}),
|
|
@@ -2210,6 +2388,7 @@ var TOOLS = [
|
|
|
2210
2388
|
input: { path: pathArg },
|
|
2211
2389
|
output: inspectOutput,
|
|
2212
2390
|
mutates_input: false,
|
|
2391
|
+
writes_files: false,
|
|
2213
2392
|
idempotency_hint: "idempotent",
|
|
2214
2393
|
supports_dry_run: false,
|
|
2215
2394
|
verification: [],
|
|
@@ -2238,6 +2417,7 @@ var TOOLS = [
|
|
|
2238
2417
|
},
|
|
2239
2418
|
output: loose({ added: z2.string(), groups: z2.array(z2.string()) }),
|
|
2240
2419
|
mutates_input: true,
|
|
2420
|
+
writes_files: false,
|
|
2241
2421
|
idempotency_hint: "not-idempotent",
|
|
2242
2422
|
supports_dry_run: true,
|
|
2243
2423
|
verification: ["icon_inspect lists the new group"],
|
|
@@ -2280,6 +2460,7 @@ var TOOLS = [
|
|
|
2280
2460
|
},
|
|
2281
2461
|
output: loose({ added: z2.string(), size: z2.string(), warnings: z2.array(z2.string()) }),
|
|
2282
2462
|
mutates_input: true,
|
|
2463
|
+
writes_files: false,
|
|
2283
2464
|
idempotency_hint: "not-idempotent",
|
|
2284
2465
|
supports_dry_run: true,
|
|
2285
2466
|
verification: ["Check `warnings` (V-01\u2026V-10)", "icon_render_sheet to see the layer in every appearance"],
|
|
@@ -2355,6 +2536,7 @@ var TOOLS = [
|
|
|
2355
2536
|
},
|
|
2356
2537
|
output: loose({ target: z2.string(), now: z2.record(z2.string(), z2.unknown()) }),
|
|
2357
2538
|
mutates_input: true,
|
|
2539
|
+
writes_files: false,
|
|
2358
2540
|
idempotency_hint: "idempotent",
|
|
2359
2541
|
supports_dry_run: true,
|
|
2360
2542
|
verification: ["`now` shows the stored values", "icon_render_sheet for the appearances you changed"],
|
|
@@ -2390,6 +2572,7 @@ var TOOLS = [
|
|
|
2390
2572
|
input: { path: pathArg, target: z2.string(), index: z2.number().int().min(0), to_group: z2.string().optional(), dry_run: dryRunArg },
|
|
2391
2573
|
output: loose({ groups: groupsOutline }),
|
|
2392
2574
|
mutates_input: true,
|
|
2575
|
+
writes_files: false,
|
|
2393
2576
|
idempotency_hint: "idempotent",
|
|
2394
2577
|
supports_dry_run: true,
|
|
2395
2578
|
verification: ["`groups` lists the new order (front to back)"],
|
|
@@ -2403,6 +2586,7 @@ var TOOLS = [
|
|
|
2403
2586
|
const ref = resolvePath(doc, target);
|
|
2404
2587
|
let gid;
|
|
2405
2588
|
if (to_group) {
|
|
2589
|
+
if (ref.kind !== "layer") throw new Error("to_group only applies to layers; groups are reordered with index");
|
|
2406
2590
|
const g = resolvePath(doc, to_group);
|
|
2407
2591
|
if (g.kind !== "group") throw new Error("to_group must be a group");
|
|
2408
2592
|
gid = g.id;
|
|
@@ -2434,6 +2618,7 @@ var TOOLS = [
|
|
|
2434
2618
|
},
|
|
2435
2619
|
output: loose({ moved: z2.number(), positions: z2.array(z2.looseObject({ target: z2.string() })) }),
|
|
2436
2620
|
mutates_input: true,
|
|
2621
|
+
writes_files: false,
|
|
2437
2622
|
idempotency_hint: "idempotent",
|
|
2438
2623
|
supports_dry_run: true,
|
|
2439
2624
|
verification: ["`positions` gives each bounding box in canvas points", "icon_render to look at the result"],
|
|
@@ -2469,6 +2654,7 @@ var TOOLS = [
|
|
|
2469
2654
|
input: { path: pathArg, target: z2.string(), dry_run: dryRunArg },
|
|
2470
2655
|
output: loose({ removed: z2.string(), groups: groupsOutline }),
|
|
2471
2656
|
mutates_input: true,
|
|
2657
|
+
writes_files: false,
|
|
2472
2658
|
idempotency_hint: "not-idempotent",
|
|
2473
2659
|
destructive: true,
|
|
2474
2660
|
supports_dry_run: true,
|
|
@@ -2505,6 +2691,7 @@ var TOOLS = [
|
|
|
2505
2691
|
issues: z2.array(z2.looseObject({ id: z2.string(), severity: z2.enum(["error", "warning", "info"]), message: z2.string() }))
|
|
2506
2692
|
}),
|
|
2507
2693
|
mutates_input: false,
|
|
2694
|
+
writes_files: false,
|
|
2508
2695
|
idempotency_hint: "idempotent",
|
|
2509
2696
|
supports_dry_run: false,
|
|
2510
2697
|
verification: ['No issue with severity "error"'],
|
|
@@ -2531,12 +2718,13 @@ var TOOLS = [
|
|
|
2531
2718
|
platform: z2.enum(["iOS", "macOS", "watchOS"]).optional().describe("Default: iOS. watchOS renders a circle"),
|
|
2532
2719
|
size: z2.number().int().min(16).max(2048).optional().describe("Pixels, default 512"),
|
|
2533
2720
|
mask: maskEnum.optional(),
|
|
2534
|
-
tint: z2.string().optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
|
|
2721
|
+
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"'),
|
|
2535
2722
|
light_angle: z2.number().min(-180).max(180).optional().describe("Light direction in degrees, 0 = top (default), -45 = top-left"),
|
|
2536
2723
|
out: z2.string().optional().describe("Save the PNG to this path")
|
|
2537
2724
|
},
|
|
2538
2725
|
output: loose({ appearance: z2.string(), saved: z2.string().optional() }),
|
|
2539
2726
|
mutates_input: false,
|
|
2727
|
+
writes_files: true,
|
|
2540
2728
|
idempotency_hint: "idempotent",
|
|
2541
2729
|
supports_dry_run: false,
|
|
2542
2730
|
verification: ["Look at the returned image"],
|
|
@@ -2569,11 +2757,12 @@ var TOOLS = [
|
|
|
2569
2757
|
input: {
|
|
2570
2758
|
path: pathArg,
|
|
2571
2759
|
cell: z2.number().int().min(96).max(600).optional().describe("Cell size in pixels, default 240"),
|
|
2572
|
-
tint: z2.string().optional(),
|
|
2760
|
+
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"'),
|
|
2573
2761
|
out: z2.string().optional()
|
|
2574
2762
|
},
|
|
2575
2763
|
output: loose({ appearances: z2.array(z2.string()), saved: z2.string().optional() }),
|
|
2576
2764
|
mutates_input: false,
|
|
2765
|
+
writes_files: true,
|
|
2577
2766
|
idempotency_hint: "idempotent",
|
|
2578
2767
|
supports_dry_run: false,
|
|
2579
2768
|
verification: ["Look at the returned image: every appearance should stay legible"],
|
|
@@ -2599,12 +2788,14 @@ var TOOLS = [
|
|
|
2599
2788
|
path: pathArg,
|
|
2600
2789
|
out_dir: z2.string(),
|
|
2601
2790
|
formats: z2.array(z2.enum(["icon", "icns", "appiconset", "png"])).min(1),
|
|
2602
|
-
sizes: z2.array(z2.number().int().min(16).max(2048)).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
|
|
2603
|
-
appearances: z2.array(appearanceEnum).optional().describe("PNG appearances, default all six")
|
|
2791
|
+
sizes: z2.array(z2.number().int().min(16).max(2048)).min(1).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
|
|
2792
|
+
appearances: z2.array(appearanceEnum).min(1).optional().describe("PNG appearances, default all six")
|
|
2604
2793
|
},
|
|
2605
2794
|
output: loose({ out_dir: z2.string(), files: z2.number(), written: z2.array(z2.string()) }),
|
|
2606
2795
|
mutates_input: false,
|
|
2796
|
+
writes_files: true,
|
|
2607
2797
|
idempotency_hint: "idempotent",
|
|
2798
|
+
destructive: true,
|
|
2608
2799
|
supports_dry_run: false,
|
|
2609
2800
|
verification: ["`written` lists every file", "Open the .icon in Icon Composer / Xcode 26 on a Mac for the final check"],
|
|
2610
2801
|
requires_visual_verification: false,
|
|
@@ -2612,6 +2803,9 @@ var TOOLS = [
|
|
|
2612
2803
|
async run(ws, { path, out_dir, formats, sizes, appearances }) {
|
|
2613
2804
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2614
2805
|
const outDir = await ws.resolve(out_dir);
|
|
2806
|
+
const inside = relative2(full, outDir);
|
|
2807
|
+
if (inside === "" || !inside.startsWith(`..${sep2}`) && inside !== ".." && !isAbsolute2(inside))
|
|
2808
|
+
throw new Error("out_dir is inside the icon bundle; choose a folder outside it");
|
|
2615
2809
|
const doc = await ws.read(full);
|
|
2616
2810
|
const name = basename(full).replace(/\.icon(\.zip)?$/, "");
|
|
2617
2811
|
const raster = nodeRasterizer(doc);
|
|
@@ -2620,16 +2814,9 @@ var TOOLS = [
|
|
|
2620
2814
|
throw new Error(`These visible layers can't be rendered and would be missing from the images: ${missing.join(", ")}. Fix them first (icon_validate).`);
|
|
2621
2815
|
const written = [];
|
|
2622
2816
|
if (formats.includes("icon")) {
|
|
2623
|
-
|
|
2624
|
-
await saveIcon(dest, doc);
|
|
2625
|
-
written.push(join2(dest, "icon.json"), ...Object.keys(doc.assets).map((a) => join2(dest, "Assets", a)));
|
|
2626
|
-
}
|
|
2627
|
-
if (formats.includes("icns")) {
|
|
2628
|
-
const p = join2(outDir, `${name}.icns`);
|
|
2629
|
-
await mkdir2(dirname2(p), { recursive: true });
|
|
2630
|
-
await writeFile2(p, await buildIcns(raster));
|
|
2631
|
-
written.push(p);
|
|
2817
|
+
written.push(...await saveIcon(join2(outDir, `${name}.icon`), doc));
|
|
2632
2818
|
}
|
|
2819
|
+
if (formats.includes("icns")) written.push(...await writeFiles(outDir, { [`${name}.icns`]: await buildIcns(raster) }));
|
|
2633
2820
|
if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
|
|
2634
2821
|
if (formats.includes("png"))
|
|
2635
2822
|
written.push(
|
|
@@ -2646,7 +2833,7 @@ async function runTool(ws, t, args) {
|
|
|
2646
2833
|
}
|
|
2647
2834
|
|
|
2648
2835
|
// src/version.ts
|
|
2649
|
-
var VERSION = "0.2.
|
|
2836
|
+
var VERSION = "0.2.2";
|
|
2650
2837
|
|
|
2651
2838
|
// src/contract.ts
|
|
2652
2839
|
var RESOURCES = [
|
|
@@ -2683,6 +2870,7 @@ function toolSpec(t) {
|
|
|
2683
2870
|
input_schema: jsonSchema(z3.object(t.input).strict(), "input"),
|
|
2684
2871
|
output_schema: jsonSchema(t.output, "output"),
|
|
2685
2872
|
mutates_input: t.mutates_input,
|
|
2873
|
+
writes_files: t.writes_files,
|
|
2686
2874
|
idempotency_hint: t.idempotency_hint,
|
|
2687
2875
|
destructive: !!t.destructive,
|
|
2688
2876
|
supports_dry_run: t.supports_dry_run,
|
|
@@ -2715,7 +2903,7 @@ function buildContract() {
|
|
|
2715
2903
|
rule: "Breaking changes bump contract_version to the next major. The frozen surface (packages/mcp/spec/surface.json) makes CI fail otherwise."
|
|
2716
2904
|
},
|
|
2717
2905
|
tools: TOOLS.map(toolSpec),
|
|
2718
|
-
capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a
|
|
2906
|
+
capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)),
|
|
2719
2907
|
resources: RESOURCES,
|
|
2720
2908
|
prompts: PROMPTS,
|
|
2721
2909
|
icon_json: { document: attrTable(DOCUMENT_ATTRS), group: attrTable(GROUP_ATTRS), layer: attrTable(LAYER_ATTRS) },
|
|
@@ -2809,10 +2997,11 @@ function createServer(ws) {
|
|
|
2809
2997
|
{
|
|
2810
2998
|
title: t.title,
|
|
2811
2999
|
description: t.description,
|
|
2812
|
-
|
|
3000
|
+
// strict: a misspelt argument (dryRun for dry_run) is an error, not silently dropped
|
|
3001
|
+
inputSchema: z4.object(t.input).strict(),
|
|
2813
3002
|
outputSchema: t.output,
|
|
2814
3003
|
annotations: {
|
|
2815
|
-
readOnlyHint: !t.mutates_input,
|
|
3004
|
+
readOnlyHint: !t.mutates_input && !t.writes_files,
|
|
2816
3005
|
destructiveHint: !!t.destructive,
|
|
2817
3006
|
idempotentHint: t.idempotency_hint === "idempotent",
|
|
2818
3007
|
openWorldHint: false
|
|
@@ -2886,8 +3075,8 @@ Use the fresnel tools:
|
|
|
2886
3075
|
}
|
|
2887
3076
|
|
|
2888
3077
|
// src/workspace.ts
|
|
2889
|
-
import { realpath, stat as stat3 } from "fs/promises";
|
|
2890
|
-
import { dirname as dirname3, isAbsolute as
|
|
3078
|
+
import { lstat as lstat2, realpath, stat as stat3 } from "fs/promises";
|
|
3079
|
+
import { dirname as dirname3, isAbsolute as isAbsolute3, relative as relative3, resolve as resolve2, sep as sep3 } from "path";
|
|
2891
3080
|
var Workspace = class {
|
|
2892
3081
|
roots;
|
|
2893
3082
|
locks = /* @__PURE__ */ new Map();
|
|
@@ -2900,13 +3089,19 @@ var Workspace = class {
|
|
|
2900
3089
|
/** Resolves a user path (relative paths are relative to the first root) and checks it's allowed. */
|
|
2901
3090
|
async resolve(p, opts = {}) {
|
|
2902
3091
|
if (!p || typeof p !== "string") throw new Error("A path is required");
|
|
2903
|
-
const abs =
|
|
3092
|
+
const abs = isAbsolute3(p) ? resolve2(p) : resolve2(this.roots[0], p);
|
|
2904
3093
|
let probe = abs;
|
|
2905
3094
|
let suffix = "";
|
|
2906
3095
|
for (; ; ) {
|
|
2907
3096
|
try {
|
|
2908
3097
|
const real = await realpath(probe);
|
|
2909
3098
|
const full = suffix ? resolve2(real, suffix) : real;
|
|
3099
|
+
if (suffix) {
|
|
3100
|
+
const first = resolve2(real, suffix.split(sep3)[0]);
|
|
3101
|
+
const st = await lstat2(first).catch(() => null);
|
|
3102
|
+
if (st?.isSymbolicLink()) throw new Error(`Access denied: ${p} goes through a symbolic link that points nowhere`);
|
|
3103
|
+
if (st) throw new Error(`Invalid path: ${p} (${suffix.split(sep3)[0]} is not a folder)`);
|
|
3104
|
+
}
|
|
2910
3105
|
if (!await this.allowed(full)) throw new Error(`Access denied: ${p} is outside the allowed roots (${this.roots.join(", ")})`);
|
|
2911
3106
|
if (opts.mustExist && full !== real) throw new Error(`Not found: ${p}`);
|
|
2912
3107
|
return full;
|
|
@@ -2915,7 +3110,7 @@ var Workspace = class {
|
|
|
2915
3110
|
if (opts.mustExist) throw new Error(`Not found: ${p}`);
|
|
2916
3111
|
const parent = dirname3(probe);
|
|
2917
3112
|
if (parent === probe) throw new Error(`Invalid path: ${p}`);
|
|
2918
|
-
suffix = suffix ? `${probe.slice(parent.length + 1)}${
|
|
3113
|
+
suffix = suffix ? `${probe.slice(parent.length + 1)}${sep3}${suffix}` : probe.slice(parent.length + 1);
|
|
2919
3114
|
probe = parent;
|
|
2920
3115
|
}
|
|
2921
3116
|
}
|
|
@@ -2928,15 +3123,17 @@ var Workspace = class {
|
|
|
2928
3123
|
root = await realpath(r);
|
|
2929
3124
|
} catch {
|
|
2930
3125
|
}
|
|
2931
|
-
const rel =
|
|
2932
|
-
if (rel === "" || !rel.startsWith(
|
|
3126
|
+
const rel = relative3(root, full);
|
|
3127
|
+
if (rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute3(rel)) return true;
|
|
2933
3128
|
}
|
|
2934
3129
|
return false;
|
|
2935
3130
|
}
|
|
2936
3131
|
/** Normalizes an icon path: accepts "Foo", "Foo.icon", ".../Foo.icon/icon.json". */
|
|
2937
3132
|
async iconPath(p, opts = {}) {
|
|
2938
|
-
let q = p.trim();
|
|
3133
|
+
let q = (typeof p === "string" ? p : "").trim().replace(/[\\/]+$/, "");
|
|
2939
3134
|
if (q.endsWith("/icon.json") || q.endsWith("\\icon.json")) q = q.slice(0, -"/icon.json".length);
|
|
3135
|
+
const base = q.split(/[\\/]/).pop() ?? "";
|
|
3136
|
+
if (!base || base === ".icon" || base === ".icon.zip" || base === "." || base === "..") throw new Error(`Invalid icon path "${p}": give a name such as "MyApp.icon"`);
|
|
2940
3137
|
if (!q.endsWith(".icon") && !q.endsWith(".icon.zip")) q = `${q}.icon`;
|
|
2941
3138
|
const full = await this.resolve(q, opts);
|
|
2942
3139
|
if (opts.mustExist) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fresnel-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "MCP server for fresnel: let AI agents create, edit, preview and export Liquid Glass app icons (.icon bundles)",
|
|
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/
|
|
42
|
-
"@fresnel/
|
|
41
|
+
"@fresnel/core": "0.2.2",
|
|
42
|
+
"@fresnel/render": "0.2.2"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsup",
|