feat(cli): keep every icon a site declares and headline the bare mark (#3727)

A brand band shows logo artwork on a transparency checker, where a badge (a
mark knocked out of a full-bleed disc or square) reads as a solid blob. The
capture kept only the first icon that downloaded, so which of a site's icons
survived was decided by ranking alone, and nothing recorded what the survivor
actually looked like.

Keep them all. Each declared icon is written as assets/icon-<rel>-<sizes>.<ext>
and classified as bare-mark, badge, or unknown, then the best one is copied onto
the historical assets/favicon.<ext> stem so a stem match still finds it.

Classification is deterministic and cheap. SVG is read from markup, not
rendered: these files routinely fill via CSS custom properties behind a
prefers-color-scheme query, and librsvg resolves neither, rasterising one real
favicon to a fully transparent image while Chrome draws it correctly. Raster
icons are sampled at the midpoint of each edge rather than the corners, because
a full-bleed disc has four transparent corners and would otherwise read as a
bare mark.

The headline prefers a bare mark, then falls back to the existing ranking.
Preference is binary on purpose: an "unknown" is not promoted above a badge,
or an undecodable .ico would outrank a good SVG purely for being unexaminable.

extracted/icons-manifest.json records every icon, its shape and the evidence
for that shape, plus which file became the headline, its rank, and why. A
choice whose losers are invisible is indistinguishable from having had no
choice, which is how a substituted icon went unnoticed in the first place.

Worth noting for consumers: sites often declare no bare mark at all. An
apple-touch-icon is composited onto an opaque tile per Apple's spec, so it is
a badge, not a mark. The manifest says so rather than implying the preference
was satisfied.
This commit is contained in:
Miguel Ángel
2026-09-05 22:35:45 -04:00
committed by GitHub
parent 97fde27df7
commit 1fa31d5dfb
5 changed files with 678 additions and 54 deletions
+163 -28
View File
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">
<rect width="180" height="180" rx="90" fill="#000" /><path d="M60 60h60v60H60z" fill="#fff" />
</svg>`;
/** One shape with transparent margins. */
const BARE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">
<path d="M40 40h100v100H40z" fill="#000" />
</svg>`;
function solidPng(alpha: number): Promise<Buffer> {
return sharp({
create: { width: 64, height: 64, channels: 4, background: { r: 20, g: 20, b: 20, alpha } },
})
.png()
.toBuffer();
}
const CONTENT_TYPE: Record<string, string> = {
".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<string, Buffer | string>) {
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.<ext> 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");
});
});
});
+211 -26
View File
@@ -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.<ext>` 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<IconShape, number> = { "bare-mark": 0, unknown: 1, badge: 1 };
/** `<link rel="apple-touch-icon" sizes="180x180">` -> `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.<ext>` 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>): 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.<ext>` stem consumers match on. */
function promoteHeadline(
manifest: IconManifest,
bytesByFile: Map<string, Buffer>,
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<string, Buffer>();
const usedStems = new Set<string>();
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<string>();
let icons: IconManifest = emptyIconManifest();
mkdirSync(join(outputDir, "assets", "svgs"), { recursive: true });
const usedSvgNames = new Set<string>();
@@ -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 */
@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" fill="none">
<style>:root { --primary-fill: #fff; --secondary-fill: #000; }</style>
<g clip-path="url(#a)">
<rect width="180" height="180" fill="var(--primary-fill)" rx="90" />
<g clip-path="url(#b)"><path fill="var(--secondary-fill)" d="M75.91 73.6V62.2l22.9-13.2Z" /></g>
</g>
<defs><clipPath id="a"><path d="M0 0h180v180H0z" /></clipPath></defs>
</svg>`;
const BARE_MARK_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">
<path fill="#000" d="M40 40h100v100H40z" />
</svg>`;
/** A square canvas that is uniformly transparent or uniformly opaque. */
function solidPng(alpha: number): Promise<Buffer> {
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<Buffer> {
const mask = Buffer.from(
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><circle cx="32" cy="32" r="32" fill="#000"/></svg>`,
);
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 `<defs>` in the openai file contains a full-bleed `<path>`; reading elements in
// document order without skipping non-painting containers would judge that one.
const clipOnly = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180">
<defs><clipPath id="a"><rect width="180" height="180" /></clipPath></defs>
<path fill="#000" d="M40 40h100v100H40z" />
</svg>`;
expect(classifySvgIcon(clipOnly).shape).toBe("bare-mark");
});
it("treats a full-bleed circle as a badge", () => {
const disc = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="50" fill="#000" /><path d="M30 30h40v40H30z" fill="#fff" />
</svg>`;
expect(classifySvgIcon(disc).shape).toBe("badge");
});
it("reports unknown rather than guessing when there is nothing to measure against", () => {
const noCanvas = `<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0h10v10H0z" /></svg>`;
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");
});
});
+189
View File
@@ -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(`<!doctype html><body>${source}</body>`);
const svg = document.querySelector("svg");
if (!svg) return { shape: "unknown", reason: "no <svg> 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<IconVerdict> {
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<IconVerdict> {
return ext.toLowerCase() === ".svg"
? classifySvgIcon(buffer.toString("utf-8"))
: classifyRasterIcon(buffer);
}
+8
View File
@@ -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.<ext>.
// 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",