|
| 1 | +// ======================================================================== |
| 2 | +// Figma Icon Recognition Algorithm (Plugin API) - v3 |
| 3 | +// ======================================================================== |
| 4 | +// This file provides functions to heuristically determine if a Figma node |
| 5 | +// is likely functioning as an icon. Refined to better handle simple shapes. |
| 6 | + |
| 7 | +// --- Constants --- |
| 8 | + |
| 9 | +const ICON_PRIMITIVE_TYPES: ReadonlySet<NodeType> = new Set([ |
| 10 | + "ELLIPSE", |
| 11 | + "RECTANGLE", |
| 12 | + "STAR", |
| 13 | + "POLYGON", |
| 14 | + "LINE", |
| 15 | + "POLYGON", |
| 16 | +]); |
| 17 | + |
| 18 | +const ICON_COMPLEX_VECTOR_TYPES: ReadonlySet<NodeType> = new Set([ |
| 19 | + "VECTOR", |
| 20 | + "BOOLEAN_OPERATION", |
| 21 | +]); |
| 22 | + |
| 23 | +const ICON_CONTAINER_TYPES: ReadonlySet<NodeType> = new Set([ |
| 24 | + "FRAME", |
| 25 | + "GROUP", |
| 26 | + "COMPONENT", |
| 27 | + "INSTANCE", |
| 28 | +]); |
| 29 | + |
| 30 | +const DISALLOWED_ICON_TYPES: ReadonlySet<NodeType> = new Set([ |
| 31 | + "SLICE", |
| 32 | + "CONNECTOR", |
| 33 | + "STICKY", |
| 34 | + "SHAPE_WITH_TEXT", |
| 35 | + "CODE_BLOCK", |
| 36 | + "WIDGET", |
| 37 | + // 'TEXT' is handled specifically |
| 38 | +]); |
| 39 | + |
| 40 | +// ======================================================================== |
| 41 | +// Helper Functions |
| 42 | +// ======================================================================== |
| 43 | + |
| 44 | +// --- nameSuggestsIcon, isTypicalIconSize, hasIconExportSettings remain the same --- |
| 45 | +// (Assuming they are defined as in the previous version) |
| 46 | + |
| 47 | +/** |
| 48 | + * Checks if a node's name suggests it's an icon based on common naming conventions. |
| 49 | + * (Implementation from previous version) |
| 50 | + */ |
| 51 | +function nameSuggestsIcon(name: string): boolean { |
| 52 | + if (!name || typeof name !== "string" || name.trim() === "") return false; |
| 53 | + const lowerName = name.toLowerCase(); |
| 54 | + const prefixPattern = |
| 55 | + /^(icon[\/\-_]|ic[\/\-_]|icn[\/\-_]|ico[\/\-_]|glyph[\/\-_])/; |
| 56 | + if (prefixPattern.test(lowerName)) return true; |
| 57 | + const suffixPattern = /[\/\-_]icon$/; |
| 58 | + if (suffixPattern.test(lowerName)) return true; |
| 59 | + const containsWordPattern = /([\/\-_]|^)(ic(on)?|glyph)([\/\-_]|$)/; |
| 60 | + if (containsWordPattern.test(lowerName)) return true; |
| 61 | + const variantSizePattern = /(\/|size=|[\/\-_])(xs|sm|md|lg|xl|\d{1,3})(px)?$/; |
| 62 | + if (variantSizePattern.test(lowerName)) { |
| 63 | + if ( |
| 64 | + containsWordPattern.test(lowerName) || |
| 65 | + prefixPattern.test(lowerName) || |
| 66 | + lowerName.includes("/") |
| 67 | + ) |
| 68 | + return true; |
| 69 | + } |
| 70 | + const leadingSizePattern = /^\d{1,3}(px)?\//; |
| 71 | + if (leadingSizePattern.test(lowerName)) return true; |
| 72 | + return false; |
| 73 | +} |
| 74 | + |
| 75 | +/** |
| 76 | + * Checks if a node's dimensions fall within a typical size range for icons. |
| 77 | + * (Implementation from previous version) |
| 78 | + */ |
| 79 | +function isTypicalIconSize( |
| 80 | + node: SceneNode, |
| 81 | + minSize = 8, |
| 82 | + maxSize = 64, // Keep slightly larger max size |
| 83 | + aspectRatioTolerance = 0.5, |
| 84 | +): boolean { |
| 85 | + if ( |
| 86 | + !("width" in node && "height" in node && node.width > 0 && node.height > 0) |
| 87 | + ) |
| 88 | + return false; |
| 89 | + if ( |
| 90 | + node.width < minSize || |
| 91 | + node.height < minSize || |
| 92 | + node.width > maxSize || |
| 93 | + node.height > maxSize |
| 94 | + ) |
| 95 | + return false; |
| 96 | + const aspectRatio = node.width / node.height; |
| 97 | + return ( |
| 98 | + aspectRatio >= 1 - aspectRatioTolerance && |
| 99 | + aspectRatio <= 1 + aspectRatioTolerance |
| 100 | + ); |
| 101 | +} |
| 102 | + |
| 103 | +/** |
| 104 | + * Checks if a node (or its main component) has icon-like export settings. |
| 105 | + * (Implementation from previous version, ensuring suffix check handles potential null/undefined) |
| 106 | + */ |
| 107 | +function hasIconExportSettings(node: SceneNode): boolean { |
| 108 | + let settingsToCheck: ReadonlyArray<ExportSettings> = |
| 109 | + node.exportSettings || []; |
| 110 | + if ( |
| 111 | + node.type === "INSTANCE" && |
| 112 | + node.mainComponent && |
| 113 | + settingsToCheck.length === 0 |
| 114 | + ) { |
| 115 | + settingsToCheck = node.mainComponent.exportSettings || []; |
| 116 | + } |
| 117 | + if (settingsToCheck.length > 0) { |
| 118 | + return settingsToCheck.some( |
| 119 | + (setting) => |
| 120 | + setting.format === "SVG" || |
| 121 | + (setting.format === "PNG" && |
| 122 | + (setting.suffix === "" || /\@[1-4]x$/.test(setting.suffix || ""))) || // Added || '' for safety |
| 123 | + (setting.format === "JPG" && |
| 124 | + (setting.suffix === "" || /\@[1-4]x$/.test(setting.suffix || ""))), // Added || '' for safety |
| 125 | + ); |
| 126 | + } |
| 127 | + return false; |
| 128 | +} |
| 129 | + |
| 130 | +/** |
| 131 | + * Recursively checks if a node's visible content consists primarily of allowed vector shapes. |
| 132 | + * This version differentiates return value slightly based on *how* it's vector-like. |
| 133 | + * Returns: |
| 134 | + * - 'complex' if it's VECTOR, BOOLEAN_OPERATION, or container with vector children. |
| 135 | + * - 'primitive' if it's just a single primitive shape (RECTANGLE, ELLIPSE, etc.). |
| 136 | + * - 'none' if it contains disallowed types or is invisible/empty inappropriately. |
| 137 | + */ |
| 138 | +function checkVectorContentNature( |
| 139 | + node: SceneNode, |
| 140 | +): "complex" | "primitive" | "none" { |
| 141 | + // Direct complex vector types |
| 142 | + if (ICON_COMPLEX_VECTOR_TYPES.has(node.type)) { |
| 143 | + return "complex"; |
| 144 | + } |
| 145 | + |
| 146 | + // Direct primitive vector types |
| 147 | + if (ICON_PRIMITIVE_TYPES.has(node.type)) { |
| 148 | + return "primitive"; |
| 149 | + } |
| 150 | + |
| 151 | + // Disallowed types immediately disqualify |
| 152 | + if (DISALLOWED_ICON_TYPES.has(node.type) || node.type === "TEXT") { |
| 153 | + return "none"; |
| 154 | + } |
| 155 | + |
| 156 | + // Allowed containers: check their children recursively |
| 157 | + if (ICON_CONTAINER_TYPES.has(node.type) && "children" in node) { |
| 158 | + const visibleChildren = node.children.filter((child) => child.visible); |
| 159 | + |
| 160 | + if (visibleChildren.length > 0) { |
| 161 | + let containsComplex = false; |
| 162 | + for (const child of visibleChildren) { |
| 163 | + const childNature = checkVectorContentNature(child); |
| 164 | + if (childNature === "none") { |
| 165 | + return "none"; // Any disallowed child disqualifies the container |
| 166 | + } |
| 167 | + if (childNature === "complex") { |
| 168 | + containsComplex = true; |
| 169 | + } |
| 170 | + } |
| 171 | + // If all children passed, return 'complex' if at least one child was complex, |
| 172 | + // otherwise return 'primitive' (container only holds primitives). |
| 173 | + return containsComplex ? "complex" : "primitive"; |
| 174 | + } else { |
| 175 | + // Empty container: Allow if it has visual style OR if it's C/I |
| 176 | + const hasVisibleFill = |
| 177 | + "fills" in node && |
| 178 | + Array.isArray(node.fills) && |
| 179 | + node.fills.some( |
| 180 | + (f) => f.visible !== false && f.opacity && f.opacity > 0, |
| 181 | + ); |
| 182 | + const hasVisibleStroke = |
| 183 | + "strokes" in node && |
| 184 | + Array.isArray(node.strokes) && |
| 185 | + node.strokes.some((s) => s.visible !== false); |
| 186 | + if ( |
| 187 | + hasVisibleFill || |
| 188 | + hasVisibleStroke || |
| 189 | + node.type === "COMPONENT" || |
| 190 | + node.type === "INSTANCE" |
| 191 | + ) { |
| 192 | + return "primitive"; // Empty C/I or styled Frame/Group is okay, treat as primitive base |
| 193 | + } else { |
| 194 | + return "none"; // Empty, unstyled Frame/Group is not valid content |
| 195 | + } |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + // Unknown node type or node type without children that wasn't caught earlier |
| 200 | + return "none"; |
| 201 | +} |
| 202 | + |
| 203 | +/** |
| 204 | + * Checks if a container node (Frame or Group) contains a visible TEXT child directly. |
| 205 | + * @param node The SceneNode (should be Frame or Group). |
| 206 | + * @returns True if a visible text child exists. |
| 207 | + */ |
| 208 | +function containsVisibleTextChild(node: SceneNode): boolean { |
| 209 | + if ((node.type === "FRAME" || node.type === "GROUP") && "children" in node) { |
| 210 | + return node.children.some( |
| 211 | + (child) => child.visible && child.type === "TEXT", |
| 212 | + ); |
| 213 | + } |
| 214 | + return false; |
| 215 | +} |
| 216 | + |
| 217 | +// ======================================================================== |
| 218 | +// Main Icon Recognition Function |
| 219 | +// ======================================================================== |
| 220 | + |
| 221 | +/** |
| 222 | + * Analyzes a Figma SceneNode using multiple heuristics to determine if it's likely an icon. |
| 223 | + * Combines checks for type, name, size, structure, export settings, and context. |
| 224 | + * v3: Refined scoring for simple primitives vs complex vectors. |
| 225 | + * |
| 226 | + * @param node The Figma SceneNode to evaluate. |
| 227 | + * @param confidenceThreshold The minimum score required to classify as an icon. (Default: 4) |
| 228 | + * @param logDetails Set to true to print debug information to the console for each node evaluated. |
| 229 | + * @returns True if the node is likely an icon, false otherwise. |
| 230 | + */ |
| 231 | +export function isLikelyIcon( |
| 232 | + node: SceneNode, |
| 233 | + confidenceThreshold = 3, |
| 234 | + logDetails = false, |
| 235 | +): boolean { |
| 236 | + let score = 0; |
| 237 | + const info: string[] = [`Node: ${node.name} (${node.type}, ID: ${node.id})`]; |
| 238 | + |
| 239 | + // --- 1. Initial Filtering Out --- |
| 240 | + if (node.visible === false) { |
| 241 | + info.push("Result: NO (Invisible)"); |
| 242 | + if (logDetails) console.log(info.join(" | ")); |
| 243 | + return false; |
| 244 | + } |
| 245 | + if (DISALLOWED_ICON_TYPES.has(node.type) || node.type === "TEXT") { |
| 246 | + info.push(`Result: NO (Disallowed Type: ${node.type})`); |
| 247 | + if (logDetails) console.log(info.join(" | ")); |
| 248 | + return false; |
| 249 | + } |
| 250 | + if (!("width" in node && "height" in node)) { |
| 251 | + info.push("Result: NO (No dimensions)"); |
| 252 | + if (logDetails) console.log(info.join(" | ")); |
| 253 | + return false; |
| 254 | + } |
| 255 | + |
| 256 | + // Filter overly large nodes unless they are C/I or Frames |
| 257 | + const MAX_NON_CI_FRAME_SIZE = 64; // Tightened max size |
| 258 | + if ( |
| 259 | + node.type !== "COMPONENT" && |
| 260 | + node.type !== "INSTANCE" && |
| 261 | + node.type !== "FRAME" && |
| 262 | + (node.width > MAX_NON_CI_FRAME_SIZE || node.height > MAX_NON_CI_FRAME_SIZE) |
| 263 | + ) { |
| 264 | + info.push( |
| 265 | + `Result: NO (Too large for type ${node.type}: ${node.width}x${node.height})`, |
| 266 | + ); |
| 267 | + if (logDetails) console.log(info.join(" | ")); |
| 268 | + return false; |
| 269 | + } |
| 270 | + |
| 271 | + // --- 2. Scoring Heuristics --- |
| 272 | + let isComponentOrInstance = false; |
| 273 | + let mainComp: ComponentNode | null = null; |
| 274 | + |
| 275 | + // Heuristic: Component/Instance Status (Strong: +2) |
| 276 | + if (node.type === "COMPONENT") { |
| 277 | + score += 2; |
| 278 | + info.push("Is Component (+2)"); |
| 279 | + isComponentOrInstance = true; |
| 280 | + mainComp = node; |
| 281 | + } else if ( |
| 282 | + node.type === "INSTANCE" && |
| 283 | + node.mainComponent?.type === "COMPONENT" |
| 284 | + ) { |
| 285 | + score += 2; |
| 286 | + info.push("Is Instance (+2)"); |
| 287 | + isComponentOrInstance = true; |
| 288 | + mainComp = node.mainComponent; |
| 289 | + } else if (node.type === "INSTANCE") { |
| 290 | + info.push("Is Instance (mainComp inaccessible/invalid)"); |
| 291 | + } |
| 292 | + |
| 293 | + // Heuristic: Naming Convention (Strong: +2) |
| 294 | + if (nameSuggestsIcon(node.name)) { |
| 295 | + score += 2; |
| 296 | + info.push("Name Suggests (+2)"); |
| 297 | + } |
| 298 | + if ( |
| 299 | + mainComp && |
| 300 | + node.name !== mainComp.name && |
| 301 | + nameSuggestsIcon(mainComp.name) |
| 302 | + ) { |
| 303 | + score += 0.5; |
| 304 | + info.push("MainComp Name Suggests (+0.5)"); |
| 305 | + } |
| 306 | + |
| 307 | + // Heuristic: Export Settings (Good: +1) |
| 308 | + if (hasIconExportSettings(node)) { |
| 309 | + score += 1; |
| 310 | + info.push("Has Icon Export Settings (+1)"); |
| 311 | + } |
| 312 | + |
| 313 | + // Heuristic: Size (Good: +1) - Prefer main component size |
| 314 | + const nodeToCheckSize = mainComp || node; |
| 315 | + if (isTypicalIconSize(nodeToCheckSize)) { |
| 316 | + score += 1; |
| 317 | + info.push( |
| 318 | + `Typical Size (${Math.round(nodeToCheckSize.width)}x${Math.round(nodeToCheckSize.height)}) (+1)`, |
| 319 | + ); |
| 320 | + } else if ("width" in nodeToCheckSize) { |
| 321 | + info.push( |
| 322 | + `Atypical Size (${Math.round(nodeToCheckSize.width)}x${Math.round(nodeToCheckSize.height)})`, |
| 323 | + ); |
| 324 | + } |
| 325 | + |
| 326 | + // Heuristic: Structure - Vector Content Nature (Variable Score) |
| 327 | + const contentNature = checkVectorContentNature(node); |
| 328 | + if (contentNature === "complex") { |
| 329 | + score += 3; // Higher score for complex vectors/boolean ops/nested vectors |
| 330 | + info.push("Vector Content [Complex] (+2)"); |
| 331 | + return true; |
| 332 | + } else if (contentNature === "primitive") { |
| 333 | + score += 1; // Lower score if it's just a primitive shape or contains only primitives |
| 334 | + info.push("Vector Content [Primitive] (+1)"); |
| 335 | + } else { |
| 336 | + // contentNature === 'none' |
| 337 | + // Penalize only if it's not a C/I (which might have complex internal reasons for failing) |
| 338 | + if (!isComponentOrInstance) { |
| 339 | + // No score change, but log it. The lack of positive points is the main effect. |
| 340 | + // score -= 1; // Optionally add penalty back if needed |
| 341 | + info.push("Content NOT Vector-Like/Disallowed (-0)"); |
| 342 | + } else { |
| 343 | + info.push("Content NOT Vector-Like (C/I - penalty skipped)"); |
| 344 | + } |
| 345 | + } |
| 346 | + |
| 347 | + // Heuristic: Context - Parent / Component Set (Good: +0.5 to +1) |
| 348 | + if (node.parent) { |
| 349 | + if (node.parent.type === "COMPONENT_SET" && isComponentOrInstance) { |
| 350 | + score += 1; |
| 351 | + info.push("In Component Set (+1)"); |
| 352 | + } else if (nameSuggestsIcon(node.parent.name)) { |
| 353 | + score += 0.5; |
| 354 | + info.push(`Parent Name "${node.parent.name}" Suggests (+0.5)`); |
| 355 | + } |
| 356 | + } |
| 357 | + |
| 358 | + // Heuristic: Problematic Children (Penalty: -1) |
| 359 | + if (containsVisibleTextChild(node)) { |
| 360 | + score -= 1; |
| 361 | + info.push("Contains Visible Text Child (-1)"); |
| 362 | + } |
| 363 | + |
| 364 | + // --- 3. Decision --- |
| 365 | + const isIcon = score >= confidenceThreshold; |
| 366 | + // Round score for cleaner logging if using decimals |
| 367 | + const displayScore = Math.round(score * 10) / 10; |
| 368 | + info.push( |
| 369 | + `Score: ${displayScore} / ${confidenceThreshold} -> ${isIcon ? "YES" : "NO"}`, |
| 370 | + ); |
| 371 | + if (logDetails) console.log(info.join(" | ")); |
| 372 | + |
| 373 | + return isIcon; |
| 374 | +} |
0 commit comments