/** * Tell a site's BARE MARK apart from its BADGE. * * A brand band wants the logo artwork, not the browser-tab tile. Those are usually the same * drawing in two packagings: * * bare mark — the glyph alone, transparent margins. Reads on any background. * badge — that glyph knocked out of, or sitting on, a full-bleed disc or square. * * Shown on a transparency checker a badge reads as a solid blob, which is exactly the complaint * that produced this code. Neither is wrong; they belong in different tiles. * * Both rules below are deliberately cheap and deterministic — no rendering for SVG, one small * resize for raster. They answer "does this drawing carry its own backdrop", nothing more. */ import { parseHTML } from "linkedom"; import { loadSharp } from "../utils/optionalPackages.js"; /** What a downloaded icon is, as a drawing. `unknown` when the format cannot be inspected. */ export type IconShape = "bare-mark" | "badge" | "unknown"; export interface IconVerdict { shape: IconShape; /** Why, in one human-readable clause. Recorded in the manifest so a pick is never unexplained. */ reason: string; } /** Elements that describe paint without applying it; a shape inside one is never the backdrop. */ const NON_PAINTING = new Set(["defs", "clippath", "mask", "style", "title", "desc", "metadata"]); /** Elements that paint. `g` is a container, so it is walked through rather than judged. */ const PAINTING = new Set(["rect", "circle", "ellipse", "path", "polygon", "image", "use"]); function firstPaintedElement(root: Element): Element | null { for (const child of Array.from(root.children)) { const tag = child.tagName.toLowerCase(); if (NON_PAINTING.has(tag)) continue; if (tag === "g" || tag === "svg") { const nested = firstPaintedElement(child); if (nested) return nested; continue; } if (PAINTING.has(tag)) return child; } return null; } function num(el: Element, attr: string): number | null { const raw = el.getAttribute(attr); if (raw === null) return null; const n = Number.parseFloat(raw); return Number.isFinite(n) ? n : null; } function positiveBox(w: number | null, h: number | null): { w: number; h: number } | null { return w !== null && h !== null && w > 0 && h > 0 ? { w, h } : null; } /** The `viewBox`'s own width/height, ignoring its origin. */ function canvasFromViewBox(viewBox: string | null): { w: number; h: number } | null { if (!viewBox) return null; const parts = viewBox .trim() .split(/[\s,]+/) .map(Number); if (parts.length !== 4 || !parts.every(Number.isFinite)) return null; return positiveBox(parts[2]!, parts[3]!); } /** viewBox width/height, falling back to the width/height attributes. */ function canvasOf(svg: Element): { w: number; h: number } | null { return ( canvasFromViewBox(svg.getAttribute("viewBox")) ?? positiveBox(num(svg, "width"), num(svg, "height")) ); } /** A backdrop covers essentially the whole canvas; anything smaller leaves margins. */ const FULL_BLEED_RATIO = 0.95; /** * Classify an SVG by MARKUP, not by rendering it. * * Rendering would be the obvious approach and it does not work here: these files routinely fill * with CSS custom properties under a `prefers-color-scheme` query, and librsvg (what sharp uses) * resolves neither — openai.com's favicon rasterises to a fully transparent image through sharp * while Chrome draws it correctly. Markup is the only reliable signal available offline. */ export function classifySvgIcon(source: string): IconVerdict { const { document } = parseHTML(`
${source}`); const svg = document.querySelector("svg"); if (!svg) return { shape: "unknown", reason: "no