mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
Merge pull request #1827 from heygen-com/feat/capture-component-extraction
feat(capture): extract chips/stat-cells/tabs, detect icon fonts, transparent grounds
This commit is contained in:
@@ -21,8 +21,11 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
function rgbToHex(color) {
|
||||
if (!color) return "";
|
||||
if (color.startsWith('#')) return color.toUpperCase();
|
||||
var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)/);
|
||||
var m = color.match(/rgba?\\(\\s*(\\d+)\\s*,\\s*(\\d+)\\s*,\\s*(\\d+)\\s*(?:,\\s*([\\d.]+))?/);
|
||||
if (!m) return color;
|
||||
// fully-transparent fill (rgba(...,0)) → sentinel, NOT #000000 — otherwise a transparent
|
||||
// chip/tab/stat ground reads as solid black on a light-ground site.
|
||||
if (m[4] !== undefined && parseFloat(m[4]) === 0) return "transparent";
|
||||
return '#' + ((1<<24) + (parseInt(m[1])<<16) + (parseInt(m[2])<<8) + parseInt(m[3])).toString(16).slice(1).toUpperCase();
|
||||
}
|
||||
|
||||
@@ -188,6 +191,100 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
};
|
||||
}
|
||||
|
||||
// ── 4b. Chips / pills / badges / tags ──
|
||||
// selector by class-substring, PLUS a shape fallback (small + fully-rounded + short text) so
|
||||
// hashed/utility class names (Tailwind, CSS-modules) still get caught.
|
||||
var chipEls = Array.from(document.querySelectorAll(
|
||||
'[class*="pill"], [class*="Pill"], [class*="badge"], [class*="Badge"], ' +
|
||||
'[class*="chip"], [class*="Chip"], [class*="tag"], [class*="Tag"]'
|
||||
)).filter(function(el) {
|
||||
if (!isVisible(el) || el.closest('nav, [role="navigation"]')) return false;
|
||||
var r = el.getBoundingClientRect();
|
||||
var txt = (el.textContent || "").trim();
|
||||
return r.height > 0 && r.height <= 60 && r.width <= 360 && txt.length > 0 && txt.length <= 40;
|
||||
});
|
||||
var shapeChips = Array.from(document.querySelectorAll('span, div, li, a')).slice(0, 500).filter(function(el) {
|
||||
if (!isVisible(el) || el.closest('nav, [role="navigation"]')) return false;
|
||||
var st = getComputedStyle(el);
|
||||
var r = el.getBoundingClientRect();
|
||||
var rad = parseFloat(st.borderRadius) || 0;
|
||||
var txt = (el.textContent || "").trim();
|
||||
var hasSkin = (st.backgroundColor && st.backgroundColor !== "rgba(0, 0, 0, 0)" && st.backgroundColor !== "transparent") || (parseFloat(st.borderTopWidth) || 0) > 0;
|
||||
return hasSkin && r.height > 0 && r.height <= 44 && r.width <= 260 && rad >= (r.height / 2) - 1 && txt.length > 0 && txt.length <= 24 && el.children.length <= 1;
|
||||
});
|
||||
var allChips = chipEls.concat(shapeChips);
|
||||
var chipMap = {};
|
||||
for (var chi = 0; chi < allChips.length; chi++) {
|
||||
var chs = getStyles(allChips[chi]);
|
||||
var chKey = chs.background + "|" + chs.borderRadius + "|" + chs.border;
|
||||
if (!chipMap[chKey]) {
|
||||
chipMap[chKey] = {
|
||||
label: (allChips[chi].textContent || "").trim().slice(0, 24) || "chip",
|
||||
background: chs.background, color: chs.color, padding: chs.padding, borderRadius: chs.borderRadius,
|
||||
border: chs.border, boxShadow: chs.boxShadow, fontSize: chs.fontSize, fontWeight: chs.fontWeight, height: chs.height
|
||||
};
|
||||
}
|
||||
}
|
||||
var chips = Object.values(chipMap).slice(0, 4);
|
||||
|
||||
// ── 4c. Stat / metric cells (a big numeral + a small label) ──
|
||||
var statEls = Array.from(document.querySelectorAll(
|
||||
'[class*="stat"], [class*="Stat"], [class*="metric"], [class*="Metric"], ' +
|
||||
'[class*="kpi"], [class*="KPI"], [class*="figure"], [class*="Figure"]'
|
||||
)).filter(function(el) {
|
||||
if (!isVisible(el)) return false;
|
||||
var r = el.getBoundingClientRect();
|
||||
return r.height > 0 && r.height <= 400 && r.width <= 600;
|
||||
}).slice(0, 14);
|
||||
function biggestFontChild(el) {
|
||||
var best = 0, bestEl = null, kids = el.querySelectorAll("*");
|
||||
for (var i = 0; i < kids.length; i++) {
|
||||
if (!isVisible(kids[i])) continue;
|
||||
var fz = parseFloat(getComputedStyle(kids[i]).fontSize) || 0;
|
||||
if (fz > best) { best = fz; bestEl = kids[i]; }
|
||||
}
|
||||
return bestEl;
|
||||
}
|
||||
var statMap = {};
|
||||
for (var sti = 0; sti < statEls.length; sti++) {
|
||||
var numEl = biggestFontChild(statEls[sti]) || statEls[sti];
|
||||
var numFz = parseFloat(getComputedStyle(numEl).fontSize) || 0;
|
||||
if (numFz < 28) continue; // needs a genuinely large numeral to count as a stat cell
|
||||
var cst = getStyles(statEls[sti]);
|
||||
var nst = getStyles(numEl);
|
||||
var stKey = Math.round(numFz) + "|" + cst.background;
|
||||
if (!statMap[stKey]) {
|
||||
statMap[stKey] = {
|
||||
background: cst.background, borderRadius: cst.borderRadius, border: cst.border, boxShadow: cst.boxShadow,
|
||||
numberFontSize: nst.fontSize, numberFontWeight: nst.fontWeight, numberColor: nst.color
|
||||
};
|
||||
}
|
||||
}
|
||||
var statCells = Object.values(statMap).slice(0, 3);
|
||||
|
||||
// ── 4d. Tabs ──
|
||||
var tabEls = Array.from(document.querySelectorAll(
|
||||
'[role="tab"], [class*="tab"]:not([class*="table"]):not([class*="Table"])'
|
||||
)).filter(function(el) {
|
||||
if (!isVisible(el)) return false;
|
||||
var r = el.getBoundingClientRect();
|
||||
var txt = (el.textContent || "").trim();
|
||||
return r.height > 0 && r.height <= 64 && txt.length > 0 && txt.length <= 30;
|
||||
}).slice(0, 12);
|
||||
var tabMap = {};
|
||||
for (var tbi = 0; tbi < tabEls.length; tbi++) {
|
||||
var tst = getStyles(tabEls[tbi]);
|
||||
var tKey = tst.background + "|" + tst.color + "|" + tst.border;
|
||||
if (!tabMap[tKey]) {
|
||||
tabMap[tKey] = {
|
||||
label: (tabEls[tbi].textContent || "").trim().slice(0, 24) || "tab",
|
||||
background: tst.background, color: tst.color, padding: tst.padding, borderRadius: tst.borderRadius,
|
||||
border: tst.border, boxShadow: tst.boxShadow, fontSize: tst.fontSize, fontWeight: tst.fontWeight, height: tst.height
|
||||
};
|
||||
}
|
||||
}
|
||||
var tabs = Object.values(tabMap).slice(0, 4);
|
||||
|
||||
// ── 5. Spacing scale ──
|
||||
// Collect padding and margin values from visible elements
|
||||
var spacingCounts = {};
|
||||
@@ -273,7 +370,10 @@ const EXTRACT_DESIGN_STYLES_SCRIPT = `(() => {
|
||||
shadows: shadows,
|
||||
buttons: buttons,
|
||||
cards: cards,
|
||||
nav: nav
|
||||
nav: nav,
|
||||
chips: chips,
|
||||
statCells: statCells,
|
||||
tabs: tabs
|
||||
};
|
||||
})()`;
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
canonicalizeFamily,
|
||||
extractFontMetadata,
|
||||
inferWeightFromSubfamily,
|
||||
isIconCharacterSet,
|
||||
} from "./fontMetadataExtractor.js";
|
||||
|
||||
describe("inferWeightFromSubfamily", () => {
|
||||
@@ -174,3 +175,41 @@ describe("extractFontMetadata", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isIconCharacterSet", () => {
|
||||
// codepoint helpers
|
||||
const latin = Array.from({ length: 26 }, (_, i) => 0x41 + i); // A-Z
|
||||
const pua = Array.from({ length: 20 }, (_, i) => 0xe000 + i); // Private Use Area
|
||||
const puaSupp = [0xf0000, 0xf0001, 0x10fffd]; // supplementary PUA-A/B
|
||||
|
||||
it("flags a font whose glyphs are mostly Private-Use-Area (an icon font)", () => {
|
||||
// ~63% PUA, mirroring a real "hushly" icon set
|
||||
expect(isIconCharacterSet([...pua, ...latin.slice(0, 12)])).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a near-100% PUA set (Font-Awesome-style)", () => {
|
||||
expect(isIconCharacterSet([...pua, ...puaSupp])).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT flag a normal Latin text font", () => {
|
||||
expect(isIconCharacterSet(latin)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT flag a unicode-range subset with no Latin letters but 0% PUA", () => {
|
||||
// e.g. a cyrillic/latin-ext subset served by Next.js/Google Fonts — the false-positive case
|
||||
const cyrillic = Array.from({ length: 40 }, (_, i) => 0x0410 + i);
|
||||
expect(isIconCharacterSet(cyrillic)).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT flag a PUA-heavy TEXT font that still has a full Latin alphabet", () => {
|
||||
// Apple SF Pro (~81% PUA, ships SF Symbols in the PUA) and Descript's Booton (~50% PUA) are
|
||||
// full text fonts — the Latin alphabet gate must keep them out of the icon bucket.
|
||||
const fullAlphabet = latin; // A-Z (26)
|
||||
const sfProLike = [...fullAlphabet, ...Array.from({ length: 200 }, (_, i) => 0xe000 + i)];
|
||||
expect(isIconCharacterSet(sfProLike)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty character set", () => {
|
||||
expect(isIconCharacterSet([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,12 @@ export interface FontFileMetadata {
|
||||
variationAxes: string[];
|
||||
/** Whether identification came from the binary name table (the trustworthy source). */
|
||||
identified: boolean;
|
||||
/**
|
||||
* True when this is an ICON font — it has no basic Latin letters, or its glyphs live mostly in
|
||||
* the Unicode Private Use Area (Font Awesome, swiper-icons, a custom "hushly" icon set, …).
|
||||
* Consumers must NOT treat it as a text family: binding it to one renders headings as tofu/icons.
|
||||
*/
|
||||
isIcon: boolean;
|
||||
}
|
||||
|
||||
export interface FontFamilySummary {
|
||||
@@ -149,6 +155,7 @@ function readSingleFont(fullPath: string, filename: string): FontFileMetadata {
|
||||
style: "normal",
|
||||
variationAxes: [],
|
||||
identified: false,
|
||||
isIcon: false,
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -185,12 +192,45 @@ function readSingleFont(fullPath: string, filename: string): FontFileMetadata {
|
||||
style,
|
||||
variationAxes,
|
||||
identified: true,
|
||||
isIcon: detectIconFont(font),
|
||||
};
|
||||
} catch {
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect an ICON font by glyph coverage rather than by name (icon fonts often have arbitrary
|
||||
* names like "hushly" or "swiper-icons" that no name-list can enumerate). See isIconCharacterSet
|
||||
* for the rule (lacks a Latin alphabet AND mostly Private-Use-Area glyphs). `characterSet` is a
|
||||
* fontkit runtime member not always in its typings, so it's read through a narrow local shape.
|
||||
*/
|
||||
function detectIconFont(font: Font): boolean {
|
||||
const f = font as unknown as { characterSet?: number[] };
|
||||
try {
|
||||
return isIconCharacterSet(Array.isArray(f.characterSet) ? f.characterSet : []);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect an icon font from glyph coverage. Two conditions must BOTH hold:
|
||||
* 1. it lacks a real Latin alphabet (< 26 of A-Za-z) — a text font ships the full alphabet;
|
||||
* 2. most of its glyphs (> 50%) live in a Unicode Private Use Area.
|
||||
* The Latin gate is essential: some text fonts pack thousands of PUA glyphs yet are plainly text —
|
||||
* Apple's SF Pro (81% PUA, full A-Za-z, ships SF Symbols in the PUA), Descript's Booton (50% PUA,
|
||||
* full A-Za-z). Flagging those by PUA ratio alone strips a brand's real typeface. Measured icon
|
||||
* fonts: "hushly" 63% PUA / 7 letters, Font Awesome 95% PUA / 0 letters. Exported for testing.
|
||||
*/
|
||||
export function isIconCharacterSet(characterSet: number[]): boolean {
|
||||
if (!characterSet.length) return false;
|
||||
const isLatinLetter = (cp: number) => (cp >= 0x41 && cp <= 0x5a) || (cp >= 0x61 && cp <= 0x7a);
|
||||
if (characterSet.filter(isLatinLetter).length >= 26) return false; // has an alphabet → a text font
|
||||
const inPua = (cp: number) => (cp >= 0xe000 && cp <= 0xf8ff) || (cp >= 0xf0000 && cp <= 0x10fffd);
|
||||
return characterSet.filter(inPua).length / characterSet.length > 0.5;
|
||||
}
|
||||
|
||||
/** Aggregate per-file entries into per-family summaries — most useful shape for DESIGN.md. */
|
||||
// fallow-ignore-next-line complexity
|
||||
function aggregateFamilies(files: FontFileMetadata[]): FontFamilySummary[] {
|
||||
|
||||
@@ -189,6 +189,17 @@ export interface ComponentStyle {
|
||||
height: string;
|
||||
}
|
||||
|
||||
export interface StatCellStyle {
|
||||
background: string;
|
||||
borderRadius: string;
|
||||
border: string;
|
||||
boxShadow: string;
|
||||
/** the large numeral's type */
|
||||
numberFontSize: string;
|
||||
numberFontWeight: string;
|
||||
numberColor: string;
|
||||
}
|
||||
|
||||
export interface DesignStyles {
|
||||
typography: TypographyRole[];
|
||||
spacing: {
|
||||
@@ -200,6 +211,12 @@ export interface DesignStyles {
|
||||
buttons: ComponentStyle[];
|
||||
cards: ComponentStyle[];
|
||||
nav: ComponentStyle | null;
|
||||
/** pill / badge / chip / tag — small rounded labelled elements */
|
||||
chips?: ComponentStyle[];
|
||||
/** metric / KPI cells (a large numeral + label) */
|
||||
statCells?: StatCellStyle[];
|
||||
/** tab controls */
|
||||
tabs?: ComponentStyle[];
|
||||
}
|
||||
|
||||
// ── Assets ──────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user