icons.helper.ts 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. import fs from "fs";
  2. import path from "path";
  3. import sharp from "sharp";
  4. /**
  5. * Recursively search an object for any property called `icon`
  6. * and push the string value to the supplied Set.
  7. *
  8. * @param obj The object to search (may be nested)
  9. * @param set The Set that collects unique icon strings
  10. */
  11. export function walkForIcons(obj: unknown, set: Set<string>) {
  12. if (obj && typeof obj === "object") {
  13. if (Array.isArray(obj)) {
  14. for (const item of obj) walkForIcons(item, set);
  15. } else {
  16. for (const [key, value] of Object.entries(obj)) {
  17. if (key === "icon" && typeof value === "string") {
  18. set.add(value);
  19. } else {
  20. walkForIcons(value, set);
  21. }
  22. }
  23. }
  24. }
  25. }
  26. /**
  27. * Metadata for a single icon.
  28. * All icons are guaranteed to be square.
  29. */
  30. interface IconMeta {
  31. name: string; // icon name (without extension)
  32. size: number; // width = height
  33. buffer: Buffer;
  34. file: string; // absolute path to the PNG
  35. }
  36. /**
  37. * Position of an icon inside its texture.
  38. */
  39. export interface IconPosition {
  40. name: string;
  41. x: number;
  42. y: number;
  43. size: number;
  44. }
  45. async function validateIcons(icons: string[], scriptOutputPath: string) {
  46. const validIcons: IconMeta[] = [];
  47. for (const icon of icons) {
  48. const absPath = path.resolve(scriptOutputPath, icon);
  49. // Skip if file does not exist or is not a PNG
  50. if (!fs.existsSync(absPath) || path.extname(absPath).toLowerCase() !== ".png") continue;
  51. const stat = fs.statSync(absPath);
  52. if (stat.size === 0) continue; // skip empty files
  53. const buffer = fs.readFileSync(absPath);
  54. const meta = await sharp(buffer).metadata();
  55. if (!meta.width || !meta.height || meta.width !== meta.height) continue; // skip non‑square
  56. validIcons.push({
  57. name: icon,
  58. file: absPath,
  59. buffer,
  60. size: meta.width,
  61. });
  62. }
  63. return validIcons;
  64. }
  65. function groupBySize(metas: IconMeta[]): Record<string, IconMeta[]> {
  66. const groups: Record<string, IconMeta[]> = {};
  67. for (const m of metas) {
  68. const key = String(m.size);
  69. groups[key] ??= [];
  70. groups[key].push(m);
  71. }
  72. return groups;
  73. }
  74. /**
  75. * Packs a set of square icons of the same size into a single texture image.
  76. *
  77. * The icons are laid out in a regular grid (rows × columns) where the
  78. * number of columns is the ceiling of the square root of the icon count,
  79. * and the number of rows is the smallest integer that can hold all icons.
  80. *
  81. * @param icons Array of {@link IconMeta} objects that all share the same `size`.
  82. * @param iconSize Width (and height) of each icon in pixels.
  83. * @returns An object containing:
  84. * - `textureBuffer`: Buffer with the resulting WEBP image that contains
  85. * all icons arranged in the grid.
  86. * - `iconMap`: Mapping from icon name to its position (`x`, `y`,
  87. * `width`, `height`) inside the texture. `width` and `height` are equal
  88. * to `iconSize`.
  89. *
  90. * @remarks
  91. * * All icons are guaranteed to be square; `iconSize` is the common
  92. * dimension of every icon in `icons`.
  93. * * The texture background is fully transparent.
  94. */
  95. async function createTextureMap(
  96. icons: IconMeta[],
  97. iconSize: number
  98. ): Promise<{ textureBuffer: Buffer; iconMap: Record<string, IconPosition> }> {
  99. const n = icons.length;
  100. const cols = Math.ceil(Math.sqrt(n));
  101. const rows = Math.ceil(n / cols);
  102. const canvasW = cols * iconSize;
  103. const canvasH = rows * iconSize;
  104. const composites: sharp.OverlayOptions[] = [];
  105. const map: Record<string, IconPosition> = {};
  106. icons.forEach((icon, idx) => {
  107. const x = (idx % cols) * iconSize;
  108. const y = Math.floor(idx / cols) * iconSize;
  109. composites.push({ input: icon.file, left: x, top: y });
  110. map[icon.name] = { name: icon.name, x, y, size: iconSize };
  111. });
  112. const buffer = await sharp({
  113. create: {
  114. width: canvasW,
  115. height: canvasH,
  116. channels: 4,
  117. background: { r: 0, g: 0, b: 0, alpha: 0 },
  118. },
  119. })
  120. .composite(composites)
  121. .webp({ lossless: true })
  122. .toBuffer();
  123. return { textureBuffer: buffer, iconMap: map };
  124. }
  125. /**
  126. * Build a texture map from a list of PNG icons.
  127. *
  128. * @param icons Array of icon file paths (relative to scriptOutputPath)
  129. * @param scriptOutputPath Base directory where the icons are located
  130. * @param outputDir Destination directory for the generated files.
  131. *
  132. * The function will:
  133. * • Filter out non‑PNG files, missing files or empty entries.
  134. * • Ensure every PNG is square and that group icons by dimensions.
  135. * • Pack the icons into a single WebP texture (grid layout).
  136. * • Output `icon_{size}.webp` and `iconMap.json` into `outputDir`.
  137. */
  138. export async function buildIconTextureMap(icons: string[], scriptOutputPath: string, outputDir: string): Promise<void> {
  139. const validIcons = await validateIcons(icons, scriptOutputPath);
  140. if (validIcons.length === 0) {
  141. throw new Error("No valid PNG icons found.");
  142. }
  143. const iconPositions: IconPosition[] = [];
  144. for (let [size, group] of Object.entries(groupBySize(validIcons))) {
  145. const outTexturePath = path.join(outputDir, `icon_${size}.webp`);
  146. const { textureBuffer, iconMap } = await createTextureMap(group, parseInt(size));
  147. fs.writeFileSync(outTexturePath, textureBuffer);
  148. iconPositions.push(...Object.values(iconMap));
  149. }
  150. const outMapPath = path.join(outputDir, "iconMap.json");
  151. fs.writeFileSync(outMapPath, JSON.stringify(iconPositions, null, 2), "utf-8");
  152. }