diff --git a/packages/cli/src/capture/assetDownloader.test.ts b/packages/cli/src/capture/assetDownloader.test.ts index 782584f83c..857c898baa 100644 --- a/packages/cli/src/capture/assetDownloader.test.ts +++ b/packages/cli/src/capture/assetDownloader.test.ts @@ -12,6 +12,7 @@ import { import type { DesignTokens } from "./types.js"; import type { IconCandidate } from "./faviconRanker.js"; import { CAPTURE_USER_AGENT } from "./userAgent.js"; +import sharp from "sharp"; describe("isPrivateUrl — SSRF denylist (security: F-003)", () => { it("blocks loopback, private, and metadata IPv4", () => { @@ -199,6 +200,31 @@ function tokensWithNoSvgs(): DesignTokens { return { svgs: [], sections: [], ogImage: "" } as unknown as DesignTokens; } +/** + * openai.com's declared icons, in DOM order, with the attributes the page really carries. + * `rankIconCandidates` puts `favicon.svg` first, so that is the file a capture must land. + */ +const OPENAI_ICONS: IconCandidate[] = [ + { + rel: "icon", + href: "https://openai.example/favicon.svg", + sizes: null, + type: "image/svg+xml", + }, + { + rel: "icon", + href: "https://openai.example/favicon.ico", + sizes: "48x48", + type: "image/x-icon", + }, + { + rel: "apple-touch-icon", + href: "https://openai.example/apple-icon.png", + sizes: "180x180", + type: "image/png", + }, +]; + describe("drop counts — why a referenced asset is not in the capture", () => { afterEach(() => vi.unstubAllGlobals()); @@ -334,31 +360,6 @@ describe("drop counts — why a referenced asset is not in the capture", () => { describe("asset fetches present the same identity as the page navigation", () => { afterEach(() => vi.unstubAllGlobals()); - /** - * openai.com's declared icons, in DOM order, with the attributes the page really carries. - * `rankIconCandidates` puts `favicon.svg` first, so that is the file a capture must land. - */ - const OPENAI_ICONS: IconCandidate[] = [ - { - rel: "icon", - href: "https://openai.example/favicon.svg", - sizes: null, - type: "image/svg+xml", - }, - { - rel: "icon", - href: "https://openai.example/favicon.ico", - sizes: "48x48", - type: "image/x-icon", - }, - { - rel: "apple-touch-icon", - href: "https://openai.example/apple-icon.png", - sizes: "180x180", - type: "image/png", - }, - ]; - /** * Measured against the real origin: `GET /favicon.svg` answers `403 text/html` to * `User-Agent: HyperFrames/1.0` and `200 image/svg+xml` to the browser UA the capture @@ -383,9 +384,9 @@ describe("asset fetches present the same identity as the page navigation", () => // next candidate, so the file on disk is chosen by the CDN rather than by the ranker. await withTempDir(async (dir) => { vi.stubGlobal("fetch", serveLikeAnAntiBotEdge()); - const { assets } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS); - expect(assets.map((a) => a.localPath)).toEqual(["assets/favicon.svg"]); - expect(assets[0]?.url).toBe("https://openai.example/favicon.svg"); + const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS); + expect(icons.headline?.file).toBe("assets/favicon.svg"); + expect(icons.icons[0]?.url).toBe("https://openai.example/favicon.svg"); }); }); @@ -402,3 +403,137 @@ describe("asset fetches present the same identity as the page navigation", () => }); }); }); + +describe("declared icons — keep them all, headline the bare mark", () => { + afterEach(() => vi.unstubAllGlobals()); + + /** A mark knocked out of a full-bleed rounded rect: the shape openai.com's favicon.svg has. */ + const BADGE_SVG = ` + + `; + + /** One shape with transparent margins. */ + const BARE_SVG = ` + + `; + + function solidPng(alpha: number): Promise { + return sharp({ + create: { width: 64, height: 64, channels: 4, background: { r: 20, g: 20, b: 20, alpha } }, + }) + .png() + .toBuffer(); + } + + const CONTENT_TYPE: Record = { + ".svg": "image/svg+xml", + ".png": "image/png", + ".ico": "image/vnd.microsoft.icon", + }; + + /** Serve each URL the bytes the test names for it; 404 anything unexpected. */ + function serve(bodies: Record) { + return vi.fn(async (url: string) => { + const body = Object.entries(bodies).find(([suffix]) => url.endsWith(suffix))?.[1]; + if (body === undefined) return new Response("nope", { status: 404 }); + const ext = url.slice(url.lastIndexOf(".")); + const bytes = typeof body === "string" ? Buffer.from(body) : body; + return new Response(new Uint8Array(bytes), { + status: 200, + headers: { "content-type": CONTENT_TYPE[ext] ?? "application/octet-stream" }, + }); + }); + } + + it("writes one file per declared icon, named for its rel and sizes", async () => { + await withTempDir(async (dir) => { + vi.stubGlobal( + "fetch", + serve({ + "favicon.svg": BADGE_SVG, + "favicon.ico": Buffer.from("not a decodable ico"), + "apple-icon.png": await solidPng(1), + }), + ); + const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS); + expect(icons.icons.map((i) => i.file)).toEqual([ + "assets/icon-icon-unsized.svg", + "assets/icon-apple-touch-icon-180x180.png", + "assets/icon-icon-48x48.ico", + ]); + }); + }); + + it("records openai.com's icons as badges, and says the headline had no bare mark to pick", async () => { + // Measured against the real files: the SVG is a mark knocked out of a full-bleed disc, and + // the apple-touch PNG is an opaque white square, because Apple composites those onto an + // opaque tile. openai.com declares no bare mark at all, so the headline falls back to the + // ranker and the manifest has to say so rather than implying a preference was satisfied. + await withTempDir(async (dir) => { + vi.stubGlobal( + "fetch", + serve({ + "favicon.svg": BADGE_SVG, + "favicon.ico": Buffer.from("not a decodable ico"), + "apple-icon.png": await solidPng(1), + }), + ); + const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], OPENAI_ICONS); + expect(icons.icons.map((i) => i.shape)).toEqual(["badge", "badge", "unknown"]); + expect(icons.headline).toMatchObject({ + file: "assets/favicon.svg", + source: "assets/icon-icon-unsized.svg", + shape: "badge", + }); + expect(icons.headline?.reason).toContain("no bare-mark candidate"); + }); + }); + + it("headlines a lower-ranked bare mark over the better-ranked badge", async () => { + // The ordering rule, on its own. By declared quality the SVG wins outright; by shape the + // PNG does, and shape is what the brand band needs. Under the old rule this lands + // assets/favicon.svg. + await withTempDir(async (dir) => { + vi.stubGlobal( + "fetch", + serve({ "favicon.svg": BADGE_SVG, "apple-icon.png": await solidPng(0) }), + ); + const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], [ + { rel: "icon", href: "https://x.test/favicon.svg", sizes: null, type: "image/svg+xml" }, + { + rel: "apple-touch-icon", + href: "https://x.test/apple-icon.png", + sizes: "180x180", + type: "image/png", + }, + ] as IconCandidate[]); + expect(icons.headline).toMatchObject({ + file: "assets/favicon.png", + source: "assets/icon-apple-touch-icon-180x180.png", + shape: "bare-mark", + rank: 1, + }); + expect(icons.headline?.reason).toContain("bare mark preferred over 1 badge"); + }); + }); + + it("headlines the SVG when it is the bare mark", async () => { + await withTempDir(async (dir) => { + vi.stubGlobal("fetch", serve({ "favicon.svg": BARE_SVG })); + const { icons } = await downloadAssets(tokensWithNoSvgs(), dir, [], [ + { rel: "icon", href: "https://x.test/favicon.svg", sizes: null, type: "image/svg+xml" }, + ] as IconCandidate[]); + expect(icons.headline).toMatchObject({ file: "assets/favicon.svg", shape: "bare-mark" }); + }); + }); + + it("keeps assets/favicon. so a stem match still finds the icon", async () => { + await withTempDir(async (dir) => { + vi.stubGlobal("fetch", serve({ "favicon.svg": BARE_SVG })); + const { assets } = await downloadAssets(tokensWithNoSvgs(), dir, [], [ + { rel: "icon", href: "https://x.test/favicon.svg", sizes: null, type: "image/svg+xml" }, + ] as IconCandidate[]); + expect(assets.map((a) => a.localPath)).toContain("assets/favicon.svg"); + }); + }); +}); diff --git a/packages/cli/src/capture/assetDownloader.ts b/packages/cli/src/capture/assetDownloader.ts index 223d6ab03f..0f827be6d2 100644 --- a/packages/cli/src/capture/assetDownloader.ts +++ b/packages/cli/src/capture/assetDownloader.ts @@ -12,6 +12,7 @@ import type { DesignTokens, DownloadedAsset } from "./types.js"; import type { CatalogedAsset } from "./assetCataloger.js"; import { CAPTURE_USER_AGENT } from "./userAgent.js"; import { rankIconCandidates, type IconCandidate } from "./faviconRanker.js"; +import { classifyIcon, type IconShape } from "./iconClassifier.js"; interface DownloadBudgetOptions { remainingMs?: () => number; @@ -90,6 +91,209 @@ export function toStandaloneSvg(outerHTML: string): string { return outerHTML.replace(original, tag); } +/** One icon the page declared, as downloaded and inspected. */ +export interface IconRecord { + /** Path inside the capture, e.g. `assets/icon-apple-touch-icon-180x180.png`. */ + file: string; + url: string; + rel: string; + sizes: string | null; + type: string | null; + /** Position in the declared-quality ranking; 0 is the best-ranked icon. */ + rank: number; + shape: IconShape; + shapeReason: string; +} + +/** + * Every icon a page declared, plus which one became `assets/favicon.` and why. + * + * The `reason` field is the point of this file. The downloader chooses among candidates, and a + * choice whose losers are invisible is indistinguishable from having had no choice at all — the + * failure mode that let a silent 403 substitute a worse icon without anything recording it. + */ +export interface IconManifest { + schema: "hyperframes.capture.icons.v1"; + headline: { + /** The backwards-compatible stem, e.g. `assets/favicon.png`. */ + file: string; + /** The `icons[].file` it was copied from, so a consumer can avoid showing it twice. */ + source: string; + rank: number; + shape: IconShape; + reason: string; + } | null; + icons: IconRecord[]; +} + +function emptyIconManifest(): IconManifest { + return { schema: "hyperframes.capture.icons.v1", headline: null, icons: [] }; +} + +/** Icons downloaded per capture. Pages declare up to a dozen apple-touch sizes; a few is plenty. */ +const MAX_ICONS = 8; + +/** + * Headline preference, deliberately BINARY: a positively identified bare mark first, then the + * existing declared-quality ranking for everything else. + * + * `unknown` is not promoted above `badge`. Ranking it in between looked reasonable and is wrong: + * a `.ico` cannot be decoded for inspection, so on a site whose icons are all badges the + * undecodable legacy file would outrank the good SVG purely for being unexaminable. Absence of + * evidence is not evidence of a bare mark. + */ +const SHAPE_PREFERENCE: Record = { "bare-mark": 0, unknown: 1, badge: 1 }; + +/** `` -> `icon-apple-touch-icon-180x180`. */ +function iconFileStem(icon: IconCandidate): string { + const slug = `${icon.rel} ${icon.sizes ?? "unsized"}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return `icon-${slug || "unknown"}`; +} + +function headlineReason(chosen: IconRecord, all: IconRecord[]): string { + const badges = all.filter((i) => i.shape === "badge").length; + if (chosen.shape === "bare-mark") { + return badges > 0 + ? `bare mark preferred over ${badges} badge(s)` + : "the only candidate is a bare mark"; + } + const bare = all.filter((i) => i.shape === "bare-mark").length; + if (bare > 0) return `best-ranked bare mark was unavailable; used a ${chosen.shape}`; + return `no bare-mark candidate among ${all.length} icon(s); used the best-ranked ${chosen.shape}`; +} + +/** + * Download every icon the page declared, classify each, and copy the best onto the historical + * `assets/favicon.` stem. + * + * Downloading all of them rather than stopping at the first success is what makes the choice + * possible: shape can only be read from bytes, so a ranking that stops early can never know + * whether the candidate it skipped was the bare mark the brand band actually wants. + */ +/** A stem not yet used in this capture; sites declare the same rel+sizes pair more than once. */ +function uniqueStem(icon: IconCandidate, used: Set): string { + const base = iconFileStem(icon); + let stem = base; + for (let n = 2; used.has(stem); n++) stem = `${base}-${n}`; + used.add(stem); + return stem; +} + +/** Fetch one icon, write it under `stem`, and inspect what it is. Null when it did not arrive. */ +async function fetchAndInspectIcon( + icon: IconCandidate, + rank: number, + stem: string, + outputDir: string, + timeoutMs: number, +): Promise<{ record: IconRecord; buffer: Buffer } | null> { + const ext = extname(new URL(icon.href).pathname) || ".ico"; + const buffer = await fetchBuffer(icon.href, timeoutMs); + if (!buffer) return null; + + const file = `assets/${stem}${ext}`; + writeFileSync(join(outputDir, file), buffer); + const verdict = await classifyIcon(buffer, ext); + return { + buffer, + record: { + file, + url: icon.href, + rel: icon.rel, + sizes: icon.sizes ?? null, + type: icon.type ?? null, + rank, + shape: verdict.shape, + shapeReason: verdict.reason, + }, + }; +} + +/** Copy the winning icon onto the historical `assets/favicon.` stem consumers match on. */ +function promoteHeadline( + manifest: IconManifest, + bytesByFile: Map, + outputDir: string, +): DownloadedAsset | null { + const chosen = [...manifest.icons].sort( + (a, b) => SHAPE_PREFERENCE[a.shape] - SHAPE_PREFERENCE[b.shape] || a.rank - b.rank, + )[0]; + if (!chosen) return null; + + const file = `assets/favicon${extname(chosen.file)}`; + writeFileSync(join(outputDir, file), bytesByFile.get(chosen.file)!); + manifest.headline = { + file, + source: chosen.file, + rank: chosen.rank, + shape: chosen.shape, + reason: headlineReason(chosen, manifest.icons), + }; + return { url: chosen.url, localPath: file, type: "favicon" }; +} + +/** + * Download every icon the page declared, classify each, and promote the best one. + * + * Downloading all of them rather than stopping at the first success is what makes the choice + * possible: shape can only be read from bytes, so a ranking that stops early can never know + * whether the candidate it skipped was the bare mark the brand band actually wants. + */ +async function downloadDeclaredIcons( + faviconLinks: IconCandidate[], + outputDir: string, + drops: AssetDropCounts, + options: DownloadBudgetOptions, +): Promise<{ assets: DownloadedAsset[]; manifest: IconManifest }> { + const ranked = rankIconCandidates(faviconLinks); + const attempted = ranked.slice(0, MAX_ICONS); + drops["cap-reached"] += ranked.length - attempted.length; + + const assets: DownloadedAsset[] = []; + const manifest = emptyIconManifest(); + const bytesByFile = new Map(); + const usedStems = new Set(); + + for (const [rank, icon] of attempted.entries()) { + const remainingMs = options.remainingMs?.() ?? 10_000; + if (remainingMs <= 0) { + drops["budget-exhausted"] += attempted.length - rank; + break; + } + try { + const stem = uniqueStem(icon, usedStems); + const got = await fetchAndInspectIcon( + icon, + rank, + stem, + outputDir, + Math.min(10_000, remainingMs), + ); + if (!got) { + drops.unavailable++; + continue; + } + bytesByFile.set(got.record.file, got.buffer); + manifest.icons.push(got.record); + assets.push({ url: icon.href, localPath: got.record.file, type: "favicon" }); + } catch { + drops.unavailable++; + } + } + + try { + const headline = promoteHeadline(manifest, bytesByFile, outputDir); + if (headline) assets.push(headline); + } catch { + drops.unavailable++; + } + + return { assets, manifest }; +} + // fallow-ignore-next-line complexity export async function downloadAssets( tokens: DesignTokens, @@ -97,13 +301,14 @@ export async function downloadAssets( catalogedAssets?: CatalogedAsset[], faviconLinks?: IconCandidate[], options: DownloadBudgetOptions = {}, -): Promise<{ assets: DownloadedAsset[]; drops: AssetDropCounts }> { +): Promise<{ assets: DownloadedAsset[]; drops: AssetDropCounts; icons: IconManifest }> { const assetsDir = join(outputDir, "assets"); mkdirSync(assetsDir, { recursive: true }); const assets: DownloadedAsset[] = []; const drops = noDrops(); const downloadedUrls = new Set(); + let icons: IconManifest = emptyIconManifest(); mkdirSync(join(outputDir, "assets", "svgs"), { recursive: true }); const usedSvgNames = new Set(); @@ -135,30 +340,10 @@ export async function downloadAssets( } } - // 2. Favicon — best declared candidate first, falling back through the rest on failure. - const icons = rankIconCandidates(faviconLinks || []); - for (const [index, icon] of icons.entries()) { - const remainingMs = options.remainingMs?.() ?? 10_000; - if (remainingMs <= 0) { - drops["budget-exhausted"] += icons.length - index; - break; - } - if (!icon.href) continue; - try { - const ext = extname(new URL(icon.href).pathname) || ".ico"; - const name = `favicon${ext}`; - const localPath = `assets/${name}`; - const buffer = await fetchBuffer(icon.href, Math.min(10_000, remainingMs)); - if (buffer) { - writeFileSync(join(outputDir, localPath), buffer); - assets.push({ url: icon.href, localPath, type: "favicon" }); - break; - } - drops.unavailable++; - } catch { - drops.unavailable++; - } - } + // 2. Icons — keep every one the page declares, then choose the headline among them. + const iconPass = await downloadDeclaredIcons(faviconLinks || [], outputDir, drops, options); + assets.push(...iconPass.assets); + icons = iconPass.manifest; // 3. Images — use the catalog as the single source of truth (highest resolution, deduplicated) // If the catalog is empty, asset download produces zero images — this is surfaced as a warning @@ -299,7 +484,7 @@ export async function downloadAssets( } } - return { assets, drops }; + return { assets, drops, icons }; } /** Normalize URL for deduplication — unwrap Next.js image proxy, strip w/q params */ diff --git a/packages/cli/src/capture/iconClassifier.test.ts b/packages/cli/src/capture/iconClassifier.test.ts new file mode 100644 index 0000000000..21b54515e6 --- /dev/null +++ b/packages/cli/src/capture/iconClassifier.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import sharp from "sharp"; +import { classifyIcon, classifyRasterIcon, classifySvgIcon } from "./iconClassifier.js"; + +/** + * openai.com's `/favicon.svg`, trimmed to the structure that decides the verdict: a full-bleed + * rounded rect, then the mark knocked out of it. Both fills are CSS custom properties behind a + * `prefers-color-scheme` query, which is why this is classified from markup — librvsg resolves + * neither and rasterises the whole file to transparent. + */ +const OPENAI_FAVICON_SVG = ` + + + + + + +`; + +const BARE_MARK_SVG = ` + +`; + +/** A square canvas that is uniformly transparent or uniformly opaque. */ +function solidPng(alpha: number): Promise { + return sharp({ + create: { width: 64, height: 64, channels: 4, background: { r: 20, g: 20, b: 20, alpha } }, + }) + .png() + .toBuffer(); +} + +/** An opaque disc that touches all four edges on an otherwise transparent canvas. */ +function discPng(): Promise { + const mask = Buffer.from( + ``, + ); + return sharp({ + create: { width: 64, height: 64, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .composite([{ input: mask }]) + .png() + .toBuffer(); +} + +describe("classifySvgIcon", () => { + it("calls a mark knocked out of a full-bleed rounded rect a badge", () => { + const verdict = classifySvgIcon(OPENAI_FAVICON_SVG); + expect(verdict.shape).toBe("badge"); + expect(verdict.reason).toContain("full-bleed"); + }); + + it("calls a single shape with margins a bare mark", () => { + expect(classifySvgIcon(BARE_MARK_SVG).shape).toBe("bare-mark"); + }); + + it("does not mistake a clipPath's own rect for the backdrop", () => { + // The `` in the openai file contains a full-bleed ``; reading elements in + // document order without skipping non-painting containers would judge that one. + const clipOnly = ` + + + `; + expect(classifySvgIcon(clipOnly).shape).toBe("bare-mark"); + }); + + it("treats a full-bleed circle as a badge", () => { + const disc = ` + + `; + expect(classifySvgIcon(disc).shape).toBe("badge"); + }); + + it("reports unknown rather than guessing when there is nothing to measure against", () => { + const noCanvas = ``; + expect(classifySvgIcon(noCanvas).shape).toBe("unknown"); + }); +}); + +describe("classifyRasterIcon", () => { + it("calls an opaque square a badge", async () => { + expect((await classifyRasterIcon(await solidPng(1))).shape).toBe("badge"); + }); + + it("calls a transparent canvas a bare mark", async () => { + expect((await classifyRasterIcon(await solidPng(0))).shape).toBe("bare-mark"); + }); + + it("calls a full-bleed disc a badge, which sampling only the corners would miss", async () => { + // All four corners of an inscribed disc are transparent. The edge midpoints are not. + expect((await classifyRasterIcon(await discPng())).shape).toBe("badge"); + }); + + it("reports unknown for a format it cannot decode rather than throwing", async () => { + // .ico is the real case: libvips ships no decoder for it. + const verdict = await classifyRasterIcon(Buffer.from("not an image")); + expect(verdict.shape).toBe("unknown"); + expect(verdict.reason).toContain("could not be decoded"); + }); +}); + +describe("classifyIcon", () => { + it("routes .svg to markup inspection and everything else to pixels", async () => { + expect((await classifyIcon(Buffer.from(BARE_MARK_SVG), ".svg")).shape).toBe("bare-mark"); + expect((await classifyIcon(await solidPng(1), ".png")).shape).toBe("badge"); + }); +}); diff --git a/packages/cli/src/capture/iconClassifier.ts b/packages/cli/src/capture/iconClassifier.ts new file mode 100644 index 0000000000..92ff5a7f7d --- /dev/null +++ b/packages/cli/src/capture/iconClassifier.ts @@ -0,0 +1,189 @@ +/** + * 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 sharp from "sharp"; +import { parseHTML } from "linkedom"; + +/** 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 root found" }; + + const canvas = canvasOf(svg); + if (!canvas) return { shape: "unknown", reason: "no viewBox or width/height to measure against" }; + + const first = firstPaintedElement(svg); + if (!first) return { shape: "unknown", reason: "no painted element found" }; + + const tag = first.tagName.toLowerCase(); + + if (tag === "rect") { + const w = num(first, "width"); + const h = num(first, "height"); + if ( + w !== null && + h !== null && + w >= canvas.w * FULL_BLEED_RATIO && + h >= canvas.h * FULL_BLEED_RATIO + ) { + const rounded = num(first, "rx"); + return { + shape: "badge", + reason: `first painted element is a full-bleed ${rounded ? "rounded " : ""}rect (${w}x${h} of ${canvas.w}x${canvas.h})`, + }; + } + } + + if (tag === "circle") { + const r = num(first, "r"); + if (r !== null && r * 2 >= Math.min(canvas.w, canvas.h) * FULL_BLEED_RATIO) { + return { + shape: "badge", + reason: `first painted element is a full-bleed circle (r=${r} of ${canvas.w}x${canvas.h})`, + }; + } + } + + return { shape: "bare-mark", reason: `first painted element is <${tag}> with margins` }; +} + +/** Alpha at or above this counts as opaque. */ +const OPAQUE_ALPHA = 250; +/** How many of the four edge midpoints must be opaque before the drawing carries a backdrop. */ +const BACKDROP_EDGE_HITS = 3; +/** Sampling grid. Small on purpose — this is a shape question, not an image-quality one. */ +const SAMPLE_EDGE = 32; + +/** + * Classify a raster icon by sampling the MIDPOINT OF EACH EDGE. + * + * Corners alone are not enough, and getting that wrong is the whole trap: a disc badge on a + * transparent canvas has four transparent corners and would read as a bare mark. Its edge + * midpoints are opaque, because a full-bleed disc touches the middle of every side, and so are + * a square badge's. A mark with margins has neither. + * + * ponytail: a bare mark drawn hard to the edges would read as a badge. Accepted — icon artwork + * effectively always carries padding, and the fix would be a real alpha bounding box. + */ +export async function classifyRasterIcon(buffer: Buffer): Promise { + let data: Buffer; + let channels: number; + try { + const raw = await sharp(buffer) + .resize(SAMPLE_EDGE, SAMPLE_EDGE, { fit: "fill" }) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + data = raw.data; + channels = raw.info.channels; + } catch { + // .ico is the common case here: libvips ships no decoder for it. + return { shape: "unknown", reason: "image format could not be decoded for inspection" }; + } + + const last = SAMPLE_EDGE - 1; + const mid = SAMPLE_EDGE >> 1; + const alphaAt = (x: number, y: number): number => data[(y * SAMPLE_EDGE + x) * channels + 3] ?? 0; + const edgeMidpoints = [alphaAt(mid, 0), alphaAt(mid, last), alphaAt(0, mid), alphaAt(last, mid)]; + const opaqueEdges = edgeMidpoints.filter((a) => a >= OPAQUE_ALPHA).length; + + return opaqueEdges >= BACKDROP_EDGE_HITS + ? { + shape: "badge", + reason: `${opaqueEdges} of 4 edge midpoints are opaque, so the drawing carries a backdrop`, + } + : { + shape: "bare-mark", + reason: `${opaqueEdges} of 4 edge midpoints are opaque, so the drawing has transparent margins`, + }; +} + +/** Classify a downloaded icon by its bytes. `ext` selects the inspection, e.g. ".svg". */ +export async function classifyIcon(buffer: Buffer, ext: string): Promise { + return ext.toLowerCase() === ".svg" + ? classifySvgIcon(buffer.toString("utf-8")) + : classifyRasterIcon(buffer); +} diff --git a/packages/cli/src/capture/index.ts b/packages/cli/src/capture/index.ts index 4aed54614a..848d8776a5 100644 --- a/packages/cli/src/capture/index.ts +++ b/packages/cli/src/capture/index.ts @@ -684,6 +684,14 @@ export async function captureWebsite( }); assets = assetPass.assets; assetDrops = assetPass.drops; + // Which icons the site declared, what each one is, and why one became favicon.. + // The brand-kit consumer needs the shape to decide which tile an icon belongs in; the + // reason is what stops a substituted headline from being silent again. + writeFileSync( + join(outputDir, "extracted", "icons-manifest.json"), + JSON.stringify(assetPass.icons, null, 2), + "utf-8", + ); phase( "assets", remainingMs() > 0 ? "completed" : "degraded",