Skip to content

Commit 5d90773

Browse files
committed
Improve icon detection
1 parent eec653a commit 5d90773

File tree

2 files changed

+382
-32
lines changed

2 files changed

+382
-32
lines changed
Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
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

Comments
 (0)