@tileflow/dev 0.1.0-alpha.25 → 0.1.0-alpha.27

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.
@@ -8,7 +8,10 @@ import { readdir as readdir2, readFile, realpath as realpath3, stat } from "fs/p
8
8
  import { extname, isAbsolute as isAbsolute2, relative as relative2, resolve as resolvePath, sep as sep2 } from "path";
9
9
  import { SaxesParser } from "saxes";
10
10
  import {
11
+ assertTileflowIconLayoutBounds as assertTileflowIconLayoutBounds2,
11
12
  collectTileflowIconSetReferences as collectTileflowIconSetReferences2,
13
+ tileflowIconAuthorMetadataSchema,
14
+ tileflowIconMetadataFileName,
12
15
  TileflowIconSetError as TileflowIconSetError5,
13
16
  tileflowIconsLockfileName as tileflowIconsLockfileName3,
14
17
  tileflowIconSpriteIndexSchema
@@ -18,7 +21,7 @@ import {
18
21
  hashTileflowRenderedIconPixels as hashTileflowRenderedIconPixels3,
19
22
  parseResolvedTileflowMap,
20
23
  parseTileflowIconJson as parseTileflowIconJson2,
21
- serializeCanonicalJson as serializeCanonicalJson2,
24
+ serializeCanonicalJson as serializeCanonicalJson3,
22
25
  sha256Hex as sha256Hex3,
23
26
  tileflowIconIdPattern,
24
27
  tileflowIconPackageLimits as tileflowIconPackageLimits4
@@ -33,8 +36,8 @@ import {
33
36
  compareCodeUnits as compareCodeUnits3,
34
37
  parseTileflowIconComposition,
35
38
  parseTileflowIconsLockfile as parseTileflowIconsLockfile2,
36
- TileflowIconSetError as TileflowIconSetError4,
37
- tileflowIconPackageLimits as tileflowIconPackageLimits3
39
+ tileflowIconPackageLimits as tileflowIconPackageLimits3,
40
+ TileflowIconSetError as TileflowIconSetError4
38
41
  } from "@tileflow/core";
39
42
 
40
43
  // src/icon-cache.ts
@@ -53,21 +56,28 @@ import {
53
56
  tileflowIconSpriteIndexSchema as indexSchema
54
57
  } from "@tileflow/core";
55
58
  import {
59
+ assertTileflowSdfPixels as assertTileflowSdfPixels2,
56
60
  compareCodeUnits as compareCodeUnits2,
57
61
  hashTileflowIconPackageManifest as hashTileflowIconPackageManifest2,
58
62
  hashTileflowRenderedIconPixels as hashTileflowRenderedIconPixels2,
59
63
  parseTileflowIconJson,
64
+ readTileflowIconLayout,
65
+ scaleTileflowIconLayout as scaleTileflowIconLayout2,
66
+ serializeCanonicalJson as serializeCanonicalJson2,
60
67
  sha256Hex as sha256Hex2,
61
- TileflowIconSetError,
62
68
  tileflowIconPackageLimits as tileflowIconPackageLimits2,
63
- tileflowIconPackageManifestSchema as tileflowIconPackageManifestSchema2
69
+ tileflowIconPackageManifestSchema as tileflowIconPackageManifestSchema2,
70
+ TileflowIconSetError
64
71
  } from "@tileflow/core";
65
72
 
66
73
  // src/icon-sprite.ts
67
74
  import {
75
+ assertTileflowIconLayoutBounds,
76
+ assertTileflowSdfPixels,
68
77
  compareCodeUnits,
69
78
  hashTileflowIconPackageManifest,
70
79
  hashTileflowRenderedIconPixels,
80
+ scaleTileflowIconLayout,
71
81
  serializeCanonicalJson,
72
82
  sha256Hex,
73
83
  tileflowIconIdSchema,
@@ -77,22 +87,41 @@ import {
77
87
  async function packTileflowRenderedIcons(input) {
78
88
  const icons = [...input].sort((left, right) => compareCodeUnits(left.id, right.id));
79
89
  if (icons.length < 1 || icons.length > tileflowIconPackageLimits.maxIconCount || new Set(icons.map((icon) => icon.id)).size !== icons.length)
80
- throw new Error("Expected 1 through 256 unique rendered icons");
90
+ throw new Error(
91
+ `Expected 1 through ${tileflowIconPackageLimits.maxIconCount} unique rendered icons`
92
+ );
81
93
  const renderedIcons = [];
82
94
  for (const icon of icons) {
83
95
  tileflowIconIdSchema.parse(icon.id);
96
+ if (icon.layout) assertTileflowIconLayoutBounds(icon.layout, icon.oneX.width, icon.oneX.height);
97
+ if (icon.appearance) {
98
+ assertTileflowSdfPixels({ ...icon.oneX, pixelRatio: 1 });
99
+ assertTileflowSdfPixels({ ...icon.twoX, pixelRatio: 2 });
100
+ }
84
101
  if (icon.twoX.width !== icon.oneX.width * 2 || icon.twoX.height !== icon.oneX.height * 2)
85
102
  throw new Error("Rendered density geometry must double exactly");
86
103
  renderedIcons.push({
87
104
  name: icon.id,
105
+ ...icon.appearance ? { appearance: icon.appearance } : {},
106
+ ...icon.layout ? { layout: icon.layout } : {},
88
107
  pixelSha256: {
89
108
  oneX: await hashTileflowRenderedIconPixels({ ...icon.oneX, pixelRatio: 1 }),
90
109
  twoX: await hashTileflowRenderedIconPixels({ ...icon.twoX, pixelRatio: 2 })
91
110
  }
92
111
  });
93
112
  }
94
- const oneX = icons.map((icon) => ({ ...icon.oneX, name: icon.id }));
95
- const twoX = icons.map((icon) => ({ ...icon.twoX, name: icon.id }));
113
+ const oneX = icons.map((icon) => ({
114
+ ...icon.oneX,
115
+ name: icon.id,
116
+ appearance: icon.appearance,
117
+ layout: icon.layout
118
+ }));
119
+ const twoX = icons.map((icon) => ({
120
+ ...icon.twoX,
121
+ name: icon.id,
122
+ appearance: icon.appearance,
123
+ layout: icon.layout
124
+ }));
96
125
  const layoutOneX = createSpriteLayout(oneX, 1);
97
126
  const layoutTwoX = createSpriteLayout(twoX, 2);
98
127
  if (layoutTwoX.width !== layoutOneX.width * 2 || layoutTwoX.height !== layoutOneX.height * 2)
@@ -123,7 +152,7 @@ async function packTileflowRenderedIcons(input) {
123
152
  ];
124
153
  assertGeneratedFileLimits(files);
125
154
  const manifest = tileflowIconPackageManifestSchema.parse({
126
- format: "tileflow-icon-package-v1",
155
+ format: icons.some((icon) => icon.appearance || icon.layout) ? "tileflow-icon-package-v2" : "tileflow-icon-package-v1",
127
156
  files: await Promise.all(
128
157
  files.map(async (file) => ({
129
158
  name: file.fileName,
@@ -174,7 +203,15 @@ function createSpriteLayout(icons, pixelRatio) {
174
203
  const placement = placements[ordinal];
175
204
  return [
176
205
  icon.name,
177
- { height: icon.height, pixelRatio, width: icon.width, x: placement.left, y: placement.top }
206
+ {
207
+ height: icon.height,
208
+ pixelRatio,
209
+ width: icon.width,
210
+ x: placement.left,
211
+ y: placement.top,
212
+ ...icon.appearance ? { sdf: true, tileflow: icon.appearance } : {},
213
+ ...icon.layout ? scaleTileflowIconLayout(icon.layout, pixelRatio) : {}
214
+ }
178
215
  ];
179
216
  })
180
217
  );
@@ -279,6 +316,16 @@ async function verifyTileflowIconArtifact(input) {
279
316
  for (const name of manifest.iconNames) {
280
317
  const one = oneIndex[name];
281
318
  const two = twoIndex[name];
319
+ const declared = manifest.renderedIcons.find((icon) => icon.name === name).appearance;
320
+ const declaredLayout = manifest.renderedIcons.find((icon) => icon.name === name).layout;
321
+ const oneLayout = readTileflowIconLayout(one);
322
+ const twoLayout = readTileflowIconLayout(two);
323
+ if (serializeCanonicalJson2(oneLayout ?? null) !== serializeCanonicalJson2(declaredLayout ?? null) || serializeCanonicalJson2(twoLayout ?? null) !== serializeCanonicalJson2(oneLayout ? scaleTileflowIconLayout2(oneLayout, 2) : null))
324
+ throw new Error(
325
+ "Sprite layout must match the manifest and scale exactly between densities"
326
+ );
327
+ if (serializeCanonicalJson2(one.tileflow ?? null) !== serializeCanonicalJson2(two.tileflow ?? null) || serializeCanonicalJson2(one.tileflow ?? null) !== serializeCanonicalJson2(declared ?? null))
328
+ throw new Error("Sprite appearance must match at both densities and in the manifest");
282
329
  if (two.x !== one.x * 2 || two.y !== one.y * 2 || two.width !== one.width * 2 || two.height !== one.height * 2)
283
330
  throw new Error("Sprite densities must have exactly doubled geometry");
284
331
  }
@@ -291,7 +338,19 @@ async function verifyTileflowIconArtifact(input) {
291
338
  const expected = manifest.renderedIcons[ordinal].pixelSha256;
292
339
  if (await hashTileflowRenderedIconPixels2({ ...oneX, pixelRatio: 1 }) !== expected.oneX || await hashTileflowRenderedIconPixels2({ ...twoX, pixelRatio: 2 }) !== expected.twoX)
293
340
  throw new Error(`Rendered pixels do not match the manifest for ${id}`);
294
- icons.push({ id, oneX, twoX });
341
+ const appearance = oneIndex[id].tileflow;
342
+ if (appearance) {
343
+ assertTileflowSdfPixels2({ ...oneX, pixelRatio: 1 });
344
+ assertTileflowSdfPixels2({ ...twoX, pixelRatio: 2 });
345
+ }
346
+ const layout = readTileflowIconLayout(oneIndex[id]);
347
+ icons.push({
348
+ id,
349
+ oneX,
350
+ twoX,
351
+ ...appearance ? { appearance } : {},
352
+ ...layout ? { layout } : {}
353
+ });
295
354
  }
296
355
  return { package: { contentHash, manifest, files }, icons };
297
356
  } catch (cause) {
@@ -702,7 +761,9 @@ async function composeTileflowIconSources(input, options) {
702
761
  id: icon.id,
703
762
  width: icon.oneX.width,
704
763
  height: icon.oneX.height,
705
- pixelSha256: { ...pixels }
764
+ pixelSha256: { ...pixels },
765
+ ...icon.appearance ? { appearance: icon.appearance } : {},
766
+ ...icon.layout ? { layout: icon.layout } : {}
706
767
  },
707
768
  null
708
769
  );
@@ -731,7 +792,7 @@ async function composeTileflowIconSources(input, options) {
731
792
  if (winners.size + selected.length > tileflowIconPackageLimits3.maxIconCount)
732
793
  throw new TileflowIconSetError4(
733
794
  "ICON_COMPOSITION_INVALID",
734
- "Composed icon set exceeds 256 effective icons"
795
+ `Composed icon set exceeds ${tileflowIconPackageLimits3.maxIconCount} effective icons`
735
796
  );
736
797
  for (const item of await directory.render(selected)) {
737
798
  localSourceBytes += item.sourceBytes;
@@ -749,8 +810,8 @@ async function composeTileflowIconSources(input, options) {
749
810
  );
750
811
  const iconPackage = sorted.length ? reused ?? await packTileflowRenderedIcons(sorted.map((winner) => winner.icon)) : null;
751
812
  const composition = references.length && iconPackage ? parseTileflowIconComposition({
752
- format: "tileflow-icon-composition-v1",
753
- compositionVersion: 1,
813
+ format: sorted.some((winner) => winner.icon.appearance || winner.icon.layout) ? "tileflow-icon-composition-v2" : "tileflow-icon-composition-v1",
814
+ compositionVersion: sorted.some((winner) => winner.icon.appearance || winner.icon.layout) ? 2 : 1,
754
815
  packageHash: iconPackage.contentHash,
755
816
  contributors,
756
817
  winners: sorted.map(({ ordinal, icon }, index) => ({
@@ -758,7 +819,9 @@ async function composeTileflowIconSources(input, options) {
758
819
  contributor: ordinal,
759
820
  width: icon.oneX.width,
760
821
  height: icon.oneX.height,
761
- pixelSha256: iconPackage.manifest.renderedIcons[index].pixelSha256
822
+ pixelSha256: iconPackage.manifest.renderedIcons[index].pixelSha256,
823
+ ...icon.appearance ? { appearance: icon.appearance } : {},
824
+ ...icon.layout ? { layout: icon.layout } : {}
762
825
  }))
763
826
  }) : null;
764
827
  const previous = /* @__PURE__ */ new Map();
@@ -801,6 +864,90 @@ function isPathInside(root, candidate) {
801
864
  return pathFromRoot === "" || !isAbsolute(pathFromRoot) && pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`);
802
865
  }
803
866
 
867
+ // src/icon-distance.ts
868
+ var infinity = 1e20;
869
+ function createIconDistanceField(rgba, width, height, ratio) {
870
+ if (width !== 24 * ratio || height !== 24 * ratio || rgba.length !== width * height * 4)
871
+ throw new Error("SDF icons require a 24px logical cell at each density.");
872
+ const outer = new Float64Array(width * height);
873
+ const inner = new Float64Array(width * height);
874
+ let occupied = false;
875
+ for (let y = 0; y < height; y++) {
876
+ for (let x = 0; x < width; x++) {
877
+ const index = y * width + x;
878
+ const alpha = rgba[index * 4 + 3] / 255;
879
+ if (alpha && (x < 6 * ratio || y < 6 * ratio || x >= width - 6 * ratio || y >= height - 6 * ratio))
880
+ throw new Error(
881
+ "SDF icons require six transparent pixels of padding inside the 24px cell. Add a transparent border to the source."
882
+ );
883
+ occupied ||= alpha > 0;
884
+ outer[index] = alpha === 1 ? 0 : alpha === 0 ? infinity : Math.max(0, 0.5 - alpha) ** 2;
885
+ inner[index] = alpha === 1 ? infinity : alpha === 0 ? 0 : Math.max(0, alpha - 0.5) ** 2;
886
+ }
887
+ }
888
+ if (!occupied) throw new Error("An SDF silhouette must contain visible pixels.");
889
+ transform(outer, width, height);
890
+ transform(inner, width, height);
891
+ const result = new Uint8Array(rgba.length);
892
+ for (let i = 0; i < outer.length; i++) {
893
+ const distance = Math.sqrt(outer[i]) - Math.sqrt(inner[i]);
894
+ const alpha = Math.round(255 * Math.max(0, Math.min(1, 0.75 - distance / (8 * ratio))));
895
+ result.set([255, 255, 255, alpha], i * 4);
896
+ }
897
+ return result;
898
+ }
899
+ function transform(grid, width, height) {
900
+ const size = Math.max(width, height);
901
+ const samples = new Float64Array(size);
902
+ const result = new Float64Array(size);
903
+ const sites = new Uint32Array(size);
904
+ const bounds = new Float64Array(size + 1);
905
+ const line = (length) => {
906
+ let count = 0;
907
+ sites[0] = 0;
908
+ bounds[0] = -infinity;
909
+ bounds[1] = infinity;
910
+ for (let position = 1; position < length; position++) {
911
+ let intersection;
912
+ do {
913
+ const site = sites[count];
914
+ intersection = (samples[position] + position ** 2 - samples[site] - site ** 2) / (2 * (position - site));
915
+ if (intersection > bounds[count]) break;
916
+ count--;
917
+ } while (count >= 0);
918
+ count++;
919
+ sites[count] = position;
920
+ bounds[count] = intersection;
921
+ bounds[count + 1] = infinity;
922
+ }
923
+ count = 0;
924
+ for (let position = 0; position < length; position++) {
925
+ while (bounds[count + 1] < position) count++;
926
+ result[position] = (position - sites[count]) ** 2 + samples[sites[count]];
927
+ }
928
+ };
929
+ for (let x = 0; x < width; x++) {
930
+ for (let y = 0; y < height; y++) samples[y] = grid[y * width + x];
931
+ line(height);
932
+ for (let y = 0; y < height; y++) grid[y * width + x] = result[y];
933
+ }
934
+ for (let y = 0; y < height; y++) {
935
+ samples.set(grid.subarray(y * width, (y + 1) * width));
936
+ line(width);
937
+ grid.set(result.subarray(0, width), y * width);
938
+ }
939
+ }
940
+
941
+ // src/icon-preview.ts
942
+ import { renderTileflowIconAtlasPixels } from "@tileflow/core";
943
+ async function renderTileflowIconAtlasPreview(source, index) {
944
+ if (!Object.values(index).some((entry) => entry.sdf)) return source;
945
+ const sharp = await loadSharp();
946
+ const { data, info } = await sharp(source, { limitInputPixels: 2048 * 2048 }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
947
+ const rendered = renderTileflowIconAtlasPixels(data, info.width, info.height, index);
948
+ return sharp(rendered, { raw: { width: info.width, height: info.height, channels: 4 } }).png().toBuffer();
949
+ }
950
+
804
951
  // src/icons.ts
805
952
  var TileflowIconCompilationError = class extends Error {
806
953
  code = "ICON_INVALID";
@@ -838,9 +985,15 @@ async function compileTileflowIconPackages(project, options) {
838
985
  if (!composed?.package) {
839
986
  throw new Error(`Missing compiled icon package for map ${request.mapName}`);
840
987
  }
988
+ const appearances = Object.fromEntries(
989
+ composed.package.manifest.renderedIcons.flatMap(
990
+ (icon) => icon.appearance ? [[icon.name, icon.appearance]] : []
991
+ )
992
+ );
841
993
  return [
842
994
  {
843
995
  iconIds: composed.package.manifest.iconNames,
996
+ ...Object.keys(appearances).length ? { appearances } : {},
844
997
  label: request.mapName,
845
998
  mapName: request.mapName,
846
999
  packageHash: composed.package.contentHash
@@ -912,6 +1065,8 @@ async function inspectTileflowIconCatalogs(project, options) {
912
1065
  }
913
1066
  return {
914
1067
  id: winner.id,
1068
+ ...manifestIcon.appearance ? { appearance: manifestIcon.appearance } : {},
1069
+ ...manifestIcon.layout ? { layout: manifestIcon.layout } : {},
915
1070
  rendered: {
916
1071
  oneX: {
917
1072
  atlas: atlasRectangle(oneXAtlas),
@@ -1036,7 +1191,13 @@ async function prepareTileflowCatalogIcons(project, options) {
1036
1191
  const iconPackage = packagesByHash.get(binding.packageHash);
1037
1192
  if (!iconPackage) throw new Error(`Missing compiled icon package for map ${binding.mapName}`);
1038
1193
  const spriteUrl = joinUrl(options.assetBaseUrl, `icons/${binding.mapName}/sprite`);
1039
- mapAssets[binding.mapName] = { icons: { ids: binding.iconIds, sprite: spriteUrl } };
1194
+ mapAssets[binding.mapName] = {
1195
+ icons: {
1196
+ ids: binding.iconIds,
1197
+ sprite: spriteUrl,
1198
+ ...binding.appearances ? { appearances: binding.appearances } : {}
1199
+ }
1200
+ };
1040
1201
  mapIconSources[binding.mapName] = compiled.sourceIdentities[binding.mapName] ?? [];
1041
1202
  const composition = compiled.compositions[binding.mapName];
1042
1203
  if (composition) mapIconCompositions[binding.mapName] = composition;
@@ -1092,7 +1253,7 @@ function getMapIconRequests(project, mapNames) {
1092
1253
  const mapConfig = project.maps[mapName];
1093
1254
  if (!mapConfig) throw new Error(`Unknown Tileflow map: ${mapName}`);
1094
1255
  const sources = parseResolvedTileflowMap(mapConfig).icons ?? [];
1095
- return { mapName, sequenceKey: serializeCanonicalJson2(sources), sources };
1256
+ return { mapName, sequenceKey: serializeCanonicalJson3(sources), sources };
1096
1257
  });
1097
1258
  }
1098
1259
  function uniqueSequences(requests) {
@@ -1161,6 +1322,10 @@ async function inspectIconSource(directory, target, issues) {
1161
1322
  entries.sort((left, right) => compareCodeUnits4(left.name, right.name));
1162
1323
  const candidates = [];
1163
1324
  const names = /* @__PURE__ */ new Set();
1325
+ let metadata = {
1326
+ schemaVersion: 1,
1327
+ icons: {}
1328
+ };
1164
1329
  for (const entry of entries) {
1165
1330
  const entryPath = resolvePath(directory.realPath, entry.name);
1166
1331
  const path = `${directory.configPath}/${entry.name}`;
@@ -1188,6 +1353,24 @@ async function inspectIconSource(directory, target, issues) {
1188
1353
  continue;
1189
1354
  }
1190
1355
  const extension = extname(entry.name).toLowerCase();
1356
+ if (entry.name === tileflowIconMetadataFileName) {
1357
+ try {
1358
+ if (entryStat.size > tileflowIconPackageLimits4.maxSourceFileBytes)
1359
+ throw new Error("Icon metadata exceeds its byte limit");
1360
+ metadata = tileflowIconAuthorMetadataSchema.parse(
1361
+ parseTileflowIconJson2(
1362
+ await readFile(realEntryPath, "utf8"),
1363
+ tileflowIconPackageLimits4.maxSourceFileBytes
1364
+ )
1365
+ );
1366
+ } catch (error) {
1367
+ issues.push({
1368
+ path,
1369
+ message: `Invalid icon metadata: ${error instanceof Error ? error.message : String(error)}`
1370
+ });
1371
+ }
1372
+ continue;
1373
+ }
1191
1374
  if (!iconFileExtensions.has(extension)) {
1192
1375
  continue;
1193
1376
  }
@@ -1225,6 +1408,13 @@ async function inspectIconSource(directory, target, issues) {
1225
1408
  });
1226
1409
  }
1227
1410
  candidates.sort((left, right) => compareCodeUnits4(left.name, right.name));
1411
+ for (const id of Object.keys(metadata.icons)) {
1412
+ if (!names.has(id))
1413
+ issues.push({
1414
+ path: `${directory.configPath}/${tileflowIconMetadataFileName}`,
1415
+ message: `Icon metadata references unknown ID "${id}"`
1416
+ });
1417
+ }
1228
1418
  if (candidates.length === 0) {
1229
1419
  issues.push({
1230
1420
  message: "No supported .svg, .png, .jpg, .jpeg, or .webp icon files were found",
@@ -1245,7 +1435,10 @@ async function inspectIconSource(directory, target, issues) {
1245
1435
  if (target === "hosted" && extname(candidate.fileName).toLowerCase() === ".svg") {
1246
1436
  validateHostedSvg(source, displayPath);
1247
1437
  }
1438
+ const declared = metadata.icons[candidate.name];
1248
1439
  icons.push({
1440
+ ...declared?.representation === "sdf" ? { appearance: { representation: declared.representation, defaults: declared.defaults } } : {},
1441
+ ...declared?.layout ? { layout: declared.layout } : {},
1249
1442
  displayPath,
1250
1443
  fileName: candidate.fileName,
1251
1444
  format: candidate.format,
@@ -1300,8 +1493,27 @@ async function renderIcon(icon, dimensions, pixelRatio) {
1300
1493
  if (info.channels !== 4 || info.width !== targetWidth || info.height !== targetHeight) {
1301
1494
  throw new Error(`Rendered icon ${icon.fileName} did not produce canonical RGBA pixels`);
1302
1495
  }
1303
- const rgba = new Uint8Array(data);
1304
- const png = await sharp(data, {
1496
+ let rgba;
1497
+ try {
1498
+ if (icon.appearance && icon.kind === "pattern")
1499
+ throw new Error("SDF metadata is only supported for icons, not patterns.");
1500
+ if (icon.layout) {
1501
+ if (icon.kind === "pattern")
1502
+ throw new Error("Text-fitting metadata is only supported for icons, not patterns.");
1503
+ assertTileflowIconLayoutBounds2(
1504
+ icon.layout,
1505
+ info.width / pixelRatio,
1506
+ info.height / pixelRatio
1507
+ );
1508
+ }
1509
+ rgba = icon.appearance ? createIconDistanceField(new Uint8Array(data), info.width, info.height, pixelRatio) : new Uint8Array(data);
1510
+ } catch (error) {
1511
+ throw new Error(
1512
+ `${icon.displayPath}: ${error instanceof Error ? error.message : "Invalid icon capabilities"}`,
1513
+ { cause: error }
1514
+ );
1515
+ }
1516
+ const png = await sharp(rgba, {
1305
1517
  raw: { channels: 4, height: info.height, width: info.width }
1306
1518
  }).png({ adaptiveFiltering: false, compressionLevel: 9, palette: false }).toBuffer();
1307
1519
  return { height: info.height, png, rgba, width: info.width };
@@ -1500,7 +1712,9 @@ async function renderIconInputs(inputs) {
1500
1712
  width: twoX.width
1501
1713
  })
1502
1714
  },
1503
- sourceSha256: await sha256Hex3(icon.source),
1715
+ sourceSha256: icon.appearance || icon.layout ? await sha256Hex3(
1716
+ `tileflow-icon-source-v2\0${await sha256Hex3(icon.source)}\0${serializeCanonicalJson3({ ...icon.appearance ? { appearance: icon.appearance } : {}, ...icon.layout ? { layout: icon.layout } : {} })}`
1717
+ ) : await sha256Hex3(icon.source),
1504
1718
  twoX
1505
1719
  };
1506
1720
  });
@@ -1522,7 +1736,10 @@ async function readTileflowIconDirectory(source, options) {
1522
1736
  if (!inspected || issues.length) throw new TileflowIconCompilationError(issues);
1523
1737
  if (inspected.icons.length > tileflowIconPackageLimits4.maxIconCount)
1524
1738
  throw new TileflowIconCompilationError([
1525
- { path: configPath, message: "One icon contributor supports at most 256 exports" }
1739
+ {
1740
+ path: configPath,
1741
+ message: `One icon contributor supports at most ${tileflowIconPackageLimits4.maxIconCount} exports`
1742
+ }
1526
1743
  ]);
1527
1744
  const entries = inspected.icons.map(
1528
1745
  (icon) => ({
@@ -1553,6 +1770,8 @@ async function readTileflowIconDirectory(source, options) {
1553
1770
  return (await renderIconInputs(inputs)).map((rendered) => ({
1554
1771
  icon: {
1555
1772
  id: rendered.input.name,
1773
+ ...rendered.input.appearance ? { appearance: rendered.input.appearance } : {},
1774
+ ...rendered.input.layout ? { layout: rendered.input.layout } : {},
1556
1775
  oneX: {
1557
1776
  height: rendered.oneX.height,
1558
1777
  width: rendered.oneX.width,
@@ -1595,6 +1814,7 @@ export {
1595
1814
  readTileflowIconsLockfile,
1596
1815
  writeTileflowIconsLockfile,
1597
1816
  composeTileflowIconSources,
1817
+ renderTileflowIconAtlasPreview,
1598
1818
  TileflowIconCompilationError,
1599
1819
  iconFileExtensions,
1600
1820
  compileTileflowIconPackages,