fresnel-icon 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +181 -49
- package/package.json +3 -3
package/dist/cli.js
CHANGED
|
@@ -23,6 +23,8 @@ function appearanceKeys(a) {
|
|
|
23
23
|
case "tinted-light":
|
|
24
24
|
case "tinted-dark":
|
|
25
25
|
return ["tinted"];
|
|
26
|
+
case "default":
|
|
27
|
+
return ["light"];
|
|
26
28
|
default:
|
|
27
29
|
return [];
|
|
28
30
|
}
|
|
@@ -275,9 +277,10 @@ var BlendModeSchema = z.enum(BLEND_MODES);
|
|
|
275
277
|
var PositionSchema = z.object({ scale: z.number().positive(), "translation-in-points": z.tuple([z.number(), z.number()]) }).loose();
|
|
276
278
|
var ShadowSchema = z.object({ kind: z.enum(["none", "neutral", "layer-color"]), opacity: z.number().min(0).max(1) }).loose();
|
|
277
279
|
var TranslucencySchema = z.object({ enabled: z.boolean(), value: z.number().min(0).max(1) }).loose();
|
|
278
|
-
var RefractivitySchema = z.object({ enabled: z.boolean(), strength: z.number().min(
|
|
280
|
+
var RefractivitySchema = z.object({ enabled: z.boolean(), strength: z.number().min(-1).max(1), depth: z.number().min(-1).max(1) }).loose();
|
|
279
281
|
var SupportedPlatformsSchema = z.object({
|
|
280
|
-
|
|
282
|
+
// "shared", ["iOS"] or {"platforms": ["iOS"]} (both list forms appear in community tools)
|
|
283
|
+
squares: z.union([z.literal("shared"), z.array(z.string()), z.object({ platforms: z.array(z.string()) }).loose()]).optional(),
|
|
281
284
|
circles: z.array(z.string()).optional()
|
|
282
285
|
}).loose();
|
|
283
286
|
var unit = z.number().min(0).max(1);
|
|
@@ -421,14 +424,29 @@ var DROP_ELEMENTS = /* @__PURE__ */ new Set([
|
|
|
421
424
|
"animatecolor",
|
|
422
425
|
"discard",
|
|
423
426
|
"handler",
|
|
424
|
-
"listener"
|
|
425
|
-
"a"
|
|
427
|
+
"listener"
|
|
426
428
|
]);
|
|
429
|
+
var UNWRAP_ELEMENTS = /* @__PURE__ */ new Set(["a"]);
|
|
430
|
+
function decodeForCheck(v) {
|
|
431
|
+
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(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1").replace(/\s+/g, "").toLowerCase();
|
|
432
|
+
}
|
|
433
|
+
function expandInternalEntities(svg) {
|
|
434
|
+
const doctype = /<!DOCTYPE[^\[>]*\[([\s\S]*?)\]\s*>/i.exec(svg);
|
|
435
|
+
if (!doctype) return svg;
|
|
436
|
+
const ents = /* @__PURE__ */ new Map();
|
|
437
|
+
for (const m of doctype[1].matchAll(/<!ENTITY\s+([\w.-]+)\s+(?:"([^"]*)"|'([^']*)')\s*>/g)) {
|
|
438
|
+
const v = m[2] ?? m[3] ?? "";
|
|
439
|
+
if (v.length <= 2048 && !/[&<]/.test(v)) ents.set(m[1], v);
|
|
440
|
+
}
|
|
441
|
+
const body = svg.slice(0, doctype.index) + svg.slice(doctype.index + doctype[0].length);
|
|
442
|
+
return body.replace(/&([\w.-]+);/g, (m, name) => ents.get(name) ?? m);
|
|
443
|
+
}
|
|
427
444
|
function safeUrlRef(v) {
|
|
428
445
|
const t = v.trim().replace(/^['"]|['"]$/g, "").trim();
|
|
429
446
|
return t.startsWith("#") || /^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]*$/i.test(t);
|
|
430
447
|
}
|
|
431
|
-
function cleanCss(
|
|
448
|
+
function cleanCss(css0) {
|
|
449
|
+
const css = css0.replace(/\\([0-9a-f]{1,6})\s?/gi, (_m, h) => String.fromCodePoint(parseInt(h, 16) % 1114112)).replace(/\\(.)/g, "$1");
|
|
432
450
|
return css.replace(/@import\b[^;]*;?/gi, "").replace(/url\s*\(([^)]*)\)/gi, (m, inner) => safeUrlRef(inner) ? m : "none").replace(/expression\s*\(/gi, "(").replace(/javascript\s*:/gi, "");
|
|
433
451
|
}
|
|
434
452
|
function cleanAttrs(raw) {
|
|
@@ -446,9 +464,13 @@ function cleanAttrs(raw) {
|
|
|
446
464
|
if (lname === "href" || lname.endsWith(":href") || lname === "src") {
|
|
447
465
|
if (!safeUrlRef(value)) continue;
|
|
448
466
|
}
|
|
449
|
-
|
|
467
|
+
const plain = decodeForCheck(value);
|
|
468
|
+
if (/javascript:|vbscript:/.test(plain) || lname === "xml:base") continue;
|
|
450
469
|
let v = value;
|
|
451
|
-
if (lname === "style" || /url\
|
|
470
|
+
if (lname === "style" || /url\(/.test(plain)) {
|
|
471
|
+
v = cleanCss(v);
|
|
472
|
+
if (/url\((?!['"]?#)/.test(decodeForCheck(v).replace(/url\(['"]?data:image\/(png|jpe?g|gif|webp);base64,/g, "url(#"))) continue;
|
|
473
|
+
}
|
|
452
474
|
out.push(`${name}="${v.replace(/"/g, """)}"`);
|
|
453
475
|
}
|
|
454
476
|
return out.length ? " " + out.join(" ") : "";
|
|
@@ -518,6 +540,7 @@ function sanitizeOnce(svg) {
|
|
|
518
540
|
i = j + 1;
|
|
519
541
|
const [, close, tag, attrs, selfClose] = m;
|
|
520
542
|
const local = tag.toLowerCase().replace(/^.*:/, "");
|
|
543
|
+
if (UNWRAP_ELEMENTS.has(local) && !skip.length) continue;
|
|
521
544
|
if (close) {
|
|
522
545
|
if (skip.length) {
|
|
523
546
|
if (skip[skip.length - 1] === local) skip.pop();
|
|
@@ -542,7 +565,7 @@ function sanitizeOnce(svg) {
|
|
|
542
565
|
return out;
|
|
543
566
|
}
|
|
544
567
|
function sanitizeSvg(svg) {
|
|
545
|
-
let s = svg;
|
|
568
|
+
let s = expandInternalEntities(svg);
|
|
546
569
|
for (let n2 = 0; n2 < 5; n2++) {
|
|
547
570
|
const next = sanitizeOnce(s);
|
|
548
571
|
if (next === s) break;
|
|
@@ -561,8 +584,9 @@ function svgFacts(svg) {
|
|
|
561
584
|
function makeAsset(name, bytes) {
|
|
562
585
|
const kind = sniffKind(name, bytes);
|
|
563
586
|
if (kind === "svg") {
|
|
564
|
-
const
|
|
565
|
-
|
|
587
|
+
const original = td.decode(bytes);
|
|
588
|
+
const text2 = sanitizeSvg(original);
|
|
589
|
+
return { name, kind, bytes, ...text2 !== original ? { display: te.encode(text2) } : {}, ...svgSize(text2) };
|
|
566
590
|
}
|
|
567
591
|
if (kind === "png") return { name, kind, bytes, ...pngSize(bytes) };
|
|
568
592
|
if (kind === "jpg") return { name, kind, bytes, ...jpgSize(bytes) };
|
|
@@ -716,7 +740,28 @@ function encodePngRgb(width, height, rgba, bg = [0, 0, 0]) {
|
|
|
716
740
|
}
|
|
717
741
|
function flattenPng(png, bg = [0, 0, 0]) {
|
|
718
742
|
const bmp = decodePng(png);
|
|
719
|
-
|
|
743
|
+
if (!bmp) throw new Error("Could not flatten the PNG (unsupported encoding)");
|
|
744
|
+
return tagSrgb(encodePngRgb(bmp.width, bmp.height, bmp.data, bg));
|
|
745
|
+
}
|
|
746
|
+
function tagSrgb(png) {
|
|
747
|
+
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
|
|
748
|
+
if (png.length < 33 || sig.some((b, i) => png[i] !== b)) return png;
|
|
749
|
+
let o = 8;
|
|
750
|
+
const dv = new DataView(png.buffer, png.byteOffset, png.byteLength);
|
|
751
|
+
while (o + 8 <= png.length) {
|
|
752
|
+
const len = dv.getUint32(o);
|
|
753
|
+
const type = String.fromCharCode(png[o + 4], png[o + 5], png[o + 6], png[o + 7]);
|
|
754
|
+
if (type === "sRGB" || type === "iCCP") return png;
|
|
755
|
+
if (type === "IDAT") break;
|
|
756
|
+
o += 12 + len;
|
|
757
|
+
}
|
|
758
|
+
const ihdrEnd = 8 + 12 + 13;
|
|
759
|
+
const srgb = chunk("sRGB", new Uint8Array([0]));
|
|
760
|
+
const out = new Uint8Array(png.length + srgb.length);
|
|
761
|
+
out.set(png.subarray(0, ihdrEnd), 0);
|
|
762
|
+
out.set(srgb, ihdrEnd);
|
|
763
|
+
out.set(png.subarray(ihdrEnd), ihdrEnd + srgb.length);
|
|
764
|
+
return out;
|
|
720
765
|
}
|
|
721
766
|
|
|
722
767
|
// ../core/src/bundle.ts
|
|
@@ -753,6 +798,10 @@ function serializeIconJson(doc) {
|
|
|
753
798
|
const json = writeProps(doc.props, doc.order, { groups });
|
|
754
799
|
return JSON.stringify(json, null, 2).replace(/^(\s*"[^"]*"):/gm, "$1 :") + "\n";
|
|
755
800
|
}
|
|
801
|
+
function isSafeBundlePath(rel) {
|
|
802
|
+
if (!rel || rel.length > 512 || rel.startsWith("/") || /[\\\u0000-\u001f:]/.test(rel)) return false;
|
|
803
|
+
return rel.split("/").every((p) => p !== "" && p !== "." && p !== ".." && !p.startsWith(".") && p.length <= 255);
|
|
804
|
+
}
|
|
756
805
|
function readBundle(files) {
|
|
757
806
|
const jsonKey = Object.keys(files).find((k) => k === "icon.json" || k.endsWith("/icon.json"));
|
|
758
807
|
if (!jsonKey) throw new IconFormatError("icon.json not found in the bundle");
|
|
@@ -763,10 +812,11 @@ function readBundle(files) {
|
|
|
763
812
|
for (const [k, v] of Object.entries(files)) {
|
|
764
813
|
if (!k.startsWith(prefix) || k === jsonKey || k.endsWith("/")) continue;
|
|
765
814
|
const rel = k.slice(prefix.length);
|
|
815
|
+
if (!isSafeBundlePath(rel)) continue;
|
|
766
816
|
if (rel.startsWith("Assets/") && rel.split("/").length === 2) {
|
|
767
817
|
const name = rel.slice("Assets/".length);
|
|
768
|
-
if (name
|
|
769
|
-
} else if (!rel.split("/").some((p) => p === "__MACOSX"
|
|
818
|
+
if (name) assets[name] = makeAsset(name, v);
|
|
819
|
+
} else if (!rel.split("/").some((p) => p === "__MACOSX")) {
|
|
770
820
|
extra[rel] = v;
|
|
771
821
|
}
|
|
772
822
|
}
|
|
@@ -892,12 +942,7 @@ function uniqueName(existing, wanted0) {
|
|
|
892
942
|
while (existing.includes(`${wanted} ${i}`)) i++;
|
|
893
943
|
return `${wanted} ${i}`;
|
|
894
944
|
}
|
|
895
|
-
var MAX_GROUPS = 4;
|
|
896
|
-
function assertGroupRoom(doc) {
|
|
897
|
-
if (doc.groups.length >= MAX_GROUPS) throw new OpError(`An icon can have up to ${MAX_GROUPS} groups. Add the layer to an existing group instead.`);
|
|
898
|
-
}
|
|
899
945
|
function addGroup(doc, opts = {}) {
|
|
900
|
-
assertGroupRoom(doc);
|
|
901
946
|
const name = uniqueName(doc.groups.map(nameOf), opts.name ?? "Group");
|
|
902
947
|
const g = { id: newId("g"), props: {}, order: [], layers: [] };
|
|
903
948
|
setPropValue(g.props, "name", name);
|
|
@@ -935,7 +980,8 @@ function addLayer(doc, groupId, asset, opts = {}) {
|
|
|
935
980
|
const l = { id: newId("l"), props: {}, order: [] };
|
|
936
981
|
setPropValue(l.props, "image-name", imageName);
|
|
937
982
|
setPropValue(l.props, "name", uniqueName(g.layers.map(nameOf), base));
|
|
938
|
-
l.
|
|
983
|
+
setPropValue(l.props, "glass", true);
|
|
984
|
+
l.order = ["glass", "image-name", "name"];
|
|
939
985
|
for (const [k, v] of Object.entries(opts.props ?? {})) setProp(doc, { kind: "layer", id: l.id }, k, v, void 0, l);
|
|
940
986
|
g.layers.splice(clampIndex(opts.index ?? 0, g.layers.length), 0, l);
|
|
941
987
|
return l;
|
|
@@ -1022,9 +1068,20 @@ var RULES = {
|
|
|
1022
1068
|
"V-06": { severity: "info", title: "Very thin strokes may disappear at small sizes" },
|
|
1023
1069
|
"V-07": { severity: "info", title: "SVG contains text or custom effects" },
|
|
1024
1070
|
"V-08": { severity: "warning", title: "Appearance is not customized" },
|
|
1025
|
-
"V-09": { severity: "
|
|
1071
|
+
"V-09": { severity: "warning", title: "More than 4 groups" },
|
|
1026
1072
|
"V-10": { severity: "error", title: "Unsupported image format" }
|
|
1027
1073
|
};
|
|
1074
|
+
function fillHasAlpha(fill) {
|
|
1075
|
+
if (!fill || typeof fill !== "object") return false;
|
|
1076
|
+
const f = fill;
|
|
1077
|
+
const colors = [f.solid, f["automatic-gradient"], ...Array.isArray(f["linear-gradient"]) ? f["linear-gradient"] : []];
|
|
1078
|
+
return colors.some((c) => {
|
|
1079
|
+
const p = typeof c === "string" ? parseColor(c) : null;
|
|
1080
|
+
if (!p) return false;
|
|
1081
|
+
const a = p.space === "gray" || p.space === "extended-gray" ? p.c[1] : p.c[3];
|
|
1082
|
+
return a !== void 0 && a < 1;
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1028
1085
|
function validate(doc) {
|
|
1029
1086
|
const out = [];
|
|
1030
1087
|
const push = (id, message, target, ref) => out.push({ id, severity: RULES[id].severity, message, target, ...ref ? { ref } : {} });
|
|
@@ -1042,8 +1099,9 @@ function validate(doc) {
|
|
|
1042
1099
|
checkProps("document", doc.props, "document", { kind: "document" });
|
|
1043
1100
|
const fill = resolveProp(doc.props.fill, { appearance: "default" }, "automatic");
|
|
1044
1101
|
if (fill === "none") push("V-05", 'The background fill is "none". Icons need an opaque, full-bleed background.', "document", { kind: "document" });
|
|
1102
|
+
else if (fillHasAlpha(fill)) push("V-05", "The background color is partly transparent. App Store icons are flattened onto black.", "document", { kind: "document" });
|
|
1045
1103
|
const visibleGroups = doc.groups.filter((g) => resolveProp(g.props.hidden, { appearance: "default" }, false) !== true).length;
|
|
1046
|
-
if (visibleGroups > 4) push("V-09", `This icon has ${visibleGroups} visible groups.
|
|
1104
|
+
if (visibleGroups > 4) push("V-09", `This icon has ${visibleGroups} visible groups. Apple recommends at most 4, and Xcode's actool may reject icons with more.`, "document");
|
|
1047
1105
|
let anyDark = false;
|
|
1048
1106
|
let anyTinted = false;
|
|
1049
1107
|
const scan = (props) => {
|
|
@@ -1147,7 +1205,27 @@ function summarizeIssues(issues) {
|
|
|
1147
1205
|
|
|
1148
1206
|
// ../core/src/export.ts
|
|
1149
1207
|
import { strToU8 as strToU82 } from "fflate";
|
|
1208
|
+
function targetsWatch(doc) {
|
|
1209
|
+
const sp = baseValue(doc.props["supported-platforms"]);
|
|
1210
|
+
return Array.isArray(sp?.circles) && sp.circles.includes("watchOS");
|
|
1211
|
+
}
|
|
1212
|
+
function unrenderableLayers(doc) {
|
|
1213
|
+
const out = [];
|
|
1214
|
+
for (const g of doc.groups) {
|
|
1215
|
+
if (baseValue(g.props.hidden) === true) continue;
|
|
1216
|
+
for (const l of g.layers) {
|
|
1217
|
+
if (baseValue(l.props.hidden) === true) continue;
|
|
1218
|
+
const name = baseValue(l.props["image-name"]);
|
|
1219
|
+
const a = typeof name === "string" ? doc.assets[name] : void 0;
|
|
1220
|
+
if (!a || a.kind === "pdf" || a.kind === "other") out.push(`${baseValue(g.props.name) ?? "Group"}/${baseValue(l.props.name) ?? name ?? "Layer"}`);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
return out;
|
|
1224
|
+
}
|
|
1225
|
+
var MAC_INSET = 100;
|
|
1150
1226
|
var ICNS_TYPES = [
|
|
1227
|
+
["icp4", 16],
|
|
1228
|
+
["icp5", 32],
|
|
1151
1229
|
["ic11", 32],
|
|
1152
1230
|
// 16@2x
|
|
1153
1231
|
["ic12", 64],
|
|
@@ -1186,15 +1264,15 @@ function encodeIcns(pngBySize) {
|
|
|
1186
1264
|
}
|
|
1187
1265
|
async function buildIcns(rasterize, appearance = "default") {
|
|
1188
1266
|
const pngs = {};
|
|
1189
|
-
for (const s of ICNS_SIZES) pngs[s] = await rasterize(appearance, s);
|
|
1267
|
+
for (const s of ICNS_SIZES) pngs[s] = tagSrgb(await rasterize(appearance, s, { mask: "squircle", idiom: "macOS", inset: MAC_INSET }));
|
|
1190
1268
|
return encodeIcns(pngs);
|
|
1191
1269
|
}
|
|
1192
|
-
async function buildAppIconSet(rasterize) {
|
|
1270
|
+
async function buildAppIconSet(rasterize, opts = {}) {
|
|
1193
1271
|
const files = {};
|
|
1194
1272
|
const images = [];
|
|
1195
|
-
files["AppIcon.png"] = flattenPng(await rasterize("default", 1024, { mask: "none" }));
|
|
1196
|
-
files["AppIcon-Dark.png"] =
|
|
1197
|
-
files["AppIcon-Tinted.png"] = flattenPng(await rasterize("tinted-dark", 1024, { mask: "none", tint: "#FFFFFF" }));
|
|
1273
|
+
files["AppIcon.png"] = flattenPng(await rasterize("default", 1024, { mask: "none", idiom: "iOS" }));
|
|
1274
|
+
files["AppIcon-Dark.png"] = tagSrgb(await rasterize("dark", 1024, { mask: "none", idiom: "iOS", background: false }));
|
|
1275
|
+
files["AppIcon-Tinted.png"] = flattenPng(await rasterize("tinted-dark", 1024, { mask: "none", idiom: "iOS", tint: "#FFFFFF" }));
|
|
1198
1276
|
images.push({ filename: "AppIcon.png", idiom: "universal", platform: "ios", size: "1024x1024" });
|
|
1199
1277
|
images.push({
|
|
1200
1278
|
appearances: [{ appearance: "luminosity", value: "dark" }],
|
|
@@ -1214,16 +1292,20 @@ async function buildAppIconSet(rasterize) {
|
|
|
1214
1292
|
for (const scale of [1, 2]) {
|
|
1215
1293
|
const px = pt * scale;
|
|
1216
1294
|
const name = `mac-${pt}@${scale}x.png`;
|
|
1217
|
-
files[name] = await rasterize("default", px, { mask: "squircle" });
|
|
1295
|
+
files[name] = tagSrgb(await rasterize("default", px, { mask: "squircle", idiom: "macOS", inset: MAC_INSET }));
|
|
1218
1296
|
images.push({ filename: name, idiom: "mac", scale: `${scale}x`, size: `${pt}x${pt}` });
|
|
1219
1297
|
}
|
|
1220
1298
|
}
|
|
1299
|
+
if (opts.watch) {
|
|
1300
|
+
files["AppIcon-Watch.png"] = flattenPng(await rasterize("default", 1024, { mask: "none", idiom: "watchOS" }));
|
|
1301
|
+
images.push({ filename: "AppIcon-Watch.png", idiom: "universal", platform: "watchos", size: "1024x1024" });
|
|
1302
|
+
}
|
|
1221
1303
|
files["Contents.json"] = strToU82(JSON.stringify({ images, info: { author: "fresnel", version: 1 } }, null, 2) + "\n");
|
|
1222
1304
|
return files;
|
|
1223
1305
|
}
|
|
1224
1306
|
async function buildPngSet(rasterize, appearances, sizes) {
|
|
1225
1307
|
const files = {};
|
|
1226
|
-
for (const a of appearances) for (const s of sizes) files[`${a}/icon-${s}.png`] = await rasterize(a, s, { mask: "squircle" });
|
|
1308
|
+
for (const a of appearances) for (const s of sizes) files[`${a}/icon-${s}.png`] = tagSrgb(await rasterize(a, s, { mask: "squircle" }));
|
|
1227
1309
|
return files;
|
|
1228
1310
|
}
|
|
1229
1311
|
|
|
@@ -1281,16 +1363,19 @@ async function saveIcon(path, doc) {
|
|
|
1281
1363
|
const keep = new Set(Object.keys(doc.assets));
|
|
1282
1364
|
await assertNoSymlink(root, assetsDir);
|
|
1283
1365
|
for (const e of await readdir(assetsDir, { withFileTypes: true })) {
|
|
1284
|
-
if (e.isFile() && !keep.has(e.name) && e.name
|
|
1366
|
+
if (e.isFile() && !keep.has(e.name) && !e.name.startsWith(".")) await rm(join(assetsDir, e.name));
|
|
1285
1367
|
}
|
|
1286
1368
|
}
|
|
1287
1369
|
async function writeFiles(dir, files) {
|
|
1288
1370
|
const written = [];
|
|
1371
|
+
const root = resolve(dir);
|
|
1289
1372
|
for (const [rel, bytes] of Object.entries(files)) {
|
|
1290
|
-
const target = resolve(
|
|
1291
|
-
const r = relative(
|
|
1373
|
+
const target = resolve(root, ...rel.split("/"));
|
|
1374
|
+
const r = relative(root, target);
|
|
1292
1375
|
if (r.startsWith("..") || isAbsolute(r)) throw new Error(`Refusing to write outside ${dir}: ${rel}`);
|
|
1376
|
+
await assertNoSymlink(root, target);
|
|
1293
1377
|
await mkdir(dirname(target), { recursive: true });
|
|
1378
|
+
await assertNoSymlink(root, target);
|
|
1294
1379
|
await writeFile(target, bytes);
|
|
1295
1380
|
written.push(target);
|
|
1296
1381
|
}
|
|
@@ -1314,12 +1399,25 @@ function squirclePath(size = CANVAS, exponent = 4.4, steps = 192) {
|
|
|
1314
1399
|
var SQUIRCLE = squirclePath();
|
|
1315
1400
|
var C = CANVAS;
|
|
1316
1401
|
function maskPath(shape) {
|
|
1317
|
-
if (shape === "circle")
|
|
1402
|
+
if (shape === "circle") {
|
|
1403
|
+
const r = C / 2 + 32;
|
|
1404
|
+
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`;
|
|
1405
|
+
}
|
|
1318
1406
|
if (shape === "none") return `M0,0 H${C} V${C} H0Z`;
|
|
1319
1407
|
return SQUIRCLE;
|
|
1320
1408
|
}
|
|
1321
1409
|
|
|
1322
1410
|
// ../render/src/compose.ts
|
|
1411
|
+
function rasterRenderOptions(ro) {
|
|
1412
|
+
if (!ro) return {};
|
|
1413
|
+
return {
|
|
1414
|
+
...ro.mask ? { mask: ro.mask, rim: ro.mask !== "none" } : {},
|
|
1415
|
+
...ro.tint ? { tint: ro.tint } : {},
|
|
1416
|
+
...ro.idiom ? { idiom: ro.idiom } : {},
|
|
1417
|
+
...ro.background === false ? { background: false } : {},
|
|
1418
|
+
...ro.inset ? { inset: ro.inset } : {}
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1323
1421
|
var C2 = CANVAS;
|
|
1324
1422
|
var H = C2 / 2;
|
|
1325
1423
|
var b64Cache = /* @__PURE__ */ new WeakMap();
|
|
@@ -1417,7 +1515,8 @@ function renderSvg(doc, opts = {}) {
|
|
|
1417
1515
|
let darken = 0;
|
|
1418
1516
|
if (dark && !darkOverridden && bgFill !== "automatic" && bgFill !== "system-dark" && bgFill !== "none") darken = 0.62;
|
|
1419
1517
|
let bgPaint;
|
|
1420
|
-
const
|
|
1518
|
+
const P = mask === "circle" ? 32 : 0;
|
|
1519
|
+
const full = { x: -P, y: -P, w: C2 + 2 * P, h: C2 + 2 * P };
|
|
1421
1520
|
const monoOverride = (isClear || isTinted) && !!fillProp?.specs?.some((s) => s.appearance === "tinted" || s.appearance === appearance);
|
|
1422
1521
|
if (monoOverride) {
|
|
1423
1522
|
bgPaint = paintFor(bgFill ?? "automatic", defs, full, dark);
|
|
@@ -1436,12 +1535,16 @@ function renderSvg(doc, opts = {}) {
|
|
|
1436
1535
|
if (bgFill === void 0) bgFill = "automatic";
|
|
1437
1536
|
bgPaint = paintFor(bgFill, defs, full, dark);
|
|
1438
1537
|
}
|
|
1538
|
+
if (opts.background === false) {
|
|
1539
|
+
bgPaint = null;
|
|
1540
|
+
darken = 0;
|
|
1541
|
+
}
|
|
1439
1542
|
const groups = FRONT_FIRST ? [...doc.groups].reverse() : [...doc.groups];
|
|
1440
1543
|
let stackId = defs.id("s");
|
|
1441
1544
|
const bgStackId = stackId;
|
|
1442
1545
|
const contentIds = [];
|
|
1443
1546
|
defs.add(
|
|
1444
|
-
`<g id="${stackId}">${bgPaint ? `<rect width="${C2}" height="${C2}" fill="${bgPaint}"/>` : ""}` + (darken ? `<rect width="${C2}" height="${C2}" fill="#000" opacity="${darken}"/>` : "") + `</g>`
|
|
1547
|
+
`<g id="${stackId}">${bgPaint ? `<rect x="${-P}" y="${-P}" width="${C2 + 2 * P}" height="${C2 + 2 * P}" fill="${bgPaint}"/>` : ""}` + (darken ? `<rect x="${-P}" y="${-P}" width="${C2 + 2 * P}" height="${C2 + 2 * P}" fill="#000" opacity="${darken}"/>` : "") + `</g>`
|
|
1445
1548
|
);
|
|
1446
1549
|
let plainId = bgStackId;
|
|
1447
1550
|
for (const g of groups) {
|
|
@@ -1481,7 +1584,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1481
1584
|
iid = defs.id("img");
|
|
1482
1585
|
imgId.set(p.asset.name, iid);
|
|
1483
1586
|
defs.add(
|
|
1484
|
-
`<image id="${iid}" width="${n(p.asset.width)}" height="${n(p.asset.height)}" preserveAspectRatio="none" href="data:${mimeOf(p.asset)};base64,${b64(p.asset.bytes)}"/>`
|
|
1587
|
+
`<image id="${iid}" width="${n(p.asset.width)}" height="${n(p.asset.height)}" preserveAspectRatio="none" href="data:${mimeOf(p.asset)};base64,${b64(p.asset.display ?? p.asset.bytes)}"/>`
|
|
1485
1588
|
);
|
|
1486
1589
|
}
|
|
1487
1590
|
const s = p.w / p.asset.width;
|
|
@@ -1538,9 +1641,21 @@ function renderSvg(doc, opts = {}) {
|
|
|
1538
1641
|
`<g mask="url(#${mid})"><use href="#${backdropId}" filter="url(#${fid})" transform="translate(${H} ${H}) scale(${n(zoom)}) translate(${-H} ${-H})"/></g>`
|
|
1539
1642
|
);
|
|
1540
1643
|
}
|
|
1541
|
-
const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (
|
|
1644
|
+
const gOpacity = resolveProp(g.props.opacity, ctx, 1) * (refracts && !translucent ? 1 - (refr.strength ?? 0) * 0.3 : 1);
|
|
1645
|
+
let fadeMask = "";
|
|
1646
|
+
if (translucent) {
|
|
1647
|
+
const v = Math.min(1, Math.max(0, tr.value));
|
|
1648
|
+
const gid = defs.id("tg");
|
|
1649
|
+
const mid = defs.id("tm");
|
|
1650
|
+
defs.add(
|
|
1651
|
+
`<linearGradient id="${gid}" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff" stop-opacity="${n(1 - v * 0.2)}"/><stop offset="1" stop-color="#fff" stop-opacity="${n(1 - v * 0.85)}"/></linearGradient><mask id="${mid}" maskUnits="objectBoundingBox" maskContentUnits="objectBoundingBox" x="0" y="0" width="1" height="1"><rect width="1" height="1" fill="url(#${gid})"/></mask>`
|
|
1652
|
+
);
|
|
1653
|
+
fadeMask = ` mask="url(#${mid})"`;
|
|
1654
|
+
}
|
|
1542
1655
|
const gBlend = cssBlend(resolveProp(g.props["blend-mode"], ctx, "normal"));
|
|
1543
|
-
out.push(
|
|
1656
|
+
out.push(
|
|
1657
|
+
`<g${fadeMask}${gOpacity < 1 ? ` opacity="${n(gOpacity)}"` : ""}${gBlend ? ` style="mix-blend-mode:${gBlend}"` : ""}><use href="#${contentId}"/></g>`
|
|
1658
|
+
);
|
|
1544
1659
|
const specular = resolveProp(g.props.specular, ctx, true);
|
|
1545
1660
|
if (effects && specular && glassEls.length) {
|
|
1546
1661
|
const k = 7;
|
|
@@ -1587,7 +1702,14 @@ function renderSvg(doc, opts = {}) {
|
|
|
1587
1702
|
const clipId = defs.id("clip");
|
|
1588
1703
|
const shape = maskPath(mask);
|
|
1589
1704
|
defs.add(`<clipPath id="${clipId}"><path d="${shape}"/></clipPath>`);
|
|
1590
|
-
|
|
1705
|
+
const M = Math.max(0, opts.inset ?? 0);
|
|
1706
|
+
if (M > 0) {
|
|
1707
|
+
const sid = defs.id("ish");
|
|
1708
|
+
defs.add(
|
|
1709
|
+
`<filter id="${sid}" x="-20%" y="-20%" width="140%" height="150%"><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>`
|
|
1710
|
+
);
|
|
1711
|
+
body.push(`<g filter="url(#${sid})"><g clip-path="url(#${clipId})">${top}</g></g>`);
|
|
1712
|
+
} else body.push(`<g clip-path="url(#${clipId})">${top}</g>`);
|
|
1591
1713
|
if (effects && opts.rim !== false && mask !== "none") {
|
|
1592
1714
|
const rid = defs.id("rim");
|
|
1593
1715
|
const lx = H + toLight.x * H;
|
|
@@ -1597,7 +1719,7 @@ function renderSvg(doc, opts = {}) {
|
|
|
1597
1719
|
);
|
|
1598
1720
|
body.push(`<g clip-path="url(#${clipId})"><path d="${shape}" fill="none" stroke="url(#${rid})" stroke-width="10"/></g>`);
|
|
1599
1721
|
}
|
|
1600
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${size}" height="${size}" viewBox="
|
|
1722
|
+
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>`;
|
|
1601
1723
|
}
|
|
1602
1724
|
function cssBlend(mode) {
|
|
1603
1725
|
if (!mode || mode === "normal") return null;
|
|
@@ -1656,7 +1778,7 @@ async function renderSheetPng(doc, opts = {}) {
|
|
|
1656
1778
|
return svgToPng(renderSheetSvg(doc, opts), cell * 3);
|
|
1657
1779
|
}
|
|
1658
1780
|
function nodeRasterizer(doc, opts = {}) {
|
|
1659
|
-
return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...ro
|
|
1781
|
+
return (appearance, size, ro) => renderPng(doc, { ...opts, appearance, size, ...rasterRenderOptions(ro) });
|
|
1660
1782
|
}
|
|
1661
1783
|
|
|
1662
1784
|
// ../mcp/src/tools.ts
|
|
@@ -1675,7 +1797,7 @@ https://developer.apple.com/design/human-interface-guidelines/app-icons
|
|
|
1675
1797
|
- An icon is a background plus one or more foreground layers. The system adds Liquid Glass effects (specular highlights, refraction, translucency) and adapts them to size and system version.
|
|
1676
1798
|
- Canvas: 1024\xD71024 for iOS/iPadOS/macOS (masked to a rounded rectangle) and 1088\xD71088 for watchOS (masked to a circle).
|
|
1677
1799
|
- Appearances: Default, Dark, Clear light, Clear dark, Tinted light, Tinted dark. If you don't provide a variant, the system generates one.
|
|
1678
|
-
- Groups: Liquid Glass effects (mode, specular Off/Automatic/Inside/Outside, blur, refraction, translucency, shadow) are configured per group. Use at most 4 groups
|
|
1800
|
+
- Groups: Liquid Glass effects (mode, specular Off/Automatic/Inside/Outside, blur, refraction, translucency, shadow) are configured per group. Use at most 4 groups (Apple's recommendation; some actool versions reject more).
|
|
1679
1801
|
- In Icon Composer the appearances are Default, Dark and Mono (Mono covers clear and tinted); icon.json stores Mono overrides under the "tinted" key.
|
|
1680
1802
|
|
|
1681
1803
|
## Layers
|
|
@@ -1735,7 +1857,7 @@ ${table("Layer", LAYER_ATTRS)}
|
|
|
1735
1857
|
`;
|
|
1736
1858
|
|
|
1737
1859
|
// ../mcp/src/tools.ts
|
|
1738
|
-
var VERSION = "0.1.
|
|
1860
|
+
var VERSION = "0.1.1";
|
|
1739
1861
|
var appearanceEnum = z2.enum(APPEARANCES);
|
|
1740
1862
|
var overrideEnum = z2.enum(["dark", "tinted"]);
|
|
1741
1863
|
var maskEnum = z2.enum(["squircle", "circle", "none"]);
|
|
@@ -2074,10 +2196,14 @@ function createServer(ws) {
|
|
|
2074
2196
|
const doc = await ws.read(full);
|
|
2075
2197
|
const name = basename(full).replace(/\.icon(\.zip)?$/, "");
|
|
2076
2198
|
const raster = nodeRasterizer(doc);
|
|
2199
|
+
const missing = unrenderableLayers(doc);
|
|
2200
|
+
if (missing.length && formats.some((f) => f !== "icon"))
|
|
2201
|
+
throw new Error(`These visible layers can't be rendered and would be missing from the images: ${missing.join(", ")}. Fix them first (icon_validate).`);
|
|
2077
2202
|
const written = [];
|
|
2078
2203
|
if (formats.includes("icon")) {
|
|
2079
|
-
const
|
|
2080
|
-
|
|
2204
|
+
const dest = join2(outDir, `${name}.icon`);
|
|
2205
|
+
await saveIcon(dest, doc);
|
|
2206
|
+
written.push(join2(dest, "icon.json"), ...Object.keys(doc.assets).map((a) => join2(dest, "Assets", a)));
|
|
2081
2207
|
}
|
|
2082
2208
|
if (formats.includes("icns")) {
|
|
2083
2209
|
const p = join2(outDir, `${name}.icns`);
|
|
@@ -2085,7 +2211,7 @@ function createServer(ws) {
|
|
|
2085
2211
|
await writeFile2(p, await buildIcns(raster));
|
|
2086
2212
|
written.push(p);
|
|
2087
2213
|
}
|
|
2088
|
-
if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster)));
|
|
2214
|
+
if (formats.includes("appiconset")) written.push(...await writeFiles(join2(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) })));
|
|
2089
2215
|
if (formats.includes("png"))
|
|
2090
2216
|
written.push(
|
|
2091
2217
|
...await writeFiles(
|
|
@@ -2245,7 +2371,7 @@ Usage
|
|
|
2245
2371
|
Render an approximate preview PNG
|
|
2246
2372
|
fresnel export <Name.icon> -o <dir> [--formats icon,icns,appiconset,png]
|
|
2247
2373
|
[--sizes 1024,512,...] [--appearances default,dark,...]
|
|
2248
|
-
[--tint #RRGGBB]
|
|
2374
|
+
[--tint #RRGGBB] [--force]
|
|
2249
2375
|
fresnel mcp [--root <dir>]... Start the MCP server on stdio
|
|
2250
2376
|
|
|
2251
2377
|
Render options: --mask squircle|circle|none, --tint #RRGGBB (tinted appearances)
|
|
@@ -2327,6 +2453,7 @@ async function main() {
|
|
|
2327
2453
|
tint: { type: "string" },
|
|
2328
2454
|
mask: { type: "string" },
|
|
2329
2455
|
root: { type: "string", short: "r", multiple: true },
|
|
2456
|
+
force: { type: "boolean" },
|
|
2330
2457
|
help: { type: "boolean", short: "h" }
|
|
2331
2458
|
}
|
|
2332
2459
|
});
|
|
@@ -2381,14 +2508,19 @@ async function main() {
|
|
|
2381
2508
|
const formats = (values.formats ?? "icon,icns,appiconset,png").split(",").map((s) => s.trim());
|
|
2382
2509
|
const raster = nodeRasterizer(doc, values.tint ? { tint: values.tint } : {});
|
|
2383
2510
|
const outDir = resolve3(values.out);
|
|
2511
|
+
const missing = unrenderableLayers(doc);
|
|
2512
|
+
if (missing.length && formats.some((f) => f !== "icon") && !values.force)
|
|
2513
|
+
throw new Error(`These visible layers can't be rendered and would be missing from the images: ${missing.join(", ")}. Fix them, or pass --force.`);
|
|
2384
2514
|
let n2 = 0;
|
|
2385
2515
|
for (const f of formats) {
|
|
2386
|
-
if (f === "icon")
|
|
2387
|
-
|
|
2516
|
+
if (f === "icon") {
|
|
2517
|
+
await saveIcon(join3(outDir, `${name}.icon`), doc);
|
|
2518
|
+
n2 += 1 + Object.keys(doc.assets).length;
|
|
2519
|
+
} else if (f === "icns") {
|
|
2388
2520
|
await mkdir3(outDir, { recursive: true });
|
|
2389
2521
|
await writeFile3(join3(outDir, `${name}.icns`), await buildIcns(raster));
|
|
2390
2522
|
n2++;
|
|
2391
|
-
} else if (f === "appiconset") n2 += (await writeFiles(join3(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster))).length;
|
|
2523
|
+
} else if (f === "appiconset") n2 += (await writeFiles(join3(outDir, "AppIcon.appiconset"), await buildAppIconSet(raster, { watch: targetsWatch(doc) }))).length;
|
|
2392
2524
|
else if (f === "png") {
|
|
2393
2525
|
const sizes = (values.sizes ?? "1024,512,256,128,64,32,16").split(",").map(Number);
|
|
2394
2526
|
n2 += (await writeFiles(join3(outDir, `${name}-png`), await buildPngSet(raster, appearances(values.appearances), sizes))).length;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fresnel-icon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "fresnel CLI: build, validate, render and export Liquid Glass app icons (.icon bundles) from the command line or CI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"tsup": "9.0.0",
|
|
40
40
|
"tsx": "4.23.15",
|
|
41
|
-
"@fresnel/core": "0.1.
|
|
42
|
-
"@fresnel/render": "0.1.
|
|
41
|
+
"@fresnel/core": "0.1.1",
|
|
42
|
+
"@fresnel/render": "0.1.1"
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsup",
|