@hypersocial/decks-cli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +45 -7
- package/skills/hyperdecks/chat.md +17 -5
- package/src/brandCli.mjs +41 -2
- package/src/brandImport.mjs +981 -0
- package/src/brandPreview.mjs +24 -0
- package/src/checkRules.mjs +36 -8
- package/src/commands.mjs +27 -8
- package/src/importer.mjs +1 -1
- package/src/ops.mjs +33 -12
- package/src/runtime.mjs +5 -2
|
@@ -0,0 +1,981 @@
|
|
|
1
|
+
// Import a brand from what people already have (roadmap: import-a-brand-from-a-
|
|
2
|
+
// website-a-deck-or-a-file): a website, a PowerPoint theme (.pptx / .potx), a
|
|
3
|
+
// PDF or one of their Hyperdecks decks. Each extractor reads the source's real
|
|
4
|
+
// tokens and returns a draft:
|
|
5
|
+
//
|
|
6
|
+
// { name?, colors: { ink, paper, accent, accent2, card }, fonts: { display,
|
|
7
|
+
// body, mono }, logo?: { url } | { bytes, type }, notes: [string], source }
|
|
8
|
+
//
|
|
9
|
+
// Fonts are the families the source names; resolveFonts maps the ones Google
|
|
10
|
+
// Fonts does not serve to the closest curated pairing (brand.mjs) and says so.
|
|
11
|
+
// Nothing here does I/O: the caller fetches (Convex's SSRF-guarded fetch for a
|
|
12
|
+
// website, the chat upload, a local file) and the draft is saved only through
|
|
13
|
+
// set_brand { draft } after the person has seen the preview. No Node built-ins,
|
|
14
|
+
// so the dashboard parses an uploaded file in the browser with the same code.
|
|
15
|
+
import { Inflate, Unzlib, strFromU8, unzipSync } from "fflate";
|
|
16
|
+
import { FONT_PAIRINGS } from "./brand.mjs";
|
|
17
|
+
|
|
18
|
+
export const SOURCE_KINDS = ["url", "pptx", "pdf", "deck"];
|
|
19
|
+
export const MAX_IMPORT_FILE_BYTES = 25 * 1024 * 1024;
|
|
20
|
+
export const FILE_TYPES = {
|
|
21
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
22
|
+
potx: "application/vnd.openxmlformats-officedocument.presentationml.template",
|
|
23
|
+
pdf: "application/pdf",
|
|
24
|
+
};
|
|
25
|
+
// The logo types brandNode stores (convex/brandNode.ts LOGO_TYPES).
|
|
26
|
+
const LOGO_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml", avif: "image/avif" };
|
|
27
|
+
const MAX_LOGO_BYTES = 2 * 1024 * 1024;
|
|
28
|
+
// A theme, master or layout XML is a few hundred KB; past this a file is a zip bomb, not a deck.
|
|
29
|
+
const MAX_PART_BYTES = 8 * 1024 * 1024;
|
|
30
|
+
// All of them together, and how many: a deck has one theme, a master or two and a dozen layouts.
|
|
31
|
+
const MAX_PARTS_BYTES = 16 * 1024 * 1024;
|
|
32
|
+
const MAX_PARTS = 300;
|
|
33
|
+
// Everything inflated from one PDF, all streams together.
|
|
34
|
+
const MAX_PDF_INFLATE = 12 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
// ---- colours ---------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const clamp01 = (n) => Math.min(1, Math.max(0, n));
|
|
39
|
+
const NAMED = { white: [255, 255, 255], black: [0, 0, 0] };
|
|
40
|
+
|
|
41
|
+
// A CSS colour as { r, g, b, a } (0-255, alpha 0-1), or null. Hex, rgb(),
|
|
42
|
+
// hsl(), oklch(), oklab(), white and black; anything else (currentColor,
|
|
43
|
+
// gradients, system colours) is not a brand token.
|
|
44
|
+
export function parseColor(raw) {
|
|
45
|
+
const s = String(raw ?? "").trim().toLowerCase();
|
|
46
|
+
if (!s) return null;
|
|
47
|
+
if (NAMED[s]) return { r: NAMED[s][0], g: NAMED[s][1], b: NAMED[s][2], a: 1 };
|
|
48
|
+
let m = /^#([0-9a-f]{3,8})$/.exec(s);
|
|
49
|
+
if (m) {
|
|
50
|
+
let h = m[1];
|
|
51
|
+
if (h.length === 3 || h.length === 4) h = [...h].map((c) => c + c).join("");
|
|
52
|
+
if (h.length !== 6 && h.length !== 8) return null;
|
|
53
|
+
return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1 };
|
|
54
|
+
}
|
|
55
|
+
m = /^(rgba?|hsla?|oklch|oklab)\(\s*([^)]*)\)$/.exec(s);
|
|
56
|
+
if (!m) return null;
|
|
57
|
+
const parts = m[2].split(/[\s,/]+/).filter(Boolean);
|
|
58
|
+
if (parts.length < 3) return null;
|
|
59
|
+
const num = (p, scale = 1) => (p.endsWith("%") ? (parseFloat(p) / 100) * scale : parseFloat(p));
|
|
60
|
+
const alpha = parts[3] !== undefined ? clamp01(num(parts[3], 1)) : 1;
|
|
61
|
+
if (parts.slice(0, 3).some((p) => p === "none" || Number.isNaN(parseFloat(p)))) return null;
|
|
62
|
+
if (m[1].startsWith("rgb")) return { r: Math.round(clamp01(num(parts[0], 255) / 255) * 255), g: Math.round(clamp01(num(parts[1], 255) / 255) * 255), b: Math.round(clamp01(num(parts[2], 255) / 255) * 255), a: alpha };
|
|
63
|
+
if (m[1].startsWith("hsl")) return { ...hslToRgb(parseFloat(parts[0]), num(parts[1], 1) * (parts[1].endsWith("%") ? 1 : 0.01), num(parts[2], 1) * (parts[2].endsWith("%") ? 1 : 0.01)), a: alpha };
|
|
64
|
+
const L = parts[0].endsWith("%") ? parseFloat(parts[0]) / 100 : parseFloat(parts[0]);
|
|
65
|
+
if (m[1] === "oklab") return { ...oklabToRgb(L, num(parts[1], 0.4), num(parts[2], 0.4)), a: alpha };
|
|
66
|
+
const C = num(parts[1], 0.4);
|
|
67
|
+
const H = (parseFloat(parts[2]) * Math.PI) / 180;
|
|
68
|
+
return { ...oklabToRgb(L, C * Math.cos(H), C * Math.sin(H)), a: alpha };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hslToRgb(h, s, l) {
|
|
72
|
+
h = (((h % 360) + 360) % 360) / 360;
|
|
73
|
+
s = clamp01(s);
|
|
74
|
+
l = clamp01(l);
|
|
75
|
+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|
76
|
+
const p = 2 * l - q;
|
|
77
|
+
const ch = (t) => {
|
|
78
|
+
t = (t + 1) % 1;
|
|
79
|
+
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
|
80
|
+
if (t < 1 / 2) return q;
|
|
81
|
+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
|
82
|
+
return p;
|
|
83
|
+
};
|
|
84
|
+
return { r: Math.round(ch(h + 1 / 3) * 255), g: Math.round(ch(h) * 255), b: Math.round(ch(h - 1 / 3) * 255) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function oklabToRgb(L, a, b) {
|
|
88
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
89
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
90
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
91
|
+
const lin = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
|
|
92
|
+
const [r, g, bl] = lin.map((c) => Math.round(clamp01(c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055) * 255));
|
|
93
|
+
return { r, g, b: bl };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const toHex = ({ r, g, b }) => `#${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`;
|
|
97
|
+
|
|
98
|
+
// HSL lightness and saturation (0-1) and hue (degrees) of a parsed colour.
|
|
99
|
+
function hsl({ r, g, b }) {
|
|
100
|
+
const [R, G, B] = [r / 255, g / 255, b / 255];
|
|
101
|
+
const max = Math.max(R, G, B);
|
|
102
|
+
const min = Math.min(R, G, B);
|
|
103
|
+
const l = (max + min) / 2;
|
|
104
|
+
const d = max - min;
|
|
105
|
+
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
|
106
|
+
let h = 0;
|
|
107
|
+
if (d) h = max === R ? ((G - B) / d) % 6 : max === G ? (B - R) / d + 2 : (R - G) / d + 4;
|
|
108
|
+
return { h: (h * 60 + 360) % 360, s, l };
|
|
109
|
+
}
|
|
110
|
+
const isAccentLike = (c) => {
|
|
111
|
+
const { s, l } = hsl(c);
|
|
112
|
+
return s >= 0.3 && l >= 0.18 && l <= 0.85;
|
|
113
|
+
};
|
|
114
|
+
const hueGap = (a, b) => {
|
|
115
|
+
const d = Math.abs(hsl(a).h - hsl(b).h) % 360;
|
|
116
|
+
return Math.min(d, 360 - d);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// A tally of colours by hex, each with a weight.
|
|
120
|
+
class Tally {
|
|
121
|
+
constructor() {
|
|
122
|
+
this.map = new Map();
|
|
123
|
+
}
|
|
124
|
+
add(color, weight = 1) {
|
|
125
|
+
if (!color || color.a < 0.6) return;
|
|
126
|
+
const hex = toHex(color);
|
|
127
|
+
const prev = this.map.get(hex);
|
|
128
|
+
this.map.set(hex, { color, weight: (prev?.weight ?? 0) + weight });
|
|
129
|
+
}
|
|
130
|
+
ranked(pred = () => true) {
|
|
131
|
+
return [...this.map.entries()].filter(([, v]) => pred(v.color)).sort((x, y) => y[1].weight - x[1].weight).map(([hex, v]) => ({ hex, ...v }));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- CSS -------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
// Every style rule of a stylesheet as { selector, decls: [[prop, value]], at }
|
|
138
|
+
// where `at` is the at-rule preludes it sits in (e.g. "@media (prefers-color-
|
|
139
|
+
// scheme: dark)"). A small brace walker: good enough for real sites, never
|
|
140
|
+
// executes anything.
|
|
141
|
+
export function cssRules(css) {
|
|
142
|
+
const text = String(css).replace(/\/\*[\s\S]*?\*\//g, "");
|
|
143
|
+
const rules = [];
|
|
144
|
+
const stack = [];
|
|
145
|
+
let buf = "";
|
|
146
|
+
let quote = null;
|
|
147
|
+
for (let i = 0; i < text.length; i++) {
|
|
148
|
+
const ch = text[i];
|
|
149
|
+
if (quote) {
|
|
150
|
+
buf += ch;
|
|
151
|
+
if (ch === quote && text[i - 1] !== "\\") quote = null;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (ch === '"' || ch === "'") {
|
|
155
|
+
quote = ch;
|
|
156
|
+
buf += ch;
|
|
157
|
+
} else if (ch === "{") {
|
|
158
|
+
stack.push(buf.trim());
|
|
159
|
+
buf = "";
|
|
160
|
+
} else if (ch === "}") {
|
|
161
|
+
const prelude = stack.pop();
|
|
162
|
+
if (prelude !== undefined && !prelude.startsWith("@")) {
|
|
163
|
+
const decls = [];
|
|
164
|
+
for (const d of buf.split(";")) {
|
|
165
|
+
const k = d.indexOf(":");
|
|
166
|
+
if (k > 0) decls.push([d.slice(0, k).trim().toLowerCase(), d.slice(k + 1).replace(/!important/i, "").trim()]);
|
|
167
|
+
}
|
|
168
|
+
rules.push({ selector: prelude, decls, at: stack.filter((p) => p.startsWith("@")) });
|
|
169
|
+
} else if (prelude?.startsWith("@font-face")) {
|
|
170
|
+
// @font-face has declarations, not rules.
|
|
171
|
+
const fam = /font-family\s*:\s*([^;]+)/i.exec(buf);
|
|
172
|
+
if (fam) rules.push({ selector: "@font-face", decls: [["font-family", fam[1].trim()]], at: stack.filter((p) => p.startsWith("@")) });
|
|
173
|
+
}
|
|
174
|
+
buf = "";
|
|
175
|
+
} else if (ch === ";" && !stack.length) {
|
|
176
|
+
buf = ""; // a top-level @import / @charset
|
|
177
|
+
} else buf += ch;
|
|
178
|
+
}
|
|
179
|
+
return rules;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const DARK = /\bdark\b/i;
|
|
183
|
+
const isDarkContext = (rule) => rule.at.some((a) => /prefers-color-scheme\s*:\s*dark/i.test(a)) || DARK.test(rule.selector);
|
|
184
|
+
const ROOTISH = /(^|[\s,])(:root|html|body|:host)(?=$|[\s,.:[#>])|\[data-theme|\.light\b/i;
|
|
185
|
+
|
|
186
|
+
// The custom properties a site defines on :root / html / body (light mode).
|
|
187
|
+
function customProperties(rules) {
|
|
188
|
+
const vars = new Map();
|
|
189
|
+
for (const r of rules) {
|
|
190
|
+
if (isDarkContext(r) || !ROOTISH.test(r.selector)) continue;
|
|
191
|
+
for (const [k, v] of r.decls) if (k.startsWith("--")) vars.set(k, v);
|
|
192
|
+
}
|
|
193
|
+
return vars;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function resolveVars(value, vars, depth = 0) {
|
|
197
|
+
if (depth > 6 || !value.includes("var(")) return value;
|
|
198
|
+
return resolveVars(
|
|
199
|
+
value.replace(/var\(\s*(--[\w-]+)\s*(?:,\s*([^()]*(?:\([^()]*\))?[^()]*))?\)/g, (_, name, fallback) => vars.get(name) ?? fallback ?? ""),
|
|
200
|
+
vars,
|
|
201
|
+
depth + 1,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// A colour from a declaration value: the value itself, a bare shadcn-style HSL
|
|
206
|
+
// triplet ("222 47% 11%"), or the first colour inside it (a border shorthand).
|
|
207
|
+
function colorIn(value) {
|
|
208
|
+
const v = value.trim();
|
|
209
|
+
const direct = parseColor(v);
|
|
210
|
+
if (direct) return direct;
|
|
211
|
+
const triplet = /^(-?[\d.]+)(?:deg)?\s+([\d.]+)%\s+([\d.]+)%(?:\s*\/\s*[\d.]+%?)?$/.exec(v);
|
|
212
|
+
if (triplet) return { ...hslToRgb(+triplet[1], +triplet[2] / 100, +triplet[3] / 100), a: 1 };
|
|
213
|
+
const inner = /(#[0-9a-f]{3,8}\b|(?:rgba?|hsla?|oklch|oklab)\([^)]*\))/i.exec(v);
|
|
214
|
+
return inner ? parseColor(inner[1]) : null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Semantic token names, by role (the name without its leading dashes).
|
|
218
|
+
const ROLE_NAMES = [
|
|
219
|
+
["paper", /^(?:colou?r-)?(?:background|bg|paper|page|canvas|base-100|surface)(?:-colou?r|-primary|-default)?$/],
|
|
220
|
+
["ink", /^(?:colou?r-)?(?:foreground|fg|text|ink|body-colou?r|text-colou?r|text-primary|on-background|base-content)(?:-colou?r|-default)?$/],
|
|
221
|
+
["accent", /^(?:colou?r-)?(?:brand|primary|accent|brand-primary|primary-500|brand-500|accent-500)(?:-colou?r|-default|-base)?$/],
|
|
222
|
+
["accent2", /^(?:colou?r-)?(?:secondary|accent-2|accent2|tertiary|highlight|brand-secondary)(?:-colou?r|-default|-base)?$/],
|
|
223
|
+
["card", /^(?:colou?r-)?(?:card|panel|surface-2|surface-raised|elevated|popover|base-200)(?:-colou?r|-background|-bg)?$/],
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const GENERIC_FAMILIES = new Set(["serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "ui-sans-serif", "ui-serif", "ui-monospace", "ui-rounded", "-apple-system", "blinkmacsystemfont", "emoji", "math", "fangsong", "inherit", "initial", "unset", "revert", "apple color emoji", "segoe ui emoji", "segoe ui symbol", "noto color emoji"]);
|
|
227
|
+
|
|
228
|
+
// A family name as people know it: unquoted, and next/font's generated names
|
|
229
|
+
// ("__Inter_d65c78", "__Space_Grotesk_Fallback_ab12cd") back to the real one.
|
|
230
|
+
export function normalizeFamily(raw) {
|
|
231
|
+
let f = String(raw ?? "").trim().replace(/^["']|["']$/g, "").trim();
|
|
232
|
+
const next = /^__(.+?)(_Fallback)?_[0-9a-f]{5,}$/i.exec(f);
|
|
233
|
+
if (next) {
|
|
234
|
+
if (next[2]) return null;
|
|
235
|
+
f = next[1].replace(/_/g, " ");
|
|
236
|
+
}
|
|
237
|
+
if (!f || GENERIC_FAMILIES.has(f.toLowerCase()) || /^var\(/.test(f) || /fallback/i.test(f)) return null;
|
|
238
|
+
return f.replace(/\s+/g, " ");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// The first real family of a font-family stack.
|
|
242
|
+
export function firstFamily(stack) {
|
|
243
|
+
for (const part of String(stack ?? "").split(",")) {
|
|
244
|
+
const f = normalizeFamily(part);
|
|
245
|
+
if (f) return f;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const HEADING = /(^|[\s,>])(h1|h2|h3)(?=$|[\s,.:[#>])|title|heading|display|headline|hero/i;
|
|
251
|
+
const MONO_SEL = /(^|[\s,>])(code|pre|kbd|samp)(?=$|[\s,.:[#>])|mono/i;
|
|
252
|
+
const BODYISH = /(^|[\s,])(html|body|:root|:host|p)(?=$|[\s,.:[#>])/i;
|
|
253
|
+
|
|
254
|
+
// ---- HTML helpers ----------------------------------------------------------
|
|
255
|
+
|
|
256
|
+
const attr = (tag, name) => {
|
|
257
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
|
|
258
|
+
return m ? (m[1] ?? m[2] ?? m[3] ?? "").replace(/&/g, "&").trim() : null;
|
|
259
|
+
};
|
|
260
|
+
const tags = (html, name) => [...String(html).matchAll(new RegExp(`<${name}\\b[^>]*>`, "gi"))].map((m) => ({ tag: m[0], index: m.index }));
|
|
261
|
+
const absolute = (href, base) => {
|
|
262
|
+
try {
|
|
263
|
+
const u = new URL(href, base);
|
|
264
|
+
return /^https?:$/.test(u.protocol) ? u.href : null;
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
const decodeEntities = (s) => s.replace(/&/g, "&").replace(/'|'/g, "'").replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&#(\d+);/g, (_, n) => String.fromCharCode(+n));
|
|
270
|
+
|
|
271
|
+
// The stylesheets a page links (in order, at most `max`), to fetch before extractSite.
|
|
272
|
+
export function stylesheetUrls(html, pageUrl, max = 8) {
|
|
273
|
+
const out = /** @type {string[]} */ ([]);
|
|
274
|
+
for (const { tag } of tags(html, "link")) {
|
|
275
|
+
const rel = (attr(tag, "rel") ?? "").toLowerCase().split(/\s+/);
|
|
276
|
+
if (!rel.includes("stylesheet")) continue;
|
|
277
|
+
const href = attr(tag, "href");
|
|
278
|
+
const url = href && absolute(href, pageUrl);
|
|
279
|
+
if (url && !out.includes(url)) out.push(url);
|
|
280
|
+
if (out.length >= max) break;
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// The <svg>…</svg> that starts at `start`, nested svgs included.
|
|
286
|
+
function svgAt(html, start) {
|
|
287
|
+
const re = /<svg\b|<\/svg>/gi;
|
|
288
|
+
re.lastIndex = start;
|
|
289
|
+
let depth = 0;
|
|
290
|
+
for (let m; (m = re.exec(html)); ) {
|
|
291
|
+
depth += m[0][1] === "/" ? -1 : 1;
|
|
292
|
+
if (depth === 0) return html.slice(start, m.index + m[0].length);
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// An inline SVG as a file an <img> can show: it needs the xmlns, and
|
|
298
|
+
// currentColor has no colour outside the page, so it takes the brand's ink.
|
|
299
|
+
export function standaloneSvg(svg, ink) {
|
|
300
|
+
let out = sanitizeSvg(svg);
|
|
301
|
+
if (!/\sxmlns\s*=/.test(out.slice(0, out.indexOf(">")))) out = out.replace(/<svg\b/i, '<svg xmlns="http://www.w3.org/2000/svg"');
|
|
302
|
+
if (ink) out = out.replace(/currentColor/gi, ink);
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// An SVG logo with nothing active left in it: no script, foreignObject, style,
|
|
307
|
+
// animation, DOCTYPE/entities, event handlers or links other than to its own
|
|
308
|
+
// #ids or embedded raster images. Stripping runs until nothing changes (so a
|
|
309
|
+
// tag cannot be rebuilt from the pieces of another), and an SVG that still
|
|
310
|
+
// reads as active once entities are decoded is refused, not stored.
|
|
311
|
+
const SVG_DROP = ["script", "foreignObject", "style", "iframe", "object", "embed", "audio", "video", "handler", "listener", "animate", "set", "animateMotion", "animateTransform"];
|
|
312
|
+
function stripSvg(svg) {
|
|
313
|
+
let out = svg.replace(/<!DOCTYPE[\s\S]*?(\[[\s\S]*?\]\s*)?>/gi, "").replace(/<!ENTITY[\s\S]*?>/gi, "").replace(/<\?(?!xml\s)[\s\S]*?\?>/gi, "");
|
|
314
|
+
for (const tag of SVG_DROP) out = out.replace(new RegExp(`<${tag}\\b[\\s\\S]*?</${tag}\\s*>`, "gi"), "").replace(new RegExp(`<${tag}\\b[^>]*>`, "gi"), "");
|
|
315
|
+
out = out.replace(/[\s/](on[\w-]+)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "");
|
|
316
|
+
out = out.replace(/[\s/]((?:[\w-]+:)?(?:href|src))\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, (m, _k, v) => {
|
|
317
|
+
const value = v.replace(/^["']|["']$/g, "").trim();
|
|
318
|
+
return /^#[\w-]+$/.test(value) || /^data:image\/(png|jpe?g|gif|webp);base64,[a-z0-9+/=\s]+$/i.test(value) ? m : "";
|
|
319
|
+
});
|
|
320
|
+
return out.replace(/javascript:/gi, "");
|
|
321
|
+
}
|
|
322
|
+
export function sanitizeSvg(svg) {
|
|
323
|
+
let out = String(svg);
|
|
324
|
+
for (let i = 0, prev = ""; out !== prev && i < 20; i++) [prev, out] = [out, stripSvg(out)];
|
|
325
|
+
const decoded = decodeEntities(out.replace(/&#x([0-9a-f]+);?/gi, (_, h) => String.fromCharCode(parseInt(h, 16))).replace(/&#(\d+);?/g, (_, d) => String.fromCharCode(+d))).replace(/\s+/g, "");
|
|
326
|
+
if (/<script|<foreignobject|javascript:|[\/"']on[a-z-]+=|<iframe|<animate|<set\b/i.test(decoded)) throw new Error("the SVG logo has scripts or active links in it; use a PNG, or a plain SVG");
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const LOGOISH = /logo|brand|wordmark|logotype/i;
|
|
331
|
+
|
|
332
|
+
// Where the logo is, best first: an inline SVG or <img> in the header marked as
|
|
333
|
+
// a logo (or the home link's), any <img> called a logo, the apple-touch-icon,
|
|
334
|
+
// an SVG / PNG icon, then og:image.
|
|
335
|
+
/** @returns {({ url: string, via: string } | { svg: string, via: string })[]} */
|
|
336
|
+
export function logoCandidates(html, pageUrl) {
|
|
337
|
+
const out = /** @type {any[]} */ ([]);
|
|
338
|
+
const push = (c) => {
|
|
339
|
+
if (c && !out.some((o) => (o.url && o.url === c.url) || (o.svg && o.svg === c.svg))) out.push(c);
|
|
340
|
+
};
|
|
341
|
+
const doc = String(html);
|
|
342
|
+
const header = /<header\b[\s\S]*?<\/header>/i.exec(doc) ?? /<nav\b[\s\S]*?<\/nav>/i.exec(doc);
|
|
343
|
+
const regions = header ? [{ text: header[0], offset: header.index }] : [];
|
|
344
|
+
regions.push({ text: doc.slice(0, 200_000), offset: 0 });
|
|
345
|
+
for (const { text } of regions) {
|
|
346
|
+
// Elements marked as the logo, then the link home.
|
|
347
|
+
const marked = [...text.matchAll(/<(a|div|span|svg|img|picture)\b[^>]*>/gi)].filter((m) => LOGOISH.test(m[0]) || (m[1].toLowerCase() === "a" && /\shref\s*=\s*["']?(\/|\.\/|https?:\/\/[^/"']+\/?)["'\s>]/i.test(m[0])));
|
|
348
|
+
for (const m of marked) {
|
|
349
|
+
const tail = text.slice(m.index, m.index + 20_000);
|
|
350
|
+
const img = /<img\b[^>]*>/i.exec(tail);
|
|
351
|
+
const svgStart = tail.search(/<svg\b/i);
|
|
352
|
+
const close = tail.search(new RegExp(`</${m[1]}>`, "i"));
|
|
353
|
+
if (m[1].toLowerCase() === "img") {
|
|
354
|
+
const src = attr(m[0], "src");
|
|
355
|
+
push(src && absolute(src, pageUrl) && { url: absolute(src, pageUrl), via: "img" });
|
|
356
|
+
} else if (svgStart >= 0 && (close < 0 || svgStart < close) && (!img || svgStart < img.index)) {
|
|
357
|
+
const svg = svgAt(tail, svgStart);
|
|
358
|
+
if (svg && svg.length < 200_000) push({ svg, via: "inline svg" });
|
|
359
|
+
} else if (img && (close < 0 || img.index < close)) {
|
|
360
|
+
const src = attr(img[0], "src");
|
|
361
|
+
push(src && absolute(src, pageUrl) && { url: absolute(src, pageUrl), via: "img" });
|
|
362
|
+
}
|
|
363
|
+
if (out.length >= 3) break;
|
|
364
|
+
}
|
|
365
|
+
if (out.length) break;
|
|
366
|
+
}
|
|
367
|
+
for (const { tag } of tags(doc, "img")) {
|
|
368
|
+
if (!LOGOISH.test(tag)) continue;
|
|
369
|
+
const src = attr(tag, "src");
|
|
370
|
+
push(src && absolute(src, pageUrl) && { url: absolute(src, pageUrl), via: "img" });
|
|
371
|
+
}
|
|
372
|
+
const links = tags(doc, "link");
|
|
373
|
+
const rel = (tag) => (attr(tag, "rel") ?? "").toLowerCase();
|
|
374
|
+
for (const { tag } of links) if (rel(tag).includes("apple-touch-icon")) push(attr(tag, "href") && absolute(attr(tag, "href"), pageUrl) && { url: absolute(attr(tag, "href"), pageUrl), via: "apple-touch-icon" });
|
|
375
|
+
for (const { tag } of links) {
|
|
376
|
+
const href = attr(tag, "href") ?? "";
|
|
377
|
+
if (rel(tag).split(/\s+/).includes("icon") && /\.(svg|png)(\?|$)|image\/(svg|png)/i.test(href + (attr(tag, "type") ?? ""))) push(absolute(href, pageUrl) && { url: absolute(href, pageUrl), via: "icon" });
|
|
378
|
+
}
|
|
379
|
+
for (const { tag } of tags(doc, "meta")) if (/og:image$/i.test(attr(tag, "property") ?? attr(tag, "name") ?? "")) push(attr(tag, "content") && absolute(attr(tag, "content"), pageUrl) && { url: absolute(attr(tag, "content"), pageUrl), via: "og:image" });
|
|
380
|
+
return out.filter((c) => !c.url || !/\.(ico)(\?|$)/i.test(c.url));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function siteName(html) {
|
|
384
|
+
for (const { tag } of tags(html, "meta")) {
|
|
385
|
+
const key = (attr(tag, "property") ?? attr(tag, "name") ?? "").toLowerCase();
|
|
386
|
+
if (key === "og:site_name" || key === "application-name") {
|
|
387
|
+
const v = attr(tag, "content");
|
|
388
|
+
if (v) return decodeEntities(v).slice(0, 60);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const title = /<title[^>]*>([^<]*)<\/title>/i.exec(html)?.[1];
|
|
392
|
+
if (!title) return undefined;
|
|
393
|
+
const first = decodeEntities(title).split(/\s[|–—·:-]\s|\s[|–—·]|[|–—·]\s/)[0].trim();
|
|
394
|
+
return first && first.length <= 40 ? first : undefined;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---- website ---------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
// A website's brand from its HTML and the text of its stylesheets (inline
|
|
400
|
+
// <style> blocks are read from the HTML). Exact tokens first: the colour custom
|
|
401
|
+
// properties a design system names (--background, --primary, …) and the fonts
|
|
402
|
+
// it loads; then what the page uses most.
|
|
403
|
+
/** @param {{ html: string, css?: string[], url: string }} page */
|
|
404
|
+
export function extractSite({ html, css = [], url }) {
|
|
405
|
+
const doc = String(html ?? "");
|
|
406
|
+
const inline = [...doc.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1]);
|
|
407
|
+
const rules = [...css, ...inline].flatMap((sheet) => cssRules(sheet));
|
|
408
|
+
// style="" attributes count as rules on the element.
|
|
409
|
+
for (const m of doc.matchAll(/<(body|html)?[^>]*\sstyle\s*=\s*"([^"]*)"/gi)) {
|
|
410
|
+
rules.push({ selector: m[1] ?? "*", decls: m[2].split(";").map((d) => [d.slice(0, d.indexOf(":")).trim().toLowerCase(), d.slice(d.indexOf(":") + 1).trim()]).filter(([k]) => k), at: [] });
|
|
411
|
+
}
|
|
412
|
+
const vars = customProperties(rules);
|
|
413
|
+
const notes = /** @type {string[]} */ ([]);
|
|
414
|
+
const colors = /** @type {Record<string, string>} */ ({});
|
|
415
|
+
const from = /** @type {Record<string, string>} */ ({});
|
|
416
|
+
|
|
417
|
+
// 1. Semantic tokens.
|
|
418
|
+
for (const [name, value] of vars) {
|
|
419
|
+
const bare = name.replace(/^--/, "").replace(/^(?:tw|ds|theme|brand-colou?rs?|colou?rs?)-(?=.)/, (p) => (/^colou?rs?-$/.test(p) ? "color-" : ""));
|
|
420
|
+
for (const [role, re] of ROLE_NAMES) {
|
|
421
|
+
if (colors[role] || !re.test(bare)) continue;
|
|
422
|
+
const c = colorIn(resolveVars(value, vars));
|
|
423
|
+
if (!c || c.a < 0.6) continue;
|
|
424
|
+
if ((role === "accent" || role === "accent2") && !isAccentLike(c)) continue;
|
|
425
|
+
colors[role] = toHex(c);
|
|
426
|
+
from[role] = name;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// 2. What the page uses: backgrounds, text and everything else, by weight.
|
|
431
|
+
const bg = new Tally();
|
|
432
|
+
const text = new Tally();
|
|
433
|
+
const other = new Tally();
|
|
434
|
+
const fontsBy = { display: new Map(), body: new Map(), mono: new Map(), any: new Map() };
|
|
435
|
+
const bump = (map, f, w) => f && map.set(f, (map.get(f) ?? 0) + w);
|
|
436
|
+
for (const r of rules) {
|
|
437
|
+
if (isDarkContext(r)) continue;
|
|
438
|
+
const base = BODYISH.test(r.selector) && !/[\s>]/.test(r.selector.trim().replace(/\s*,\s*/g, ",")) ? 5 : 1;
|
|
439
|
+
for (const [prop, rawValue] of r.decls) {
|
|
440
|
+
if (prop.startsWith("--")) continue;
|
|
441
|
+
const value = resolveVars(rawValue, vars);
|
|
442
|
+
if (prop === "font-family" || prop === "font") {
|
|
443
|
+
const stack = prop === "font" ? value.replace(/^.*?\d[\w%.]*(?:\/[\w.%]+)?\s+/, "") : value;
|
|
444
|
+
const f = firstFamily(stack);
|
|
445
|
+
if (r.selector === "@font-face") bump(fontsBy.any, f, 1);
|
|
446
|
+
else if (MONO_SEL.test(r.selector)) bump(fontsBy.mono, f, base);
|
|
447
|
+
else if (HEADING.test(r.selector)) bump(fontsBy.display, f, base);
|
|
448
|
+
else bump(fontsBy.body, f, base);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const c = colorIn(value);
|
|
452
|
+
if (!c) continue;
|
|
453
|
+
if (prop === "background" || prop === "background-color") bg.add(c, base);
|
|
454
|
+
else if (prop === "color") text.add(c, base);
|
|
455
|
+
else if (/colou?r|fill|stroke|border|outline|shadow|background/.test(prop)) other.add(c, 1);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// Tailwind's arbitrary values in class names (bg-[#0b0b0f], text-[#ff5a1f]).
|
|
459
|
+
for (const m of doc.matchAll(/\b(bg|text|border|from|to|via|fill|stroke)-\[(#[0-9a-f]{3,8})\]/gi)) {
|
|
460
|
+
const c = parseColor(m[2]);
|
|
461
|
+
(m[1] === "bg" ? bg : m[1] === "text" ? text : other).add(c, 1);
|
|
462
|
+
}
|
|
463
|
+
const theme = tags(doc, "meta").find(({ tag }) => /theme-color/i.test(attr(tag, "name") ?? ""));
|
|
464
|
+
const themeColor = theme && parseColor(attr(theme.tag, "content"));
|
|
465
|
+
|
|
466
|
+
if (!colors.paper) {
|
|
467
|
+
const top = bg.ranked((c) => hsl(c).s < 0.35)[0];
|
|
468
|
+
if (top) colors.paper = top.hex;
|
|
469
|
+
}
|
|
470
|
+
if (!colors.ink) {
|
|
471
|
+
const paperL = colors.paper ? hsl(parseColor(colors.paper)).l : 1;
|
|
472
|
+
const top = text.ranked((c) => Math.abs(hsl(c).l - paperL) > 0.45)[0];
|
|
473
|
+
if (top) colors.ink = top.hex;
|
|
474
|
+
}
|
|
475
|
+
const saturated = new Tally();
|
|
476
|
+
for (const t of [bg, text, other]) for (const r of t.ranked(isAccentLike)) saturated.add(r.color, r.weight * (0.5 + hsl(r.color).s));
|
|
477
|
+
if (themeColor && isAccentLike(themeColor)) saturated.add(themeColor, 4);
|
|
478
|
+
const accents = saturated.ranked();
|
|
479
|
+
if (!colors.accent && accents[0]) colors.accent = accents[0].hex;
|
|
480
|
+
if (!colors.accent2 && colors.accent) {
|
|
481
|
+
const a = parseColor(colors.accent);
|
|
482
|
+
const second = accents.find((x) => x.hex !== colors.accent && hueGap(x.color, a) >= 30);
|
|
483
|
+
if (second) colors.accent2 = second.hex;
|
|
484
|
+
}
|
|
485
|
+
if (!colors.card && colors.paper) {
|
|
486
|
+
const p = hsl(parseColor(colors.paper));
|
|
487
|
+
const card = bg.ranked((c) => toHex(c) !== colors.paper && hsl(c).s < 0.3 && Math.abs(hsl(c).l - p.l) < 0.12)[0];
|
|
488
|
+
if (card) colors.card = card.hex;
|
|
489
|
+
}
|
|
490
|
+
const named = Object.keys(from);
|
|
491
|
+
if (named.length) notes.push(`Colours ${named.map((k) => `${k} from ${from[k]}`).join(", ")}; the rest sampled from the site's CSS.`);
|
|
492
|
+
else if (Object.keys(colors).length) notes.push("Colours sampled from the site's CSS (it names no colour tokens); check them in the preview.");
|
|
493
|
+
else notes.push("No colours found in the site's static CSS (it may draw them with JavaScript); set them with set_brand, or capture_url the page to read them by eye.");
|
|
494
|
+
|
|
495
|
+
// Fonts: Google Fonts links, @font-face, then font-family by role.
|
|
496
|
+
const linked = [];
|
|
497
|
+
for (const { tag } of tags(doc, "link")) {
|
|
498
|
+
const href = attr(tag, "href") ?? "";
|
|
499
|
+
if (!/fonts\.googleapis\.com\/css/.test(href)) continue;
|
|
500
|
+
for (const m of href.matchAll(/family=([^&:]+)/g)) linked.push(decodeURIComponent(m[1].replace(/\+/g, " ")).trim());
|
|
501
|
+
}
|
|
502
|
+
for (const sheet of [...css, ...inline]) for (const m of String(sheet).matchAll(/@import\s+url\(["']?https:\/\/fonts\.googleapis\.com\/css2?\?([^"')]+)/g)) for (const f of m[1].matchAll(/family=([^&:]+)/g)) linked.push(decodeURIComponent(f[1].replace(/\+/g, " ")).trim());
|
|
503
|
+
const top = (map) => [...map.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
|
|
504
|
+
for (const f of linked) bump(fontsBy.any, f, 1);
|
|
505
|
+
// Font tokens a design system names (--font-sans, --font-heading, …).
|
|
506
|
+
for (const [name, value] of vars) {
|
|
507
|
+
if (!/font/.test(name)) continue;
|
|
508
|
+
const f = firstFamily(resolveVars(value, vars));
|
|
509
|
+
if (!f) continue;
|
|
510
|
+
if (/mono|code/.test(name)) bump(fontsBy.mono, f, 2);
|
|
511
|
+
else if (/display|heading|title|serif(?!-)|head/.test(name) && !/sans/.test(name)) bump(fontsBy.display, f, 2);
|
|
512
|
+
else if (/sans|body|base|text|default|primary/.test(name)) bump(fontsBy.body, f, 2);
|
|
513
|
+
}
|
|
514
|
+
// The fonts a site ships (@font-face, Google Fonts) are its brand; a family
|
|
515
|
+
// it only names (Arial in a starter template's body rule) is a fallback.
|
|
516
|
+
const web = new Set(fontsBy.any.keys());
|
|
517
|
+
const pick = (map, ok = () => true) => {
|
|
518
|
+
const ranked = [...map.entries()].filter(([f]) => ok(f)).sort((a, b) => b[1] - a[1]).map(([f]) => f);
|
|
519
|
+
return web.size ? ranked.find((f) => web.has(f)) : ranked[0];
|
|
520
|
+
};
|
|
521
|
+
const monoish = (f) => fontClass(f) === "mono";
|
|
522
|
+
const used = new Map([...fontsBy.any]);
|
|
523
|
+
for (const role of ["display", "body", "mono"]) for (const [f, w] of fontsBy[role]) if (web.has(f)) used.set(f, (used.get(f) ?? 0) + w);
|
|
524
|
+
const fonts = /** @type {Record<string, string>} */ ({});
|
|
525
|
+
fonts.body = pick(fontsBy.body, (f) => !monoish(f)) ?? pick(used, (f) => !monoish(f)) ?? top(fontsBy.body);
|
|
526
|
+
fonts.display = pick(fontsBy.display, (f) => !monoish(f)) ?? fonts.body;
|
|
527
|
+
const mono = pick(fontsBy.mono, monoish) ?? pick(used, monoish) ?? top(fontsBy.mono);
|
|
528
|
+
if (mono) fonts.mono = mono;
|
|
529
|
+
for (const k of Object.keys(fonts)) if (!fonts[k]) delete fonts[k];
|
|
530
|
+
|
|
531
|
+
const candidates = logoCandidates(doc, url);
|
|
532
|
+
const name = siteName(doc);
|
|
533
|
+
return { name, colors, fonts, logoCandidates: candidates, notes, source: { kind: "url", ref: url } };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ---- a Hyperdecks deck -----------------------------------------------------
|
|
537
|
+
|
|
538
|
+
// A deck's own tokens: its :root colours and fonts, and the brand logo on its
|
|
539
|
+
// first slide if it has one.
|
|
540
|
+
export function extractDeck(html, slug) {
|
|
541
|
+
const doc = String(html ?? "");
|
|
542
|
+
const root = /:root\s*\{([^}]*)\}/.exec(doc);
|
|
543
|
+
if (!root) throw new Error(`deck ${slug} has no :root tokens to read`);
|
|
544
|
+
const decl = (name) => new RegExp(`(?:^|[;{\\s])${name}\\s*:\\s*([^;}]+)`).exec(root[1])?.[1]?.trim();
|
|
545
|
+
const colors = /** @type {Record<string, string>} */ ({});
|
|
546
|
+
for (const [role, name] of [["ink", "--ink"], ["paper", "--paper"], ["accent", "--accent"], ["accent2", "--accent-2"], ["card", "--card"]]) {
|
|
547
|
+
const c = decl(name) && parseColor(decl(name));
|
|
548
|
+
if (c) colors[role] = toHex(c);
|
|
549
|
+
}
|
|
550
|
+
const fonts = /** @type {Record<string, string>} */ ({});
|
|
551
|
+
const body = firstFamily(decl("--font"));
|
|
552
|
+
const display = firstFamily(decl("--display")) ?? body;
|
|
553
|
+
const mono = firstFamily(decl("--mono"));
|
|
554
|
+
if (display) fonts.display = display;
|
|
555
|
+
if (body) fonts.body = body;
|
|
556
|
+
if (mono) fonts.mono = mono;
|
|
557
|
+
const logoTag = tags(doc, "img").find(({ tag }) => /class\s*=\s*["'][^"']*brand-logo/.test(tag));
|
|
558
|
+
const src = logoTag && attr(logoTag.tag, "src");
|
|
559
|
+
const alt = logoTag && attr(logoTag.tag, "alt");
|
|
560
|
+
const name = alt && / logo$/i.test(alt) ? decodeEntities(alt).replace(/ logo$/i, "").trim() : undefined;
|
|
561
|
+
const notes = /** @type {string[]} */ ([]);
|
|
562
|
+
notes.push(`Colours and fonts are deck ${slug}'s own :root tokens.`);
|
|
563
|
+
if (!src) notes.push("The deck has no brand logo; add one with set_brand { logoUrl }.");
|
|
564
|
+
else if (!/^https:\/\//.test(src)) notes.push(`The deck's logo (${src.slice(0, 80)}) is not a hosted https URL, so it was not copied; add it with set_brand { logoUrl }.`);
|
|
565
|
+
return { name, colors, fonts, logoCandidates: src && /^https:\/\//.test(src) ? [{ url: src, via: "deck" }] : [], notes, source: { kind: "deck", ref: slug } };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ---- PowerPoint ------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
const xmlText = (bytes) => strFromU8(bytes);
|
|
571
|
+
const relsOf = (files, path) => {
|
|
572
|
+
const dir = path.slice(0, path.lastIndexOf("/"));
|
|
573
|
+
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
574
|
+
const rels = files[`${dir}/_rels/${name}.rels`];
|
|
575
|
+
const out = {};
|
|
576
|
+
if (!rels) return out;
|
|
577
|
+
for (const m of xmlText(rels).matchAll(/<Relationship\b[^>]*>/g)) {
|
|
578
|
+
const id = attr(m[0], "Id");
|
|
579
|
+
const target = attr(m[0], "Target");
|
|
580
|
+
const type = attr(m[0], "Type") ?? "";
|
|
581
|
+
if (!id || !target || attr(m[0], "TargetMode") === "External") continue;
|
|
582
|
+
// Targets are relative to the part's folder.
|
|
583
|
+
const parts = `${dir}/${target}`.split("/");
|
|
584
|
+
const stack = [];
|
|
585
|
+
for (const p of parts) {
|
|
586
|
+
if (p === "..") stack.pop();
|
|
587
|
+
else if (p && p !== ".") stack.push(p);
|
|
588
|
+
}
|
|
589
|
+
out[id] = { path: target.startsWith("/") ? target.slice(1) : stack.join("/"), type };
|
|
590
|
+
}
|
|
591
|
+
return out;
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
// <a:dk1><a:srgbClr val="1F1F1F"/></a:dk1>, or a system colour's lastClr.
|
|
595
|
+
function schemeColor(theme, slot) {
|
|
596
|
+
const m = new RegExp(`<a:${slot}>([\\s\\S]*?)</a:${slot}>`).exec(theme);
|
|
597
|
+
if (!m) return undefined;
|
|
598
|
+
const hex = /<a:srgbClr\s+val="([0-9A-Fa-f]{6})"/.exec(m[1])?.[1] ?? /<a:sysClr\b[^>]*lastClr="([0-9A-Fa-f]{6})"/.exec(m[1])?.[1];
|
|
599
|
+
return hex ? `#${hex.toLowerCase()}` : undefined;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// A .pptx / .potx (PowerPoint, or Keynote and Google Slides exported to it):
|
|
603
|
+
// the theme's colour scheme and major/minor fonts exactly, and the slide
|
|
604
|
+
// master's picture as the logo.
|
|
605
|
+
export function extractPptx(bytes, fileName = "presentation.pptx") {
|
|
606
|
+
let files;
|
|
607
|
+
try {
|
|
608
|
+
let total = 0;
|
|
609
|
+
let parts = 0;
|
|
610
|
+
files = unzipSync(bytes, {
|
|
611
|
+
filter: (f) => {
|
|
612
|
+
if (!(/\.(xml|rels)$/.test(f.name) && /^(ppt\/(theme|slideMasters|slideLayouts)\/|docProps\/|\[Content_Types\])/.test(f.name))) return false;
|
|
613
|
+
total += f.originalSize;
|
|
614
|
+
if (f.originalSize > MAX_PART_BYTES || total > MAX_PARTS_BYTES) throw new Error(`${fileName}: its theme and layouts unpack to over ${Math.round(Math.max(f.originalSize, total) / 1024 / 1024)} MB; a PowerPoint theme is far smaller`);
|
|
615
|
+
if (++parts > MAX_PARTS) throw new Error(`${fileName}: over ${MAX_PARTS} theme and layout parts; a PowerPoint theme has far fewer`);
|
|
616
|
+
return true;
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
} catch (e) {
|
|
620
|
+
if (/unpack to|theme and layout parts/.test(e?.message)) throw e;
|
|
621
|
+
throw new Error(`${fileName} is not a PowerPoint file (.pptx / .potx)`);
|
|
622
|
+
}
|
|
623
|
+
const masters = Object.keys(files).filter((n) => /^ppt\/slideMasters\/slideMaster\d+\.xml$/.test(n)).sort((a, b) => parseInt(a.replace(/\D/g, ""), 10) - parseInt(b.replace(/\D/g, ""), 10));
|
|
624
|
+
const master = masters[0];
|
|
625
|
+
const masterRels = master ? relsOf(files, master) : {};
|
|
626
|
+
const themePath = Object.values(masterRels).find((r) => /\/theme$/.test(r.type))?.path ?? Object.keys(files).filter((n) => /^ppt\/theme\/theme\d+\.xml$/.test(n)).sort()[0];
|
|
627
|
+
if (!themePath || !files[themePath]) throw new Error(`${fileName} has no theme (ppt/theme/theme1.xml): is it a PowerPoint file?`);
|
|
628
|
+
const theme = xmlText(files[themePath]);
|
|
629
|
+
const scheme = /<a:clrScheme\b[^>]*name="([^"]*)"/.exec(theme)?.[1];
|
|
630
|
+
const colors = /** @type {Record<string, string>} */ ({});
|
|
631
|
+
const set = (role, slot) => {
|
|
632
|
+
const c = schemeColor(theme, slot);
|
|
633
|
+
if (c) colors[role] = c;
|
|
634
|
+
};
|
|
635
|
+
set("ink", "dk1");
|
|
636
|
+
set("paper", "lt1");
|
|
637
|
+
set("accent", "accent1");
|
|
638
|
+
set("accent2", "accent2");
|
|
639
|
+
set("card", "lt2");
|
|
640
|
+
const fontOf = (kind) => {
|
|
641
|
+
const block = new RegExp(`<a:${kind}>([\\s\\S]*?)</a:${kind}>`).exec(theme)?.[1] ?? "";
|
|
642
|
+
const f = /<a:latin\b[^>]*typeface="([^"]*)"/.exec(block)?.[1];
|
|
643
|
+
return f ? decodeEntities(f).trim() : undefined;
|
|
644
|
+
};
|
|
645
|
+
const fonts = /** @type {Record<string, string>} */ ({});
|
|
646
|
+
const major = fontOf("majorFont");
|
|
647
|
+
const minor = fontOf("minorFont");
|
|
648
|
+
if (major) fonts.display = major;
|
|
649
|
+
if (minor) fonts.body = minor;
|
|
650
|
+
const notes = /** @type {string[]} */ ([]);
|
|
651
|
+
notes.push(`Colours and fonts are the exact theme of ${fileName}${scheme ? ` (colour scheme "${scheme}")` : ""}: ink dk1, paper lt1, accent accent1, accent2 accent2, card lt2; titles the major font, body the minor.`);
|
|
652
|
+
if (/^office/i.test(scheme ?? "") && /^(calibri|aptos)/i.test(minor ?? "")) notes.push("This is PowerPoint's default Office theme, not a custom one: the colours may not be the brand's.");
|
|
653
|
+
|
|
654
|
+
// The logo: the first picture on the slide master, else on its first layout.
|
|
655
|
+
let logo;
|
|
656
|
+
const pictureOf = (part) => {
|
|
657
|
+
if (!files[part]) return null;
|
|
658
|
+
const xml = xmlText(files[part]);
|
|
659
|
+
const rels = relsOf(files, part);
|
|
660
|
+
for (const pic of xml.matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
|
|
661
|
+
const embed = /<a:blip\b[^>]*r:embed="([^"]+)"/.exec(pic[0])?.[1];
|
|
662
|
+
const svgEmbed = /<asvg:svgBlip\b[^>]*r:embed="([^"]+)"/.exec(pic[0])?.[1];
|
|
663
|
+
const rel = rels[svgEmbed ?? ""] ?? rels[embed ?? ""];
|
|
664
|
+
if (rel) return rel.path;
|
|
665
|
+
}
|
|
666
|
+
return null;
|
|
667
|
+
};
|
|
668
|
+
const firstLayout = Object.values(masterRels).find((r) => /\/slideLayout$/.test(r.type))?.path;
|
|
669
|
+
const media = (master && pictureOf(master)) || (firstLayout && pictureOf(firstLayout));
|
|
670
|
+
if (media) {
|
|
671
|
+
const ext = /\.([a-z0-9]+)$/i.exec(media)?.[1]?.toLowerCase() ?? "";
|
|
672
|
+
const type = LOGO_TYPES[ext];
|
|
673
|
+
// Only a picture under the logo limit is unpacked (its declared size; fflate inflates into exactly that).
|
|
674
|
+
let declared = 0;
|
|
675
|
+
const got = type ? unzipSync(bytes, { filter: (f) => f.name === media && (declared = f.originalSize) <= MAX_LOGO_BYTES })[media] : null;
|
|
676
|
+
if (!type) notes.push(`The slide master's picture is a .${ext} file, which a deck cannot show; add the logo with set_brand { logoUrl }.`);
|
|
677
|
+
else if (declared > MAX_LOGO_BYTES) notes.push(`The slide master's picture is ${Math.ceil(declared / 1024)} KB, over the 2 MB logo limit; add a smaller logo.`);
|
|
678
|
+
else if (!got) notes.push("The slide master names a picture the file does not contain.");
|
|
679
|
+
else if (got.byteLength > MAX_LOGO_BYTES) notes.push(`The slide master's picture is ${Math.ceil(got.byteLength / 1024)} KB, over the 2 MB logo limit; add a smaller logo.`);
|
|
680
|
+
else logo = { bytes: got, type, via: "slide master" };
|
|
681
|
+
} else notes.push("The slide master has no picture, so no logo was found; add one with set_brand { logoUrl }.");
|
|
682
|
+
const company = files["docProps/app.xml"] && /<Company>([^<]+)<\/Company>/.exec(xmlText(files["docProps/app.xml"]))?.[1]?.trim();
|
|
683
|
+
return { name: company ? decodeEntities(company).slice(0, 60) : undefined, colors, fonts, logo, logoCandidates: [], notes, source: { kind: "pptx", ref: fileName } };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ---- PDF -------------------------------------------------------------------
|
|
687
|
+
|
|
688
|
+
const latin1 = (bytes) => {
|
|
689
|
+
let s = "";
|
|
690
|
+
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
|
691
|
+
return s;
|
|
692
|
+
};
|
|
693
|
+
const toBytes = (s) => {
|
|
694
|
+
const out = new Uint8Array(s.length);
|
|
695
|
+
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 0xff;
|
|
696
|
+
return out;
|
|
697
|
+
};
|
|
698
|
+
// Inflate (zlib, or raw deflate) at most `cap` bytes: a stream that would
|
|
699
|
+
// grow past it is cut off there, so a PDF cannot balloon in memory. Null when
|
|
700
|
+
// the data does not inflate.
|
|
701
|
+
function inflateCapped(raw, cap, zlib) {
|
|
702
|
+
const chunks = [];
|
|
703
|
+
let size = 0;
|
|
704
|
+
let full = false;
|
|
705
|
+
const sink = (chunk) => {
|
|
706
|
+
if (full) return;
|
|
707
|
+
const room = cap - size;
|
|
708
|
+
if (chunk.length >= room) {
|
|
709
|
+
chunks.push(chunk.subarray(0, Math.max(room, 0)));
|
|
710
|
+
size = cap;
|
|
711
|
+
full = true;
|
|
712
|
+
throw new RangeError("cap");
|
|
713
|
+
}
|
|
714
|
+
chunks.push(chunk);
|
|
715
|
+
size += chunk.length;
|
|
716
|
+
};
|
|
717
|
+
const z = zlib ? new Unzlib(sink) : new Inflate(sink);
|
|
718
|
+
try {
|
|
719
|
+
// Small slices: fflate hands over all a slice inflates to at once, and deflate
|
|
720
|
+
// grows at most ~1032x, so one slice never holds more than about 1 MB.
|
|
721
|
+
for (let i = 0; i < raw.length && !full; i += 1024) z.push(raw.subarray(i, i + 1024), i + 1024 >= raw.length);
|
|
722
|
+
} catch {
|
|
723
|
+
if (!full) return size ? concat(chunks, size) : null;
|
|
724
|
+
}
|
|
725
|
+
return concat(chunks, size);
|
|
726
|
+
}
|
|
727
|
+
const concat = (chunks, size) => {
|
|
728
|
+
const out = new Uint8Array(size);
|
|
729
|
+
let at = 0;
|
|
730
|
+
for (const c of chunks) {
|
|
731
|
+
out.set(c, at);
|
|
732
|
+
at += c.length;
|
|
733
|
+
}
|
|
734
|
+
return out;
|
|
735
|
+
};
|
|
736
|
+
|
|
737
|
+
const HEAVY = /bold|black|heavy|semibold|demibold|extrabold|ultrabold|medium/i;
|
|
738
|
+
|
|
739
|
+
// A PDF font's BaseFont as a family: "ABCDEF+PlayfairDisplay-Bold" ->
|
|
740
|
+
// { family: "Playfair Display", heavy: true }.
|
|
741
|
+
export function pdfFontFamily(baseFont) {
|
|
742
|
+
let n = baseFont.replace(/#([0-9a-f]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16))).replace(/^[A-Z]{6}\+/, "");
|
|
743
|
+
const [fam, ...style] = n.split(/[-,]/);
|
|
744
|
+
const styleText = style.join(" ");
|
|
745
|
+
n = fam.replace(/(PSMT|MT|PS)$/, "").replace(/_/g, " ");
|
|
746
|
+
// "PlayfairDisplay" -> "Playfair Display", "IBMPlexSans" -> "IBM Plex Sans", "SourceSans3" -> "Source Sans 3".
|
|
747
|
+
n = n.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/([A-Za-z])(\d)/g, "$1 $2").replace(/\s+/g, " ").trim();
|
|
748
|
+
// A weight folded into the family name ("Inter SemiBold").
|
|
749
|
+
const folded = /\s(Thin|Light|Regular|Medium|Semi ?Bold|Bold|Extra ?Bold|Black|Heavy|Italic)$/i.exec(n);
|
|
750
|
+
if (folded) n = n.slice(0, folded.index);
|
|
751
|
+
return { family: n, heavy: HEAVY.test(styleText) || (!!folded && HEAVY.test(folded[1])) };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// A PDF's embedded fonts and the colours its first pages paint with. Content
|
|
755
|
+
// streams and object streams are inflated (FlateDecode); images and font
|
|
756
|
+
// programs are skipped.
|
|
757
|
+
export function extractPdf(bytes, fileName = "document.pdf") {
|
|
758
|
+
const text = latin1(bytes.subarray(0, Math.min(bytes.length, MAX_IMPORT_FILE_BYTES)));
|
|
759
|
+
if (!text.startsWith("%PDF")) throw new Error(`${fileName} is not a PDF`);
|
|
760
|
+
if (/\/Encrypt\b/.test(text)) throw new Error(`${fileName} is encrypted, so its fonts and colours cannot be read; export an unprotected copy`);
|
|
761
|
+
const chunks = [text];
|
|
762
|
+
let inflated = 0;
|
|
763
|
+
let streams = 0;
|
|
764
|
+
let undecoded = 0; // compressed streams we could not read
|
|
765
|
+
const re = /stream\r?\n/g;
|
|
766
|
+
for (let m; (m = re.exec(text)) && streams < 600 && inflated < MAX_PDF_INFLATE; ) {
|
|
767
|
+
const dictStart = text.lastIndexOf("obj", m.index);
|
|
768
|
+
const dict = text.slice(dictStart, m.index);
|
|
769
|
+
const end = text.indexOf("endstream", m.index);
|
|
770
|
+
if (end < 0) break;
|
|
771
|
+
re.lastIndex = end;
|
|
772
|
+
if (/\/Subtype\s*\/Image|\/FontFile|\/Length1|\/Length2|\/ICCBased|\/N\s+[134]\b.*\/Alternate|\/Type\s*\/XRef|\/Type\s*\/Metadata/.test(dict)) continue;
|
|
773
|
+
if (/\/Filter\s*\[?\s*\/(DCTDecode|JPXDecode|CCITTFaxDecode|JBIG2Decode)/.test(dict)) continue;
|
|
774
|
+
let body = text.slice(m.index + m[0].length, end);
|
|
775
|
+
if (/\/FlateDecode/.test(dict)) {
|
|
776
|
+
const raw = toBytes(body);
|
|
777
|
+
const out = inflateCapped(raw, MAX_PDF_INFLATE - inflated, true) ?? inflateCapped(raw, MAX_PDF_INFLATE - inflated, false);
|
|
778
|
+
if (!out) {
|
|
779
|
+
undecoded++;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
body = latin1(out);
|
|
783
|
+
} else if (/\/Filter/.test(dict)) {
|
|
784
|
+
undecoded++;
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
inflated += body.length;
|
|
788
|
+
streams++;
|
|
789
|
+
chunks.push(body);
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// Fonts.
|
|
793
|
+
const families = new Map();
|
|
794
|
+
for (const chunk of chunks) {
|
|
795
|
+
for (const m of chunk.matchAll(/\/BaseFont\s*\/([^\s/[\]<>()]+)/g)) {
|
|
796
|
+
const { family, heavy } = pdfFontFamily(m[1]);
|
|
797
|
+
if (!family || /^(Symbol|ZapfDingbats|Wingdings)/i.test(family)) continue;
|
|
798
|
+
const f = families.get(family) ?? { heavy: 0, regular: 0 };
|
|
799
|
+
f[heavy ? "heavy" : "regular"]++;
|
|
800
|
+
families.set(family, f);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
const isMono = (f) => /mono|code|courier|consol|menlo/i.test(f);
|
|
804
|
+
const ranked = [...families.entries()].filter(([f]) => !isMono(f));
|
|
805
|
+
const fonts = /** @type {Record<string, string>} */ ({});
|
|
806
|
+
const body = [...ranked].sort((a, b) => b[1].regular - a[1].regular || b[1].heavy - a[1].heavy)[0]?.[0];
|
|
807
|
+
const display = [...ranked].filter(([f]) => f !== body).sort((a, b) => b[1].heavy - a[1].heavy)[0];
|
|
808
|
+
if (body) fonts.body = body;
|
|
809
|
+
fonts.display = display && display[1].heavy > 0 ? display[0] : body;
|
|
810
|
+
const mono = [...families.keys()].find(isMono);
|
|
811
|
+
if (mono) fonts.mono = mono;
|
|
812
|
+
for (const k of Object.keys(fonts)) if (!fonts[k]) delete fonts[k];
|
|
813
|
+
|
|
814
|
+
// Colours: fill operators, and the big filled rectangles that are page backgrounds.
|
|
815
|
+
const fills = new Tally();
|
|
816
|
+
const backgrounds = new Tally();
|
|
817
|
+
const n = "(-?[\\d.]+)";
|
|
818
|
+
const ops = new RegExp(`${n}\\s+${n}\\s+${n}\\s+${n}\\s+(k|K)\\b|${n}\\s+${n}\\s+${n}\\s+(rg|RG|scn|SCN|sc|SC)\\b|${n}\\s+(g|G)\\b`, "g");
|
|
819
|
+
for (const chunk of chunks.slice(1)) {
|
|
820
|
+
for (const m of chunk.matchAll(ops)) {
|
|
821
|
+
let c;
|
|
822
|
+
if (m[5]) {
|
|
823
|
+
const [C, M, Y, K] = [m[1], m[2], m[3], m[4]].map(Number);
|
|
824
|
+
c = { r: Math.round(255 * (1 - C) * (1 - K)), g: Math.round(255 * (1 - M) * (1 - K)), b: Math.round(255 * (1 - Y) * (1 - K)), a: 1 };
|
|
825
|
+
} else if (m[9]) c = { r: Math.round(+m[6] * 255), g: Math.round(+m[7] * 255), b: Math.round(+m[8] * 255), a: 1 };
|
|
826
|
+
else c = { r: Math.round(+m[10] * 255), g: Math.round(+m[10] * 255), b: Math.round(+m[10] * 255), a: 1 };
|
|
827
|
+
if ([c.r, c.g, c.b].some((x) => Number.isNaN(x) || x < 0 || x > 255)) continue;
|
|
828
|
+
const stroke = /^[A-Z]+$/.test(m[5] ?? m[9] ?? m[11]);
|
|
829
|
+
if (stroke) continue;
|
|
830
|
+
fills.add(c, 1);
|
|
831
|
+
// The next path op: a page-sized rectangle filled right away is the background.
|
|
832
|
+
const after = chunk.slice(m.index + m[0].length, m.index + m[0].length + 160);
|
|
833
|
+
const rect = /^\s*(?:[^\n]*\n)??\s*-?[\d.]+\s+-?[\d.]+\s+([\d.]+)\s+([\d.]+)\s+re\s+f\*?\b/.exec(after);
|
|
834
|
+
if (rect && +rect[1] * +rect[2] >= 300_000) backgrounds.add(c, 1);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const colors = /** @type {Record<string, string>} */ ({});
|
|
838
|
+
const paper = backgrounds.ranked((c) => hsl(c).s < 0.4)[0];
|
|
839
|
+
// A white page is assumed only when the pages were read and drew no background.
|
|
840
|
+
const painted = fills.ranked().length > 0;
|
|
841
|
+
if (paper) colors.paper = paper.hex;
|
|
842
|
+
else if (painted) colors.paper = "#ffffff";
|
|
843
|
+
const paperL = colors.paper ? hsl(parseColor(colors.paper)).l : 1;
|
|
844
|
+
const ink = fills.ranked((c) => Math.abs(hsl(c).l - paperL) > 0.5 && hsl(c).s < 0.5)[0];
|
|
845
|
+
if (ink) colors.ink = ink.hex;
|
|
846
|
+
const accents = fills.ranked(isAccentLike);
|
|
847
|
+
if (accents[0]) colors.accent = accents[0].hex;
|
|
848
|
+
const second = accents.find((x) => x.hex !== colors.accent && hueGap(x.color, accents[0].color) >= 30);
|
|
849
|
+
if (second) colors.accent2 = second.hex;
|
|
850
|
+
|
|
851
|
+
const notes = /** @type {string[]} */ ([]);
|
|
852
|
+
notes.push(families.size ? `Fonts are the ones embedded in ${fileName}.` : `${fileName} embeds no fonts we could read.`);
|
|
853
|
+
const skipped = undecoded ? ` (${undecoded} compressed stream${undecoded > 1 ? "s" : ""} could not be decoded)` : "";
|
|
854
|
+
if (painted) notes.push(`Colours sampled from what the first pages paint${paper ? "" : "; no page background was drawn, so paper is white"}${skipped}: check them in the preview.`);
|
|
855
|
+
else notes.push(`No colours could be read from ${fileName}'s pages${skipped}; set them with set_brand.`);
|
|
856
|
+
notes.push("A PDF does not say which picture is the logo; add one with set_brand { logoUrl } or --logo.");
|
|
857
|
+
return { colors, fonts, logoCandidates: [], notes, source: { kind: "pdf", ref: fileName } };
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// Whether a draft read anything worth saving: a colour (beyond the white page
|
|
861
|
+
// a PDF is assumed to have) or a font. Saving an empty one would wipe a brand.
|
|
862
|
+
export function foundAnything(x) {
|
|
863
|
+
const colors = Object.entries(x.colors ?? {}).filter(([k, v]) => !(x.source?.kind === "pdf" && k === "paper" && v === "#ffffff"));
|
|
864
|
+
return colors.length > 0 || Object.values(x.fonts ?? {}).some(Boolean);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// Which extractor a file needs, by its bytes (a zip or a PDF) and then its name.
|
|
868
|
+
export function fileKind(bytes, name = "") {
|
|
869
|
+
if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) return "pdf";
|
|
870
|
+
if (bytes[0] === 0x50 && bytes[1] === 0x4b) return "pptx";
|
|
871
|
+
if (/\.pdf$/i.test(name)) return "pdf";
|
|
872
|
+
if (/\.(pptx|potx)$/i.test(name)) return "pptx";
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
export function extractFile(bytes, name) {
|
|
877
|
+
const kind = fileKind(bytes, name);
|
|
878
|
+
if (kind === "pdf") return extractPdf(bytes, name || "document.pdf");
|
|
879
|
+
if (kind === "pptx") return extractPptx(bytes, name || "presentation.pptx");
|
|
880
|
+
throw new Error(`${name || "the file"} is not a .pptx, .potx or .pdf`);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// ---- fonts -----------------------------------------------------------------
|
|
884
|
+
|
|
885
|
+
// Families Google Fonts serves (the pairings' and the most used), for when the
|
|
886
|
+
// live check (fonts.googleapis.com) is unavailable. Lower-case keys.
|
|
887
|
+
const KNOWN_GOOGLE = new Map(
|
|
888
|
+
[
|
|
889
|
+
...FONT_PAIRINGS.flatMap((p) => [p.display, p.body, p.mono]),
|
|
890
|
+
"Roboto", "Open Sans", "Lato", "Montserrat", "Poppins", "Oswald", "Raleway", "Nunito", "Rubik", "Work Sans", "Noto Sans", "Noto Serif",
|
|
891
|
+
"Merriweather", "Lora", "PT Sans", "PT Serif", "Ubuntu", "Mukta", "Karla", "Barlow", "Mulish", "Quicksand", "Josefin Sans", "Libre Franklin",
|
|
892
|
+
"Cabin", "Arimo", "Tinos", "Cousine", "Carlito", "Caladea", "Heebo", "Hind", "Figtree", "Onest", "Instrument Sans", "Instrument Serif",
|
|
893
|
+
"Lexend", "Urbanist", "Red Hat Display", "Red Hat Text", "Albert Sans", "Be Vietnam Pro", "Epilogue", "Chivo", "Cormorant Garamond",
|
|
894
|
+
"EB Garamond", "Crimson Text", "Crimson Pro", "Spectral", "Libre Caslon Text", "DM Serif Display", "Bodoni Moda", "Abril Fatface",
|
|
895
|
+
"Bebas Neue", "Anton", "Archivo Black", "Righteous", "Comfortaa", "Varela Round", "Fira Sans", "Source Serif 4", "Source Sans 3",
|
|
896
|
+
"Roboto Slab", "Roboto Condensed", "Roboto Flex", "Inter Tight", "Space Grotesk", "Manrope", "Sora", "Outfit", "Syne", "Unbounded",
|
|
897
|
+
"Geist", "Geist Mono", "JetBrains Mono", "Fira Code", "IBM Plex Serif", "Noto Sans Mono", "Ubuntu Mono", "Inconsolata", "Overpass",
|
|
898
|
+
"Overpass Mono", "Questrial", "Assistant", "Exo 2", "Titillium Web", "Kanit", "Prompt", "Zilla Slab", "Bitter", "Arvo", "Domine",
|
|
899
|
+
"Libre Bodoni", "Gloock", "Young Serif", "Literata", "Atkinson Hyperlegible", "Golos Text", "Wix Madefor Display", "Wix Madefor Text",
|
|
900
|
+
"Funnel Display", "Funnel Sans", "Host Grotesk", "Parkinsans", "Afacad", "Gabarito", "Hanken Grotesk", "Readex Pro",
|
|
901
|
+
].map((f) => [f.toLowerCase(), f]),
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
// The look of a family Google Fonts does not serve, for the closest pairing.
|
|
905
|
+
export function fontClass(family) {
|
|
906
|
+
const f = String(family).toLowerCase();
|
|
907
|
+
if (/mono|code|courier|consol|menlo|monaco/.test(f)) return "mono";
|
|
908
|
+
if (!/sans/.test(f) && /serif|times|georgia|garamond|baskerville|caslon|didot|bodoni|cambria|palatino|antiqua|minion|tiempos|sectra|canela|freight|charter|iowan|book|century schoolbook|perpetua|rockwell|constantia/.test(f)) return "serif";
|
|
909
|
+
if (/futura|avenir|gotham|circular|proxima|brandon|gilroy|eina|product sans|google sans|century gothic|cera|aeonik|walsheim|tt norms|euclid|graphik|gilmer|sf pro rounded|museo sans/.test(f)) return "geometric";
|
|
910
|
+
if (/rounded|nunito|quicksand|varela|comfortaa/.test(f)) return "friendly";
|
|
911
|
+
return "grotesk";
|
|
912
|
+
}
|
|
913
|
+
const CLASS_PAIRING = { serif: "editorial", geometric: "geometric", friendly: "friendly", grotesk: "inter", mono: "inter" };
|
|
914
|
+
// A serif body reads best as the literary pairing's body.
|
|
915
|
+
const BODY_PAIRING = { serif: "literary" };
|
|
916
|
+
|
|
917
|
+
// The draft's fonts as Google Fonts families. A family Google serves is kept
|
|
918
|
+
// (with Google's spelling); any other maps to the closest pairing's family for
|
|
919
|
+
// that role, with a note saying so. isGoogleFont(family) -> boolean | Promise.
|
|
920
|
+
export async function resolveFonts(fonts = {}, { isGoogleFont } = {}) {
|
|
921
|
+
const out = {};
|
|
922
|
+
const notes = /** @type {string[]} */ ([]);
|
|
923
|
+
const known = async (family) => {
|
|
924
|
+
const hit = KNOWN_GOOGLE.get(family.toLowerCase());
|
|
925
|
+
if (hit) return hit;
|
|
926
|
+
// Google family names are letters, digits and spaces; nothing else is worth asking about.
|
|
927
|
+
if (!isGoogleFont || !/^[A-Za-z0-9]+(?: [A-Za-z0-9]+)*$/.test(family) || family.length > 60) return null;
|
|
928
|
+
try {
|
|
929
|
+
return (await isGoogleFont(family)) ? family : null;
|
|
930
|
+
} catch (e) {
|
|
931
|
+
// Could not ask: keep the family rather than swap it on a guess.
|
|
932
|
+
console.warn("[brand] Google Fonts check failed", family, e?.message ?? e);
|
|
933
|
+
unconfirmed.push(family);
|
|
934
|
+
return family;
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
const unconfirmed = [];
|
|
938
|
+
let pairing = null;
|
|
939
|
+
for (const role of ["display", "body", "mono"]) {
|
|
940
|
+
const family = fonts[role] ? normalizeFamily(fonts[role]) : null;
|
|
941
|
+
if (!family) continue;
|
|
942
|
+
const google = await known(family);
|
|
943
|
+
if (google) {
|
|
944
|
+
out[role] = google;
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
const cls = role === "mono" ? "mono" : fontClass(family);
|
|
948
|
+
const id = (role === "body" && BODY_PAIRING[cls]) || CLASS_PAIRING[cls];
|
|
949
|
+
const p = FONT_PAIRINGS.find((x) => x.id === id);
|
|
950
|
+
pairing ??= p;
|
|
951
|
+
out[role] = p[role];
|
|
952
|
+
notes.push(`${family} is not on Google Fonts, so ${role} uses ${p[role]} (the "${p.id}" pairing), the closest match.`);
|
|
953
|
+
}
|
|
954
|
+
if (!out.mono) {
|
|
955
|
+
const p =
|
|
956
|
+
pairing ??
|
|
957
|
+
FONT_PAIRINGS.find((x) => x.display === out.display && x.body === out.body) ??
|
|
958
|
+
FONT_PAIRINGS.find((x) => x.display === out.display) ??
|
|
959
|
+
FONT_PAIRINGS.find((x) => x.body === out.body) ??
|
|
960
|
+
FONT_PAIRINGS.find((x) => x.id === "inter");
|
|
961
|
+
out.mono = p.mono;
|
|
962
|
+
notes.push(`No monospace font found; labels use ${p.mono} (the "${p.id}" pairing).`);
|
|
963
|
+
}
|
|
964
|
+
for (const f of new Set(unconfirmed)) notes.push(`Could not check that ${f} is on Google Fonts (the check failed), so it is kept as found; if the preview shows a fallback font, pick another.`);
|
|
965
|
+
// A role the source left empty takes the other's family.
|
|
966
|
+
if (!out.display && out.body) out.display = out.body;
|
|
967
|
+
if (!out.body && out.display) out.body = out.display;
|
|
968
|
+
return { fonts: out, notes };
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// A draft read from a file as brandNode.createDraft's arguments (no undefined
|
|
972
|
+
// fields: Convex refuses them).
|
|
973
|
+
export function draftArgs(x) {
|
|
974
|
+
const args = { source: x.source, colors: x.colors, fonts: x.fonts, notes: x.notes };
|
|
975
|
+
if (x.name) args.name = x.name;
|
|
976
|
+
if (x.logo?.bytes) {
|
|
977
|
+
args.logoBytes = x.logo.bytes.slice().buffer;
|
|
978
|
+
args.logoType = x.logo.type;
|
|
979
|
+
}
|
|
980
|
+
return args;
|
|
981
|
+
}
|