icons.helper.ts 5.7 KB

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