fresnel-mcp 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/server.js +315 -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);
|
|
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]);
|
|
455
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
|
}
|
|
@@ -1023,31 +1084,40 @@ function normalizeFillColors(f) {
|
|
|
1023
1084
|
const o = { ...f };
|
|
1024
1085
|
if (typeof o.solid === "string") o.solid = normalizeColorInput(o.solid);
|
|
1025
1086
|
if (typeof o["automatic-gradient"] === "string") o["automatic-gradient"] = normalizeColorInput(o["automatic-gradient"]);
|
|
1026
|
-
if (Array.isArray(o["linear-gradient"]))
|
|
1087
|
+
if (Array.isArray(o["linear-gradient"])) {
|
|
1088
|
+
if (!o["linear-gradient"].every((c) => typeof c === "string")) throw new OpError("linear-gradient takes a list of color strings");
|
|
1089
|
+
o["linear-gradient"] = o["linear-gradient"].map(normalizeColorInput);
|
|
1090
|
+
}
|
|
1027
1091
|
return o;
|
|
1028
1092
|
}
|
|
1029
1093
|
function removeNode(doc, ref) {
|
|
1030
1094
|
if (ref.kind === "document") throw new OpError("The document can't be removed");
|
|
1095
|
+
let removedImages;
|
|
1031
1096
|
if (ref.kind === "group") {
|
|
1032
1097
|
const i = doc.groups.findIndex((g) => g.id === ref.id);
|
|
1033
1098
|
if (i < 0) throw new OpError("Group not found");
|
|
1099
|
+
removedImages = imagesOf(doc.groups[i].layers);
|
|
1034
1100
|
doc.groups.splice(i, 1);
|
|
1035
1101
|
} else {
|
|
1036
1102
|
const f = findLayer(doc, ref.id);
|
|
1037
1103
|
if (!f) throw new OpError("Layer not found");
|
|
1104
|
+
removedImages = imagesOf([f.layer]);
|
|
1038
1105
|
f.group.layers.splice(f.index, 1);
|
|
1039
1106
|
}
|
|
1040
|
-
pruneAssets(doc);
|
|
1107
|
+
pruneAssets(doc, removedImages);
|
|
1041
1108
|
}
|
|
1042
|
-
function
|
|
1109
|
+
function imagesOf(layers) {
|
|
1043
1110
|
const used = /* @__PURE__ */ new Set();
|
|
1044
|
-
for (const
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1111
|
+
for (const l of layers) {
|
|
1112
|
+
const p = l.props["image-name"];
|
|
1113
|
+
if (p?.plain && typeof p.plain.value === "string") used.add(p.plain.value);
|
|
1114
|
+
for (const s of p?.specs ?? []) if (typeof s.value === "string") used.add(s.value);
|
|
1115
|
+
}
|
|
1116
|
+
return used;
|
|
1117
|
+
}
|
|
1118
|
+
function pruneAssets(doc, only) {
|
|
1119
|
+
const used = imagesOf(doc.groups.flatMap((g) => g.layers));
|
|
1120
|
+
for (const k of Object.keys(doc.assets)) if (!used.has(k) && (!only || only.has(k))) delete doc.assets[k];
|
|
1051
1121
|
}
|
|
1052
1122
|
function moveNode(doc, ref, index, toGroupId) {
|
|
1053
1123
|
if (ref.kind === "document") throw new OpError("The document can't be moved");
|
|
@@ -1114,9 +1184,10 @@ function validate(doc) {
|
|
|
1114
1184
|
let anyTinted = false;
|
|
1115
1185
|
const scan = (props) => {
|
|
1116
1186
|
for (const p of Object.values(props)) {
|
|
1117
|
-
const
|
|
1118
|
-
|
|
1119
|
-
|
|
1187
|
+
for (const v of variantsOf(p)) {
|
|
1188
|
+
if (v.appearance === "dark") anyDark = true;
|
|
1189
|
+
if (v.appearance === "tinted") anyTinted = true;
|
|
1190
|
+
}
|
|
1120
1191
|
}
|
|
1121
1192
|
};
|
|
1122
1193
|
scan(doc.props);
|
|
@@ -1147,19 +1218,22 @@ function validate(doc) {
|
|
|
1147
1218
|
}
|
|
1148
1219
|
if (Math.abs(a.width - a.height) > 0.5) {
|
|
1149
1220
|
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}"
|
|
1221
|
+
} else if (a.kind !== "svg" && pos.scale > 1.01) {
|
|
1222
|
+
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
1223
|
}
|
|
1153
1224
|
if (a.kind === "png" && a.width === a.height && a.width >= 256) {
|
|
1154
1225
|
const bmp = safeDecode(a.bytes);
|
|
1155
1226
|
if (bmp && looksPreMasked(bmp)) push("V-04", `"${n2}" seems to have rounded corners baked in. The system masks icons itself.`, lName, lref);
|
|
1156
1227
|
}
|
|
1157
1228
|
if (a.kind === "svg") {
|
|
1158
|
-
const
|
|
1229
|
+
const text2 = assetText(a);
|
|
1230
|
+
const f = svgFacts(text2);
|
|
1231
|
+
const vb = /<svg\b[^>]*\sviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)/i.exec(text2);
|
|
1232
|
+
const unit2 = vb && Number(vb[1]) > 0 ? svgSize(text2).width / Number(vb[1]) : 1;
|
|
1159
1233
|
if (f.hasText) push("V-07", `"${n2}" contains <text>. Convert text to outlines.`, lName, lref);
|
|
1160
1234
|
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);
|
|
1235
|
+
if (f.minStrokeWidth !== null && f.minStrokeWidth * unit2 * pos.scale < 10)
|
|
1236
|
+
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
1237
|
}
|
|
1164
1238
|
}
|
|
1165
1239
|
}
|
|
@@ -1375,10 +1449,24 @@ function moveBy(doc, ref, dx, dy, ctx, variant) {
|
|
|
1375
1449
|
props = f.layer.props;
|
|
1376
1450
|
scale = resolveProp(f.group.props.position, ctx, null)?.scale ?? 1;
|
|
1377
1451
|
}
|
|
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
1452
|
const round2 = (v) => Math.round(v * 100) / 100;
|
|
1381
|
-
|
|
1453
|
+
const shift = (p) => {
|
|
1454
|
+
const q = p ?? { scale: 1, "translation-in-points": [0, 0] };
|
|
1455
|
+
const [x, y] = q["translation-in-points"] ?? [0, 0];
|
|
1456
|
+
return { ...q, "translation-in-points": [round2(x + dx / scale), round2(y + dy / scale)] };
|
|
1457
|
+
};
|
|
1458
|
+
const vv = toVariant(variant);
|
|
1459
|
+
if (vv.appearance !== void 0 || vv.idiom !== void 0) {
|
|
1460
|
+
const cur = variantValue(props.position, vv) ?? resolveProp(props.position, ctx, null);
|
|
1461
|
+
setProp(doc, ref, "position", shift(cur), variant);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const pos = props.position;
|
|
1465
|
+
setProp(doc, ref, "position", shift(baseValue(pos)), void 0);
|
|
1466
|
+
for (const e of pos?.specs ?? []) {
|
|
1467
|
+
if (e.appearance === void 0 && e.idiom === void 0) continue;
|
|
1468
|
+
if (e.value && typeof e.value === "object") e.value = shift(e.value);
|
|
1469
|
+
}
|
|
1382
1470
|
}
|
|
1383
1471
|
function alignNodes(doc, refs, how, ctx, opts = {}) {
|
|
1384
1472
|
const boxes = refs.map((r) => ({ ref: r, box: boxOf(doc, r, ctx) })).filter((x) => !!x.box);
|
|
@@ -1495,8 +1583,8 @@ function exampleCommand(t, args) {
|
|
|
1495
1583
|
}
|
|
1496
1584
|
|
|
1497
1585
|
// src/spec.ts
|
|
1498
|
-
import {
|
|
1499
|
-
import { basename, dirname as dirname2, join as join2 } from "path";
|
|
1586
|
+
import { readFile as readFile2, stat as stat2 } from "fs/promises";
|
|
1587
|
+
import { basename, dirname as dirname2, isAbsolute as isAbsolute2, join as join2, relative as relative2, sep as sep2 } from "path";
|
|
1500
1588
|
import { z as z2 } from "zod";
|
|
1501
1589
|
|
|
1502
1590
|
// ../core/src/node.ts
|
|
@@ -1540,6 +1628,7 @@ async function saveIcon(path, doc) {
|
|
|
1540
1628
|
const assetsDir = join(root, "Assets");
|
|
1541
1629
|
await assertNoSymlink(root, assetsDir);
|
|
1542
1630
|
await mkdir(assetsDir, { recursive: true });
|
|
1631
|
+
const written = [];
|
|
1543
1632
|
for (const [rel, bytes] of Object.entries(files)) {
|
|
1544
1633
|
const target = resolve(root, ...rel.split("/"));
|
|
1545
1634
|
await assertNoSymlink(root, target);
|
|
@@ -1549,16 +1638,20 @@ async function saveIcon(path, doc) {
|
|
|
1549
1638
|
await rm(tmp, { force: true });
|
|
1550
1639
|
await writeFile(tmp, bytes, { flag: "wx" });
|
|
1551
1640
|
await rename(tmp, target);
|
|
1641
|
+
written.push(target);
|
|
1552
1642
|
}
|
|
1553
1643
|
const keep = new Set(Object.keys(doc.assets));
|
|
1554
1644
|
await assertNoSymlink(root, assetsDir);
|
|
1555
1645
|
for (const e of await readdir(assetsDir, { withFileTypes: true })) {
|
|
1556
|
-
if (e.isFile()
|
|
1646
|
+
if (!e.isFile() || keep.has(e.name)) continue;
|
|
1647
|
+
if (e.name.endsWith(".fresnel-tmp") || isSafeBundlePath(`Assets/${e.name}`) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
|
|
1557
1648
|
}
|
|
1649
|
+
return written;
|
|
1558
1650
|
}
|
|
1559
1651
|
async function writeFiles(dir, files) {
|
|
1560
1652
|
const written = [];
|
|
1561
1653
|
const root = resolve(dir);
|
|
1654
|
+
if ((await lstat(root).catch(() => null))?.isSymbolicLink()) throw new Error(`Refusing to write through a symbolic link: ${dir}`);
|
|
1562
1655
|
for (const [rel, bytes] of Object.entries(files)) {
|
|
1563
1656
|
const target = resolve(root, ...rel.split("/"));
|
|
1564
1657
|
const r = relative(root, target);
|
|
@@ -1609,7 +1702,7 @@ function contentBoxOf(asset) {
|
|
|
1609
1702
|
}
|
|
1610
1703
|
function svgAssetsNeedingBounds(doc) {
|
|
1611
1704
|
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);
|
|
1705
|
+
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
1706
|
for (const g of doc.groups)
|
|
1614
1707
|
for (const l of g.layers) {
|
|
1615
1708
|
if (!hasGradient(l.props.fill)) continue;
|
|
@@ -1638,12 +1731,12 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
|
|
|
1638
1731
|
}
|
|
1639
1732
|
var SQUIRCLE = squirclePath();
|
|
1640
1733
|
var C = CANVAS;
|
|
1641
|
-
function maskPath(shape) {
|
|
1734
|
+
function maskPath(shape, pad = 0) {
|
|
1642
1735
|
if (shape === "circle") {
|
|
1643
1736
|
const r = C / 2 + 32;
|
|
1644
1737
|
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
1738
|
}
|
|
1646
|
-
if (shape === "none") return `
|
|
1739
|
+
if (shape === "none") return `M${-pad},${-pad} H${C + pad} V${C + pad} H${-pad}Z`;
|
|
1647
1740
|
return SQUIRCLE;
|
|
1648
1741
|
}
|
|
1649
1742
|
|
|
@@ -1669,6 +1762,17 @@ function hexLuma(hex) {
|
|
|
1669
1762
|
const v = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
|
|
1670
1763
|
return luminance({ r: v(0), g: v(2), b: v(4), a: 1 });
|
|
1671
1764
|
}
|
|
1765
|
+
var NAMED = { white: 1, black: 0 };
|
|
1766
|
+
function paintLuma(v) {
|
|
1767
|
+
if (v.startsWith("#")) return hexLuma(v);
|
|
1768
|
+
const m = /^rgba?\(\s*([\d.]+%?)[\s,]+([\d.]+%?)[\s,]+([\d.]+%?)/i.exec(v);
|
|
1769
|
+
if (m) {
|
|
1770
|
+
const ch = (x) => Math.min(1, Math.max(0, x.endsWith("%") ? Number(x.slice(0, -1)) / 100 : Number(x) / 255));
|
|
1771
|
+
return luminance({ r: ch(m[1]), g: ch(m[2]), b: ch(m[3]), a: 1 });
|
|
1772
|
+
}
|
|
1773
|
+
const named = NAMED[v.toLowerCase()];
|
|
1774
|
+
return named ?? null;
|
|
1775
|
+
}
|
|
1672
1776
|
function assetLuma(a) {
|
|
1673
1777
|
const hit = cache2.get(a.bytes);
|
|
1674
1778
|
if (hit !== void 0) return hit;
|
|
@@ -1689,10 +1793,11 @@ function assetLuma(a) {
|
|
|
1689
1793
|
}
|
|
1690
1794
|
} else if (a.kind === "svg") {
|
|
1691
1795
|
const text2 = new TextDecoder().decode(a.display ?? a.bytes);
|
|
1692
|
-
const
|
|
1693
|
-
const
|
|
1694
|
-
const
|
|
1695
|
-
if (
|
|
1796
|
+
const paint = /(?:fill|stop-color)\s*[:=]\s*["']?\s*(rgba?\([^)]*\)|[^"';>\s]+)/gi;
|
|
1797
|
+
const values = [...text2.matchAll(paint)].map((m) => m[1].trim()).filter((v) => !/^(none|transparent|inherit|currentcolor|url\()/i.test(v));
|
|
1798
|
+
const ls = values.map(paintLuma).filter((x) => x !== null);
|
|
1799
|
+
if (ls.length) l = ls.reduce((x, y) => x + y, 0) / ls.length;
|
|
1800
|
+
else l = values.length ? 0.5 : 0;
|
|
1696
1801
|
}
|
|
1697
1802
|
cache2.set(a.bytes, l);
|
|
1698
1803
|
return l;
|
|
@@ -1710,6 +1815,7 @@ function rasterRenderOptions(ro) {
|
|
|
1710
1815
|
};
|
|
1711
1816
|
}
|
|
1712
1817
|
var C2 = CANVAS;
|
|
1818
|
+
var PAD = C2 / 4;
|
|
1713
1819
|
var H2 = C2 / 2;
|
|
1714
1820
|
var b64Cache = /* @__PURE__ */ new WeakMap();
|
|
1715
1821
|
var b64 = (bytes) => {
|
|
@@ -1728,11 +1834,32 @@ var b64 = (bytes) => {
|
|
|
1728
1834
|
};
|
|
1729
1835
|
var esc = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/"/g, """);
|
|
1730
1836
|
var n = (x) => +x.toFixed(3);
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1837
|
+
var DEFAULT_TINT = { r: 0.25, g: 0.65, b: 0.63, a: 1 };
|
|
1838
|
+
function tintToRGBA(input) {
|
|
1839
|
+
try {
|
|
1840
|
+
const c = parseColor(normalizeColorInput(input));
|
|
1841
|
+
return c ? { ...toRGBA(c), a: 1 } : DEFAULT_TINT;
|
|
1842
|
+
} catch {
|
|
1843
|
+
return DEFAULT_TINT;
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
var num2 = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
1847
|
+
function safePosition(p) {
|
|
1848
|
+
if (!p || typeof p !== "object") return null;
|
|
1849
|
+
const o = p;
|
|
1850
|
+
const t = Array.isArray(o["translation-in-points"]) ? o["translation-in-points"] : [];
|
|
1851
|
+
const scale = num2(o.scale, 1);
|
|
1852
|
+
return { scale: scale > 0 ? scale : 1, tx: num2(t[0], 0), ty: num2(t[1], 0) };
|
|
1853
|
+
}
|
|
1854
|
+
function safeOrientation(o) {
|
|
1855
|
+
const pt = (v) => {
|
|
1856
|
+
const q = v;
|
|
1857
|
+
return q && typeof q === "object" && Number.isFinite(q.x) && Number.isFinite(q.y) ? { x: q.x, y: q.y } : null;
|
|
1858
|
+
};
|
|
1859
|
+
const r = o;
|
|
1860
|
+
const start = r && typeof r === "object" ? pt(r.start) : null;
|
|
1861
|
+
const stop = r && typeof r === "object" ? pt(r.stop) : null;
|
|
1862
|
+
return start && stop ? { start, stop } : void 0;
|
|
1736
1863
|
}
|
|
1737
1864
|
var colorOf = (s) => {
|
|
1738
1865
|
const c = typeof s === "string" ? parseColor(s) : null;
|
|
@@ -1779,10 +1906,10 @@ function paintFor(fill, defs, box, darkSystem) {
|
|
|
1779
1906
|
const c = colorOf(f["automatic-gradient"]);
|
|
1780
1907
|
return lin(shade(c, 0.22), shade(c, -0.08));
|
|
1781
1908
|
}
|
|
1782
|
-
if (Array.isArray(f["linear-gradient"])) {
|
|
1909
|
+
if (Array.isArray(f["linear-gradient"]) && f["linear-gradient"].length) {
|
|
1783
1910
|
const [a, b] = f["linear-gradient"];
|
|
1784
|
-
const o = f.orientation;
|
|
1785
|
-
return lin(colorOf(a), colorOf(b), o?.start, o?.stop);
|
|
1911
|
+
const o = safeOrientation(f.orientation);
|
|
1912
|
+
return lin(colorOf(a), colorOf(b ?? a), o?.start, o?.stop);
|
|
1786
1913
|
}
|
|
1787
1914
|
return null;
|
|
1788
1915
|
}
|
|
@@ -1795,22 +1922,25 @@ function renderSvg(doc, opts = {}) {
|
|
|
1795
1922
|
const dark = isDarkAppearance(appearance);
|
|
1796
1923
|
const isClear = appearance === "clear-light" || appearance === "clear-dark";
|
|
1797
1924
|
const isTinted = appearance === "tinted-light" || appearance === "tinted-dark";
|
|
1798
|
-
const tint =
|
|
1925
|
+
const tint = opts.tint ? tintToRGBA(opts.tint) : DEFAULT_TINT;
|
|
1926
|
+
const idioms = idiomsFor(ctx.idiom);
|
|
1927
|
+
const forPlatform = (s) => s.idiom === void 0 || idioms.includes(s.idiom);
|
|
1799
1928
|
const angle = (opts.lightAngle ?? 0) * Math.PI / 180;
|
|
1800
1929
|
const toLight = { x: Math.sin(angle), y: -Math.cos(angle) };
|
|
1801
1930
|
const defs = new Defs(opts.idPrefix ?? "");
|
|
1802
1931
|
const body = [];
|
|
1803
1932
|
const fillProp = doc.props.fill;
|
|
1804
1933
|
let bgFill = resolveProp(fillProp, ctx, "automatic");
|
|
1805
|
-
const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark");
|
|
1934
|
+
const darkOverridden = !!fillProp?.specs?.some((s) => s.appearance === "dark" && forPlatform(s));
|
|
1806
1935
|
let darken = 0;
|
|
1807
1936
|
if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
|
|
1808
1937
|
let bgPaint;
|
|
1809
|
-
const P = mask === "circle" ? 32 : 0;
|
|
1938
|
+
const P = mask === "circle" || ctx.idiom === "watchOS" ? 32 : 0;
|
|
1810
1939
|
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"
|
|
1940
|
+
const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" && forPlatform(s));
|
|
1812
1941
|
if (monoOverride) {
|
|
1813
|
-
|
|
1942
|
+
const mono = resolveProp(fillProp, { appearance: dark ? "tinted-dark" : "tinted-light", idiom: ctx.idiom }, "automatic");
|
|
1943
|
+
bgPaint = paintFor(mono ?? "automatic", defs, full, dark);
|
|
1814
1944
|
} else if (isClear) {
|
|
1815
1945
|
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
1946
|
} else if (isTinted) {
|
|
@@ -1851,7 +1981,10 @@ function renderSvg(doc, opts = {}) {
|
|
|
1851
1981
|
plainId = plain;
|
|
1852
1982
|
}
|
|
1853
1983
|
function drawGroup(g, backdropId) {
|
|
1854
|
-
const
|
|
1984
|
+
const gp = safePosition(resolveProp(g.props.position, ctx, null));
|
|
1985
|
+
const gs = gp?.scale ?? 1;
|
|
1986
|
+
const gtx = gp?.tx ?? 0;
|
|
1987
|
+
const gty = gp?.ty ?? 0;
|
|
1855
1988
|
const layers = FRONT_FIRST ? [...g.layers].reverse() : [...g.layers];
|
|
1856
1989
|
const placed = [];
|
|
1857
1990
|
for (const l of layers) {
|
|
@@ -1859,14 +1992,23 @@ function renderSvg(doc, opts = {}) {
|
|
|
1859
1992
|
const name = resolveProp(l.props["image-name"], ctx, "");
|
|
1860
1993
|
const asset = doc.assets[name];
|
|
1861
1994
|
if (!asset || asset.kind === "pdf" || asset.kind === "other") continue;
|
|
1862
|
-
|
|
1995
|
+
if (!(asset.width > 0 && asset.height > 0 && Number.isFinite(asset.width) && Number.isFinite(asset.height))) continue;
|
|
1996
|
+
const pos = safePosition(resolveProp(l.props.position, ctx, null));
|
|
1863
1997
|
const s = pos?.scale ?? 1;
|
|
1864
|
-
const [tx, ty] = pos?.["translation-in-points"] ?? [0, 0];
|
|
1865
1998
|
const w = asset.width * s;
|
|
1866
1999
|
const h = asset.height * s;
|
|
1867
|
-
|
|
2000
|
+
const x = H2 + (pos?.tx ?? 0) - w / 2;
|
|
2001
|
+
const y = H2 + (pos?.ty ?? 0) - h / 2;
|
|
2002
|
+
const cx0 = H2 + gtx + (x - H2) * gs;
|
|
2003
|
+
const cy0 = H2 + gty + (y - H2) * gs;
|
|
2004
|
+
const cx1 = cx0 + w * gs;
|
|
2005
|
+
const cy1 = cy0 + h * gs;
|
|
2006
|
+
if (cx1 < -P || cy1 < -P || cx0 > C2 + P || cy0 > C2 + P) continue;
|
|
2007
|
+
placed.push({ layer: l, asset, x, y, w, h });
|
|
1868
2008
|
}
|
|
1869
2009
|
if (!placed.length) return null;
|
|
2010
|
+
const groupOpacity = Math.min(1, Math.max(0, num2(resolveProp(g.props.opacity, ctx, 1), 1)));
|
|
2011
|
+
if (groupOpacity <= 0) return null;
|
|
1870
2012
|
const imgId = /* @__PURE__ */ new Map();
|
|
1871
2013
|
const layerEls = [];
|
|
1872
2014
|
const glassEls = [];
|
|
@@ -1883,7 +2025,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1883
2025
|
const s = p.w / p.asset.width;
|
|
1884
2026
|
const place = `transform="translate(${n(p.x)} ${n(p.y)}) scale(${n(s)})"`;
|
|
1885
2027
|
const fill = resolveProp(p.layer.props.fill, ctx, null);
|
|
1886
|
-
const opacity = resolveProp(p.layer.props.opacity, ctx, 1);
|
|
2028
|
+
const opacity = Math.min(1, Math.max(0, num2(resolveProp(p.layer.props.opacity, ctx, 1), 1)));
|
|
1887
2029
|
const blend = cssBlend(resolveProp(p.layer.props["blend-mode"], ctx, "normal"));
|
|
1888
2030
|
const cb = contentBoxOf(p.asset);
|
|
1889
2031
|
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 +2033,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1891
2033
|
let el;
|
|
1892
2034
|
if (paint) {
|
|
1893
2035
|
const mid = defs.id("lm");
|
|
1894
|
-
defs.add(`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="
|
|
2036
|
+
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>`);
|
|
1895
2037
|
el = `<rect x="${n(p.x)}" y="${n(p.y)}" width="${n(p.w)}" height="${n(p.h)}" fill="${paint}" mask="url(#${mid})"/>`;
|
|
1896
2038
|
} else {
|
|
1897
2039
|
el = `<use href="#${iid}" ${place}/>`;
|
|
@@ -1903,43 +2045,55 @@ function renderSvg(doc, opts = {}) {
|
|
|
1903
2045
|
glassLumas.push(fill && fill !== "automatic" ? fillLuma(fill, dark) ?? assetLuma(p.asset) : assetLuma(p.asset));
|
|
1904
2046
|
}
|
|
1905
2047
|
}
|
|
1906
|
-
const gTransform =
|
|
2048
|
+
const gTransform = gp ? ` transform="translate(${n(H2 + gtx)} ${n(H2 + gty)}) scale(${n(gs)}) translate(${-H2} ${-H2})"` : "";
|
|
2049
|
+
const canvasRegion = `filterUnits="userSpaceOnUse" x="${-PAD}" y="${-PAD}" width="${C2 + 2 * PAD}" height="${C2 + 2 * PAD}"`;
|
|
2050
|
+
const gx0 = H2 + (-PAD - H2 - gtx) / gs;
|
|
2051
|
+
const gy0 = H2 + (-PAD - H2 - gty) / gs;
|
|
2052
|
+
const groupRegion = `filterUnits="userSpaceOnUse" x="${n(gx0)}" y="${n(gy0)}" width="${n((C2 + 2 * PAD) / gs)}" height="${n((C2 + 2 * PAD) / gs)}"`;
|
|
1907
2053
|
const contentId = defs.id("gc");
|
|
1908
2054
|
defs.add(`<g id="${contentId}"${gTransform}>${layerEls.join("")}</g>`);
|
|
1909
2055
|
contentIds.push(contentId);
|
|
1910
2056
|
const out = [];
|
|
1911
|
-
const
|
|
2057
|
+
const shadow0 = resolveProp(g.props.shadow, ctx, { kind: "neutral", opacity: 0.5 });
|
|
2058
|
+
const shadow = shadow0 && typeof shadow0 === "object" ? { kind: shadow0.kind, opacity: Math.min(1, Math.max(0, num2(shadow0.opacity, 0.5))) } : null;
|
|
1912
2059
|
if (effects && shadow && shadow.kind !== "none" && shadow.opacity > 0) {
|
|
1913
2060
|
const fid = defs.id("sh");
|
|
1914
2061
|
if (shadow.kind === "neutral") {
|
|
1915
2062
|
defs.add(
|
|
1916
|
-
`<filter id="${fid}"
|
|
2063
|
+
`<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
2064
|
);
|
|
1918
2065
|
} else {
|
|
1919
2066
|
defs.add(
|
|
1920
|
-
`<filter id="${fid}"
|
|
2067
|
+
`<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
2068
|
);
|
|
1922
2069
|
}
|
|
1923
2070
|
out.push(`<use href="#${contentId}" filter="url(#${fid})" opacity="${n(shadow.opacity)}"/>`);
|
|
1924
2071
|
}
|
|
1925
|
-
const
|
|
1926
|
-
const
|
|
2072
|
+
const tr0 = resolveProp(g.props.translucency, ctx, { enabled: true, value: 0.5 });
|
|
2073
|
+
const tr = tr0 && typeof tr0 === "object" ? { enabled: !!tr0.enabled, value: num2(tr0.value, 0.5) } : null;
|
|
2074
|
+
const blurRaw = resolveProp(g.props["blur-material"], ctx, void 0);
|
|
2075
|
+
const blurMat = blurRaw === null ? null : num2(blurRaw, 0.2);
|
|
1927
2076
|
const translucent = effects && !!tr?.enabled && tr.value > 0;
|
|
1928
|
-
const
|
|
2077
|
+
const refr0 = resolveProp(g.props.refractivity, ctx, null);
|
|
2078
|
+
const refr = refr0 && typeof refr0 === "object" ? { enabled: !!refr0.enabled, strength: num2(refr0.strength, 0), depth: num2(refr0.depth, 0) } : null;
|
|
1929
2079
|
const refracts = effects && !!refr?.enabled && glassEls.length > 0;
|
|
1930
2080
|
if (translucent || refracts) {
|
|
1931
2081
|
const maskSrc = translucent ? `<use href="#${contentId}"/>` : `<g${gTransform}>${glassEls.join("")}</g>`;
|
|
1932
2082
|
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
|
-
|
|
2083
|
+
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>`);
|
|
2084
|
+
const blurPx = Math.max(0, (blurMat === null ? 0 : 4 + blurMat * 40) + (refracts ? refr.depth * 10 : 0));
|
|
2085
|
+
let filterAttr = "";
|
|
2086
|
+
if (blurPx > 0) {
|
|
2087
|
+
const fid = defs.id("bk");
|
|
2088
|
+
defs.add(`<filter id="${fid}" ${canvasRegion}><feGaussianBlur stdDeviation="${n(blurPx)}"/></filter>`);
|
|
2089
|
+
filterAttr = ` filter="url(#${fid})"`;
|
|
2090
|
+
}
|
|
2091
|
+
const zoom = 1.02 + (refracts ? refr.strength * 0.12 : 0.015);
|
|
1938
2092
|
out.push(
|
|
1939
|
-
`<g mask="url(#${mid})"><use href="#${backdropId}"
|
|
2093
|
+
`<g mask="url(#${mid})"><use href="#${backdropId}"${filterAttr} transform="translate(${H2} ${H2}) scale(${n(zoom)}) translate(${-H2} ${-H2})"/></g>`
|
|
1940
2094
|
);
|
|
1941
2095
|
}
|
|
1942
|
-
const gOpacity =
|
|
2096
|
+
const gOpacity = refracts && !translucent ? 1 - Math.min(1, Math.max(0, refr.strength)) * 0.3 : 1;
|
|
1943
2097
|
let fadeMask = "";
|
|
1944
2098
|
if (translucent) {
|
|
1945
2099
|
const v = Math.min(1, Math.max(0, tr.value));
|
|
@@ -1965,15 +2119,15 @@ function renderSvg(doc, opts = {}) {
|
|
|
1965
2119
|
const fid = defs.id("sp");
|
|
1966
2120
|
if (specular === "outside") {
|
|
1967
2121
|
defs.add(
|
|
1968
|
-
`<filter id="${fid}"
|
|
2122
|
+
`<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
2123
|
);
|
|
1970
2124
|
} else defs.add(
|
|
1971
|
-
`<filter id="${fid}"
|
|
2125
|
+
`<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
2126
|
);
|
|
1973
2127
|
const glass = individual ? glassEls.map((e) => `<g filter="url(#${fid})">${e}</g>`).join("") : `<g filter="url(#${fid})">${glassEls.join("")}</g>`;
|
|
1974
2128
|
out.push(`<g${gTransform}>${glass}</g>`);
|
|
1975
2129
|
}
|
|
1976
|
-
return out.join("");
|
|
2130
|
+
return groupOpacity < 1 ? `<g opacity="${n(groupOpacity)}">${out.join("")}</g>` : out.join("");
|
|
1977
2131
|
}
|
|
1978
2132
|
let top = `<use href="#${stackId}"/>`;
|
|
1979
2133
|
if ((isClear || isTinted) && contentIds.length) {
|
|
@@ -1995,18 +2149,19 @@ function renderSvg(doc, opts = {}) {
|
|
|
1995
2149
|
}
|
|
1996
2150
|
const mid = defs.id("fm");
|
|
1997
2151
|
defs.add(
|
|
1998
|
-
`<mask id="${mid}" mask-type="alpha" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="
|
|
2152
|
+
`<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
2153
|
);
|
|
2000
2154
|
top = `<use href="#${bgStackId}"/><g filter="url(#${fid})"><use href="#${stackId}" mask="url(#${mid})"/></g>`;
|
|
2001
2155
|
}
|
|
2002
2156
|
const clipId = defs.id("clip");
|
|
2003
|
-
const shape = maskPath(mask);
|
|
2157
|
+
const shape = maskPath(mask, P);
|
|
2004
2158
|
defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
|
|
2005
|
-
const
|
|
2159
|
+
const inset = Math.min(C2 / 2 - 1, Math.max(0, num2(opts.inset, 0)));
|
|
2160
|
+
const M = inset > 0 ? inset * C2 / (C2 - 2 * inset) : 0;
|
|
2006
2161
|
if (M > 0) {
|
|
2007
2162
|
const sid = defs.id("ish");
|
|
2008
2163
|
defs.add(
|
|
2009
|
-
`<filter id="${sid}" x="-
|
|
2164
|
+
`<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>`
|
|
2010
2165
|
);
|
|
2011
2166
|
body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
|
|
2012
2167
|
} else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
|
|
@@ -2021,8 +2176,9 @@ function renderSvg(doc, opts = {}) {
|
|
|
2021
2176
|
}
|
|
2022
2177
|
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
2178
|
}
|
|
2179
|
+
var KNOWN_BLEND = new Set(BLEND_MODES);
|
|
2024
2180
|
function cssBlend(mode) {
|
|
2025
|
-
if (!mode || mode === "normal") return null;
|
|
2181
|
+
if (typeof mode !== "string" || !KNOWN_BLEND.has(mode) || mode === "normal") return null;
|
|
2026
2182
|
if (mode === "plus-lighter") return "screen";
|
|
2027
2183
|
if (mode === "plus-darker") return "multiply";
|
|
2028
2184
|
return mode;
|
|
@@ -2065,6 +2221,7 @@ async function resvg() {
|
|
|
2065
2221
|
}
|
|
2066
2222
|
}
|
|
2067
2223
|
async function svgToPng(svg, width) {
|
|
2224
|
+
if (!(width >= 2)) throw new Error(`Image size must be at least 2 px (got ${width})`);
|
|
2068
2225
|
const { Resvg } = await resvg();
|
|
2069
2226
|
const r = new Resvg(svg, { fitTo: { mode: "width", value: Math.round(width) }, font: { loadSystemFonts: true } });
|
|
2070
2227
|
return new Uint8Array(r.render().asPng());
|
|
@@ -2112,6 +2269,7 @@ var groupsOutline = z2.array(z2.looseObject({ group: z2.string(), layers: z2.arr
|
|
|
2112
2269
|
function describeProps(props) {
|
|
2113
2270
|
const base = {};
|
|
2114
2271
|
const overrides = {};
|
|
2272
|
+
const variants = /* @__PURE__ */ new Map();
|
|
2115
2273
|
for (const [k, p] of Object.entries(props)) {
|
|
2116
2274
|
const b = p.specs?.find((s) => s.appearance === void 0 && s.idiom === void 0);
|
|
2117
2275
|
if (b) base[k] = b.value;
|
|
@@ -2120,21 +2278,32 @@ function describeProps(props) {
|
|
|
2120
2278
|
const s = p.specs.find((e) => e.appearance === a && e.idiom === void 0);
|
|
2121
2279
|
(overrides[a] ??= {})[k] = s.value;
|
|
2122
2280
|
}
|
|
2281
|
+
for (const s of p.specs ?? []) {
|
|
2282
|
+
if (s.idiom === void 0) continue;
|
|
2283
|
+
const key = `${s.appearance ?? ""}|${s.idiom}`;
|
|
2284
|
+
if (!variants.has(key)) variants.set(key, { ...s.appearance ? { appearance: s.appearance } : {}, platform: s.idiom, values: {} });
|
|
2285
|
+
variants.get(key).values[k] = s.value;
|
|
2286
|
+
}
|
|
2123
2287
|
}
|
|
2124
|
-
return
|
|
2288
|
+
return {
|
|
2289
|
+
...base,
|
|
2290
|
+
...Object.keys(overrides).length ? { overrides } : {},
|
|
2291
|
+
...variants.size ? { variants: [...variants.values()] } : {}
|
|
2292
|
+
};
|
|
2125
2293
|
}
|
|
2126
2294
|
function inspect(doc) {
|
|
2127
2295
|
return {
|
|
2128
2296
|
order: "groups and layers are listed front to back (index 0 = frontmost)",
|
|
2129
2297
|
document: describeProps(doc.props),
|
|
2298
|
+
// attributes first, so an unknown key in the file (e.g. "target") can't replace these fields
|
|
2130
2299
|
groups: doc.groups.map((g, gi) => ({
|
|
2300
|
+
...describeProps(g.props),
|
|
2131
2301
|
target: nameOf(g) || `#${gi}`,
|
|
2132
2302
|
index: gi,
|
|
2133
|
-
...describeProps(g.props),
|
|
2134
2303
|
layers: g.layers.map((l, li) => ({
|
|
2304
|
+
...describeProps(l.props),
|
|
2135
2305
|
target: `${nameOf(g) || `#${gi}`}/${nameOf(l) || `#${li}`}`,
|
|
2136
|
-
index: li
|
|
2137
|
-
...describeProps(l.props)
|
|
2306
|
+
index: li
|
|
2138
2307
|
}))
|
|
2139
2308
|
})),
|
|
2140
2309
|
assets: Object.values(doc.assets).map((a) => ({ name: a.name, kind: a.kind, width: a.width, height: a.height }))
|
|
@@ -2152,9 +2321,7 @@ function targetLabel(doc, ref) {
|
|
|
2152
2321
|
}
|
|
2153
2322
|
async function saveFile(ws, out, bytes) {
|
|
2154
2323
|
const p = await ws.resolve(out);
|
|
2155
|
-
await
|
|
2156
|
-
await writeFile2(p, bytes);
|
|
2157
|
-
return p;
|
|
2324
|
+
return (await writeFiles(dirname2(p), { [basename(p)]: bytes }))[0];
|
|
2158
2325
|
}
|
|
2159
2326
|
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
2327
|
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 +2346,9 @@ var TOOLS = [
|
|
|
2179
2346
|
},
|
|
2180
2347
|
output: loose({ created: z2.string(), dry_run: z2.boolean().optional(), groups: z2.array(z2.unknown()) }),
|
|
2181
2348
|
mutates_input: true,
|
|
2349
|
+
writes_files: true,
|
|
2182
2350
|
idempotency_hint: "not-idempotent",
|
|
2351
|
+
destructive: true,
|
|
2183
2352
|
supports_dry_run: true,
|
|
2184
2353
|
verification: ["icon_inspect shows the document fill", "icon_render_sheet shows the background in all appearances"],
|
|
2185
2354
|
requires_visual_verification: false,
|
|
@@ -2187,14 +2356,16 @@ var TOOLS = [
|
|
|
2187
2356
|
async run(ws, { path, background, dark_background, watch, overwrite, dry_run }) {
|
|
2188
2357
|
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
2358
|
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
2359
|
const doc = createDocument({
|
|
2193
2360
|
...background ? { background } : {},
|
|
2194
2361
|
platforms: watch === false ? { squares: "shared" } : { circles: ["watchOS"], squares: "shared" }
|
|
2195
2362
|
});
|
|
2196
2363
|
if (dark_background) setProp(doc, { kind: "document" }, "fill", dark_background, "dark");
|
|
2197
|
-
|
|
2364
|
+
await ws.withLock(full, async () => {
|
|
2365
|
+
const exists = await stat2(full).then(() => true, () => false);
|
|
2366
|
+
if (exists && !overwrite) throw new Error(`${path} already exists. Pass overwrite: true to replace it.`);
|
|
2367
|
+
if (!dry_run) await saveIcon(full, doc);
|
|
2368
|
+
});
|
|
2198
2369
|
return { data: { created: full, ...dry_run ? { dry_run: true } : {}, ...inspect(doc) } };
|
|
2199
2370
|
}
|
|
2200
2371
|
}),
|
|
@@ -2210,6 +2381,7 @@ var TOOLS = [
|
|
|
2210
2381
|
input: { path: pathArg },
|
|
2211
2382
|
output: inspectOutput,
|
|
2212
2383
|
mutates_input: false,
|
|
2384
|
+
writes_files: false,
|
|
2213
2385
|
idempotency_hint: "idempotent",
|
|
2214
2386
|
supports_dry_run: false,
|
|
2215
2387
|
verification: [],
|
|
@@ -2238,6 +2410,7 @@ var TOOLS = [
|
|
|
2238
2410
|
},
|
|
2239
2411
|
output: loose({ added: z2.string(), groups: z2.array(z2.string()) }),
|
|
2240
2412
|
mutates_input: true,
|
|
2413
|
+
writes_files: false,
|
|
2241
2414
|
idempotency_hint: "not-idempotent",
|
|
2242
2415
|
supports_dry_run: true,
|
|
2243
2416
|
verification: ["icon_inspect lists the new group"],
|
|
@@ -2280,6 +2453,7 @@ var TOOLS = [
|
|
|
2280
2453
|
},
|
|
2281
2454
|
output: loose({ added: z2.string(), size: z2.string(), warnings: z2.array(z2.string()) }),
|
|
2282
2455
|
mutates_input: true,
|
|
2456
|
+
writes_files: false,
|
|
2283
2457
|
idempotency_hint: "not-idempotent",
|
|
2284
2458
|
supports_dry_run: true,
|
|
2285
2459
|
verification: ["Check `warnings` (V-01\u2026V-10)", "icon_render_sheet to see the layer in every appearance"],
|
|
@@ -2355,6 +2529,7 @@ var TOOLS = [
|
|
|
2355
2529
|
},
|
|
2356
2530
|
output: loose({ target: z2.string(), now: z2.record(z2.string(), z2.unknown()) }),
|
|
2357
2531
|
mutates_input: true,
|
|
2532
|
+
writes_files: false,
|
|
2358
2533
|
idempotency_hint: "idempotent",
|
|
2359
2534
|
supports_dry_run: true,
|
|
2360
2535
|
verification: ["`now` shows the stored values", "icon_render_sheet for the appearances you changed"],
|
|
@@ -2390,6 +2565,7 @@ var TOOLS = [
|
|
|
2390
2565
|
input: { path: pathArg, target: z2.string(), index: z2.number().int().min(0), to_group: z2.string().optional(), dry_run: dryRunArg },
|
|
2391
2566
|
output: loose({ groups: groupsOutline }),
|
|
2392
2567
|
mutates_input: true,
|
|
2568
|
+
writes_files: false,
|
|
2393
2569
|
idempotency_hint: "idempotent",
|
|
2394
2570
|
supports_dry_run: true,
|
|
2395
2571
|
verification: ["`groups` lists the new order (front to back)"],
|
|
@@ -2403,6 +2579,7 @@ var TOOLS = [
|
|
|
2403
2579
|
const ref = resolvePath(doc, target);
|
|
2404
2580
|
let gid;
|
|
2405
2581
|
if (to_group) {
|
|
2582
|
+
if (ref.kind !== "layer") throw new Error("to_group only applies to layers; groups are reordered with index");
|
|
2406
2583
|
const g = resolvePath(doc, to_group);
|
|
2407
2584
|
if (g.kind !== "group") throw new Error("to_group must be a group");
|
|
2408
2585
|
gid = g.id;
|
|
@@ -2434,6 +2611,7 @@ var TOOLS = [
|
|
|
2434
2611
|
},
|
|
2435
2612
|
output: loose({ moved: z2.number(), positions: z2.array(z2.looseObject({ target: z2.string() })) }),
|
|
2436
2613
|
mutates_input: true,
|
|
2614
|
+
writes_files: false,
|
|
2437
2615
|
idempotency_hint: "idempotent",
|
|
2438
2616
|
supports_dry_run: true,
|
|
2439
2617
|
verification: ["`positions` gives each bounding box in canvas points", "icon_render to look at the result"],
|
|
@@ -2469,6 +2647,7 @@ var TOOLS = [
|
|
|
2469
2647
|
input: { path: pathArg, target: z2.string(), dry_run: dryRunArg },
|
|
2470
2648
|
output: loose({ removed: z2.string(), groups: groupsOutline }),
|
|
2471
2649
|
mutates_input: true,
|
|
2650
|
+
writes_files: false,
|
|
2472
2651
|
idempotency_hint: "not-idempotent",
|
|
2473
2652
|
destructive: true,
|
|
2474
2653
|
supports_dry_run: true,
|
|
@@ -2505,6 +2684,7 @@ var TOOLS = [
|
|
|
2505
2684
|
issues: z2.array(z2.looseObject({ id: z2.string(), severity: z2.enum(["error", "warning", "info"]), message: z2.string() }))
|
|
2506
2685
|
}),
|
|
2507
2686
|
mutates_input: false,
|
|
2687
|
+
writes_files: false,
|
|
2508
2688
|
idempotency_hint: "idempotent",
|
|
2509
2689
|
supports_dry_run: false,
|
|
2510
2690
|
verification: ['No issue with severity "error"'],
|
|
@@ -2531,12 +2711,13 @@ var TOOLS = [
|
|
|
2531
2711
|
platform: z2.enum(["iOS", "macOS", "watchOS"]).optional().describe("Default: iOS. watchOS renders a circle"),
|
|
2532
2712
|
size: z2.number().int().min(16).max(2048).optional().describe("Pixels, default 512"),
|
|
2533
2713
|
mask: maskEnum.optional(),
|
|
2534
|
-
tint: z2.string().optional().describe('Tint color for tinted appearances, "#RRGGBB"'),
|
|
2714
|
+
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
2715
|
light_angle: z2.number().min(-180).max(180).optional().describe("Light direction in degrees, 0 = top (default), -45 = top-left"),
|
|
2536
2716
|
out: z2.string().optional().describe("Save the PNG to this path")
|
|
2537
2717
|
},
|
|
2538
2718
|
output: loose({ appearance: z2.string(), saved: z2.string().optional() }),
|
|
2539
2719
|
mutates_input: false,
|
|
2720
|
+
writes_files: true,
|
|
2540
2721
|
idempotency_hint: "idempotent",
|
|
2541
2722
|
supports_dry_run: false,
|
|
2542
2723
|
verification: ["Look at the returned image"],
|
|
@@ -2569,11 +2750,12 @@ var TOOLS = [
|
|
|
2569
2750
|
input: {
|
|
2570
2751
|
path: pathArg,
|
|
2571
2752
|
cell: z2.number().int().min(96).max(600).optional().describe("Cell size in pixels, default 240"),
|
|
2572
|
-
tint: z2.string().optional(),
|
|
2753
|
+
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
2754
|
out: z2.string().optional()
|
|
2574
2755
|
},
|
|
2575
2756
|
output: loose({ appearances: z2.array(z2.string()), saved: z2.string().optional() }),
|
|
2576
2757
|
mutates_input: false,
|
|
2758
|
+
writes_files: true,
|
|
2577
2759
|
idempotency_hint: "idempotent",
|
|
2578
2760
|
supports_dry_run: false,
|
|
2579
2761
|
verification: ["Look at the returned image: every appearance should stay legible"],
|
|
@@ -2599,12 +2781,14 @@ var TOOLS = [
|
|
|
2599
2781
|
path: pathArg,
|
|
2600
2782
|
out_dir: z2.string(),
|
|
2601
2783
|
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")
|
|
2784
|
+
sizes: z2.array(z2.number().int().min(16).max(2048)).min(1).optional().describe("PNG sizes, default [1024, 512, 256, 128, 64, 32, 16]"),
|
|
2785
|
+
appearances: z2.array(appearanceEnum).min(1).optional().describe("PNG appearances, default all six")
|
|
2604
2786
|
},
|
|
2605
2787
|
output: loose({ out_dir: z2.string(), files: z2.number(), written: z2.array(z2.string()) }),
|
|
2606
2788
|
mutates_input: false,
|
|
2789
|
+
writes_files: true,
|
|
2607
2790
|
idempotency_hint: "idempotent",
|
|
2791
|
+
destructive: true,
|
|
2608
2792
|
supports_dry_run: false,
|
|
2609
2793
|
verification: ["`written` lists every file", "Open the .icon in Icon Composer / Xcode 26 on a Mac for the final check"],
|
|
2610
2794
|
requires_visual_verification: false,
|
|
@@ -2612,6 +2796,9 @@ var TOOLS = [
|
|
|
2612
2796
|
async run(ws, { path, out_dir, formats, sizes, appearances }) {
|
|
2613
2797
|
const full = await ws.iconPath(path, { mustExist: true });
|
|
2614
2798
|
const outDir = await ws.resolve(out_dir);
|
|
2799
|
+
const inside = relative2(full, outDir);
|
|
2800
|
+
if (inside === "" || !inside.startsWith(`..${sep2}`) && inside !== ".." && !isAbsolute2(inside))
|
|
2801
|
+
throw new Error("out_dir is inside the icon bundle; choose a folder outside it");
|
|
2615
2802
|
const doc = await ws.read(full);
|
|
2616
2803
|
const name = basename(full).replace(/\.icon(\.zip)?$/, "");
|
|
2617
2804
|
const raster = nodeRasterizer(doc);
|
|
@@ -2620,16 +2807,9 @@ var TOOLS = [
|
|
|
2620
2807
|
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
2808
|
const written = [];
|
|
2622
2809
|
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);
|
|
2810
|
+
written.push(...await saveIcon(join2(outDir, `${name}.icon`), doc));
|
|
2632
2811
|
}
|
|
2812
|
+
if (formats.includes("icns")) written.push(...await writeFiles(outDir, { [`${name}.icns`]: await buildIcns(raster) }));
|
|
2633
2813
|
if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
|
|
2634
2814
|
if (formats.includes("png"))
|
|
2635
2815
|
written.push(
|
|
@@ -2646,7 +2826,7 @@ async function runTool(ws, t, args) {
|
|
|
2646
2826
|
}
|
|
2647
2827
|
|
|
2648
2828
|
// src/version.ts
|
|
2649
|
-
var VERSION = "0.2.
|
|
2829
|
+
var VERSION = "0.2.1";
|
|
2650
2830
|
|
|
2651
2831
|
// src/contract.ts
|
|
2652
2832
|
var RESOURCES = [
|
|
@@ -2683,6 +2863,7 @@ function toolSpec(t) {
|
|
|
2683
2863
|
input_schema: jsonSchema(z3.object(t.input).strict(), "input"),
|
|
2684
2864
|
output_schema: jsonSchema(t.output, "output"),
|
|
2685
2865
|
mutates_input: t.mutates_input,
|
|
2866
|
+
writes_files: t.writes_files,
|
|
2686
2867
|
idempotency_hint: t.idempotency_hint,
|
|
2687
2868
|
destructive: !!t.destructive,
|
|
2688
2869
|
supports_dry_run: t.supports_dry_run,
|
|
@@ -2715,7 +2896,7 @@ function buildContract() {
|
|
|
2715
2896
|
rule: "Breaking changes bump contract_version to the next major. The frozen surface (packages/mcp/spec/surface.json) makes CI fail otherwise."
|
|
2716
2897
|
},
|
|
2717
2898
|
tools: TOOLS.map(toolSpec),
|
|
2718
|
-
capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a
|
|
2899
|
+
capability_map: Object.fromEntries(Object.entries(capability_map).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)),
|
|
2719
2900
|
resources: RESOURCES,
|
|
2720
2901
|
prompts: PROMPTS,
|
|
2721
2902
|
icon_json: { document: attrTable(DOCUMENT_ATTRS), group: attrTable(GROUP_ATTRS), layer: attrTable(LAYER_ATTRS) },
|
|
@@ -2809,10 +2990,11 @@ function createServer(ws) {
|
|
|
2809
2990
|
{
|
|
2810
2991
|
title: t.title,
|
|
2811
2992
|
description: t.description,
|
|
2812
|
-
|
|
2993
|
+
// strict: a misspelt argument (dryRun for dry_run) is an error, not silently dropped
|
|
2994
|
+
inputSchema: z4.object(t.input).strict(),
|
|
2813
2995
|
outputSchema: t.output,
|
|
2814
2996
|
annotations: {
|
|
2815
|
-
readOnlyHint: !t.mutates_input,
|
|
2997
|
+
readOnlyHint: !t.mutates_input && !t.writes_files,
|
|
2816
2998
|
destructiveHint: !!t.destructive,
|
|
2817
2999
|
idempotentHint: t.idempotency_hint === "idempotent",
|
|
2818
3000
|
openWorldHint: false
|
|
@@ -2886,8 +3068,8 @@ Use the fresnel tools:
|
|
|
2886
3068
|
}
|
|
2887
3069
|
|
|
2888
3070
|
// src/workspace.ts
|
|
2889
|
-
import { realpath, stat as stat3 } from "fs/promises";
|
|
2890
|
-
import { dirname as dirname3, isAbsolute as
|
|
3071
|
+
import { lstat as lstat2, realpath, stat as stat3 } from "fs/promises";
|
|
3072
|
+
import { dirname as dirname3, isAbsolute as isAbsolute3, relative as relative3, resolve as resolve2, sep as sep3 } from "path";
|
|
2891
3073
|
var Workspace = class {
|
|
2892
3074
|
roots;
|
|
2893
3075
|
locks = /* @__PURE__ */ new Map();
|
|
@@ -2900,13 +3082,17 @@ var Workspace = class {
|
|
|
2900
3082
|
/** Resolves a user path (relative paths are relative to the first root) and checks it's allowed. */
|
|
2901
3083
|
async resolve(p, opts = {}) {
|
|
2902
3084
|
if (!p || typeof p !== "string") throw new Error("A path is required");
|
|
2903
|
-
const abs =
|
|
3085
|
+
const abs = isAbsolute3(p) ? resolve2(p) : resolve2(this.roots[0], p);
|
|
2904
3086
|
let probe = abs;
|
|
2905
3087
|
let suffix = "";
|
|
2906
3088
|
for (; ; ) {
|
|
2907
3089
|
try {
|
|
2908
3090
|
const real = await realpath(probe);
|
|
2909
3091
|
const full = suffix ? resolve2(real, suffix) : real;
|
|
3092
|
+
if (suffix) {
|
|
3093
|
+
const first = resolve2(real, suffix.split(sep3)[0]);
|
|
3094
|
+
if (await lstat2(first).then(() => true, () => false)) throw new Error(`Access denied: ${p} goes through a symbolic link that points nowhere`);
|
|
3095
|
+
}
|
|
2910
3096
|
if (!await this.allowed(full)) throw new Error(`Access denied: ${p} is outside the allowed roots (${this.roots.join(", ")})`);
|
|
2911
3097
|
if (opts.mustExist && full !== real) throw new Error(`Not found: ${p}`);
|
|
2912
3098
|
return full;
|
|
@@ -2915,7 +3101,7 @@ var Workspace = class {
|
|
|
2915
3101
|
if (opts.mustExist) throw new Error(`Not found: ${p}`);
|
|
2916
3102
|
const parent = dirname3(probe);
|
|
2917
3103
|
if (parent === probe) throw new Error(`Invalid path: ${p}`);
|
|
2918
|
-
suffix = suffix ? `${probe.slice(parent.length + 1)}${
|
|
3104
|
+
suffix = suffix ? `${probe.slice(parent.length + 1)}${sep3}${suffix}` : probe.slice(parent.length + 1);
|
|
2919
3105
|
probe = parent;
|
|
2920
3106
|
}
|
|
2921
3107
|
}
|
|
@@ -2928,15 +3114,17 @@ var Workspace = class {
|
|
|
2928
3114
|
root = await realpath(r);
|
|
2929
3115
|
} catch {
|
|
2930
3116
|
}
|
|
2931
|
-
const rel =
|
|
2932
|
-
if (rel === "" || !rel.startsWith(
|
|
3117
|
+
const rel = relative3(root, full);
|
|
3118
|
+
if (rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute3(rel)) return true;
|
|
2933
3119
|
}
|
|
2934
3120
|
return false;
|
|
2935
3121
|
}
|
|
2936
3122
|
/** Normalizes an icon path: accepts "Foo", "Foo.icon", ".../Foo.icon/icon.json". */
|
|
2937
3123
|
async iconPath(p, opts = {}) {
|
|
2938
|
-
let q = p.trim();
|
|
3124
|
+
let q = (typeof p === "string" ? p : "").trim().replace(/[\\/]+$/, "");
|
|
2939
3125
|
if (q.endsWith("/icon.json") || q.endsWith("\\icon.json")) q = q.slice(0, -"/icon.json".length);
|
|
3126
|
+
const base = q.split(/[\\/]/).pop() ?? "";
|
|
3127
|
+
if (!base || base === ".icon" || base === ".icon.zip" || base === "." || base === "..") throw new Error(`Invalid icon path "${p}": give a name such as "MyApp.icon"`);
|
|
2940
3128
|
if (!q.endsWith(".icon") && !q.endsWith(".icon.zip")) q = `${q}.icon`;
|
|
2941
3129
|
const full = await this.resolve(q, opts);
|
|
2942
3130
|
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.1",
|
|
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.1",
|
|
42
|
+
"@fresnel/render": "0.2.1"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsup",
|