@hypersocial/decks-cli 0.6.0 → 0.8.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.
@@ -0,0 +1,991 @@
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(/&amp;/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(/&amp;/g, "&").replace(/&#39;|&apos;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, "<").replace(/&gt;/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
+ // For people, plain words; for the logs (details), which token gave what.
492
+ const details = /** @type {string[]} */ ([]);
493
+ if (named.length) details.push(`colours ${named.map((k) => `${k} from ${from[k]}`).join(", ")}; the rest sampled from the site's CSS`);
494
+ if (!named.length && Object.keys(colors).length) notes.push("The site doesn't name its colours, so they were picked from its styles: check them in the sample.");
495
+ else if (!Object.keys(colors).length) notes.push("No colours were found on the site (it may draw them with JavaScript): pick them by hand.");
496
+
497
+ // Fonts: Google Fonts links, @font-face, then font-family by role.
498
+ const linked = [];
499
+ for (const { tag } of tags(doc, "link")) {
500
+ const href = attr(tag, "href") ?? "";
501
+ if (!/fonts\.googleapis\.com\/css/.test(href)) continue;
502
+ for (const m of href.matchAll(/family=([^&:]+)/g)) linked.push(decodeURIComponent(m[1].replace(/\+/g, " ")).trim());
503
+ }
504
+ 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());
505
+ const top = (map) => [...map.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
506
+ for (const f of linked) bump(fontsBy.any, f, 1);
507
+ // Font tokens a design system names (--font-sans, --font-heading, …).
508
+ for (const [name, value] of vars) {
509
+ if (!/font/.test(name)) continue;
510
+ const f = firstFamily(resolveVars(value, vars));
511
+ if (!f) continue;
512
+ if (/mono|code/.test(name)) bump(fontsBy.mono, f, 2);
513
+ else if (/display|heading|title|serif(?!-)|head/.test(name) && !/sans/.test(name)) bump(fontsBy.display, f, 2);
514
+ else if (/sans|body|base|text|default|primary/.test(name)) bump(fontsBy.body, f, 2);
515
+ }
516
+ // The fonts a site ships (@font-face, Google Fonts) are its brand; a family
517
+ // it only names (Arial in a starter template's body rule) is a fallback.
518
+ const web = new Set(fontsBy.any.keys());
519
+ const pick = (map, ok = () => true) => {
520
+ const ranked = [...map.entries()].filter(([f]) => ok(f)).sort((a, b) => b[1] - a[1]).map(([f]) => f);
521
+ return web.size ? ranked.find((f) => web.has(f)) : ranked[0];
522
+ };
523
+ const monoish = (f) => fontClass(f) === "mono";
524
+ const used = new Map([...fontsBy.any]);
525
+ 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);
526
+ const fonts = /** @type {Record<string, string>} */ ({});
527
+ fonts.body = pick(fontsBy.body, (f) => !monoish(f)) ?? pick(used, (f) => !monoish(f)) ?? top(fontsBy.body);
528
+ fonts.display = pick(fontsBy.display, (f) => !monoish(f)) ?? fonts.body;
529
+ const mono = pick(fontsBy.mono, monoish) ?? pick(used, monoish) ?? top(fontsBy.mono);
530
+ if (mono) fonts.mono = mono;
531
+ for (const k of Object.keys(fonts)) if (!fonts[k]) delete fonts[k];
532
+
533
+ const candidates = logoCandidates(doc, url);
534
+ const name = siteName(doc);
535
+ return { name, colors, fonts, logoCandidates: candidates, notes, details, source: { kind: "url", ref: url } };
536
+ }
537
+
538
+ // ---- a Hyperdecks deck -----------------------------------------------------
539
+
540
+ // A deck's own tokens: its :root colours and fonts, and the brand logo on its
541
+ // first slide if it has one.
542
+ export function extractDeck(html, slug) {
543
+ const doc = String(html ?? "");
544
+ const root = /:root\s*\{([^}]*)\}/.exec(doc);
545
+ if (!root) throw new Error(`deck ${slug} has no :root tokens to read`);
546
+ const decl = (name) => new RegExp(`(?:^|[;{\\s])${name}\\s*:\\s*([^;}]+)`).exec(root[1])?.[1]?.trim();
547
+ const colors = /** @type {Record<string, string>} */ ({});
548
+ for (const [role, name] of [["ink", "--ink"], ["paper", "--paper"], ["accent", "--accent"], ["accent2", "--accent-2"], ["card", "--card"]]) {
549
+ const c = decl(name) && parseColor(decl(name));
550
+ if (c) colors[role] = toHex(c);
551
+ }
552
+ const fonts = /** @type {Record<string, string>} */ ({});
553
+ const body = firstFamily(decl("--font"));
554
+ const display = firstFamily(decl("--display")) ?? body;
555
+ const mono = firstFamily(decl("--mono"));
556
+ if (display) fonts.display = display;
557
+ if (body) fonts.body = body;
558
+ if (mono) fonts.mono = mono;
559
+ const logoTag = tags(doc, "img").find(({ tag }) => /class\s*=\s*["'][^"']*brand-logo/.test(tag));
560
+ const src = logoTag && attr(logoTag.tag, "src");
561
+ const alt = logoTag && attr(logoTag.tag, "alt");
562
+ const name = alt && / logo$/i.test(alt) ? decodeEntities(alt).replace(/ logo$/i, "").trim() : undefined;
563
+ const notes = /** @type {string[]} */ ([]);
564
+ const details = [`colours and fonts are deck ${slug}'s :root tokens`];
565
+ if (!src) notes.push("The deck has no logo: add one.");
566
+ else if (!/^https:\/\//.test(src)) {
567
+ notes.push("The deck's logo is not hosted online, so it was not copied: add it.");
568
+ details.push(`deck logo src ${src.slice(0, 80)} is not an https URL`);
569
+ }
570
+ return { name, colors, fonts, logoCandidates: src && /^https:\/\//.test(src) ? [{ url: src, via: "deck" }] : [], notes, details, source: { kind: "deck", ref: slug } };
571
+ }
572
+
573
+ // ---- PowerPoint ------------------------------------------------------------
574
+
575
+ const xmlText = (bytes) => strFromU8(bytes);
576
+ const relsOf = (files, path) => {
577
+ const dir = path.slice(0, path.lastIndexOf("/"));
578
+ const name = path.slice(path.lastIndexOf("/") + 1);
579
+ const rels = files[`${dir}/_rels/${name}.rels`];
580
+ const out = {};
581
+ if (!rels) return out;
582
+ for (const m of xmlText(rels).matchAll(/<Relationship\b[^>]*>/g)) {
583
+ const id = attr(m[0], "Id");
584
+ const target = attr(m[0], "Target");
585
+ const type = attr(m[0], "Type") ?? "";
586
+ if (!id || !target || attr(m[0], "TargetMode") === "External") continue;
587
+ // Targets are relative to the part's folder.
588
+ const parts = `${dir}/${target}`.split("/");
589
+ const stack = [];
590
+ for (const p of parts) {
591
+ if (p === "..") stack.pop();
592
+ else if (p && p !== ".") stack.push(p);
593
+ }
594
+ out[id] = { path: target.startsWith("/") ? target.slice(1) : stack.join("/"), type };
595
+ }
596
+ return out;
597
+ };
598
+
599
+ // <a:dk1><a:srgbClr val="1F1F1F"/></a:dk1>, or a system colour's lastClr.
600
+ function schemeColor(theme, slot) {
601
+ const m = new RegExp(`<a:${slot}>([\\s\\S]*?)</a:${slot}>`).exec(theme);
602
+ if (!m) return undefined;
603
+ 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];
604
+ return hex ? `#${hex.toLowerCase()}` : undefined;
605
+ }
606
+
607
+ // A .pptx / .potx (PowerPoint, or Keynote and Google Slides exported to it):
608
+ // the theme's colour scheme and major/minor fonts exactly, and the slide
609
+ // master's picture as the logo.
610
+ export function extractPptx(bytes, fileName = "presentation.pptx") {
611
+ let files;
612
+ try {
613
+ let total = 0;
614
+ let parts = 0;
615
+ files = unzipSync(bytes, {
616
+ filter: (f) => {
617
+ if (!(/\.(xml|rels)$/.test(f.name) && /^(ppt\/(theme|slideMasters|slideLayouts)\/|docProps\/|\[Content_Types\])/.test(f.name))) return false;
618
+ total += f.originalSize;
619
+ 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`);
620
+ if (++parts > MAX_PARTS) throw new Error(`${fileName}: over ${MAX_PARTS} theme and layout parts; a PowerPoint theme has far fewer`);
621
+ return true;
622
+ },
623
+ });
624
+ } catch (e) {
625
+ if (/unpack to|theme and layout parts/.test(e?.message)) throw e;
626
+ throw new Error(`${fileName} is not a PowerPoint file (.pptx / .potx)`);
627
+ }
628
+ 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));
629
+ const master = masters[0];
630
+ const masterRels = master ? relsOf(files, master) : {};
631
+ 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];
632
+ if (!themePath || !files[themePath]) throw new Error(`${fileName} has no theme (ppt/theme/theme1.xml): is it a PowerPoint file?`);
633
+ const theme = xmlText(files[themePath]);
634
+ const scheme = /<a:clrScheme\b[^>]*name="([^"]*)"/.exec(theme)?.[1];
635
+ const colors = /** @type {Record<string, string>} */ ({});
636
+ const set = (role, slot) => {
637
+ const c = schemeColor(theme, slot);
638
+ if (c) colors[role] = c;
639
+ };
640
+ set("ink", "dk1");
641
+ set("paper", "lt1");
642
+ set("accent", "accent1");
643
+ set("accent2", "accent2");
644
+ set("card", "lt2");
645
+ const fontOf = (kind) => {
646
+ const block = new RegExp(`<a:${kind}>([\\s\\S]*?)</a:${kind}>`).exec(theme)?.[1] ?? "";
647
+ const f = /<a:latin\b[^>]*typeface="([^"]*)"/.exec(block)?.[1];
648
+ return f ? decodeEntities(f).trim() : undefined;
649
+ };
650
+ const fonts = /** @type {Record<string, string>} */ ({});
651
+ const major = fontOf("majorFont");
652
+ const minor = fontOf("minorFont");
653
+ if (major) fonts.display = major;
654
+ if (minor) fonts.body = minor;
655
+ const notes = /** @type {string[]} */ ([]);
656
+ const details = [`theme of ${fileName}${scheme ? ` (colour scheme "${scheme}")` : ""}: ink dk1, paper lt1, accent accent1, accent2 accent2, card lt2; titles the major font, body the minor`];
657
+ if (/^office/i.test(scheme ?? "") && /^(calibri|aptos)/i.test(minor ?? "")) notes.push("This is PowerPoint's default theme, not a custom one, so the colours may not be your brand's.");
658
+
659
+ // The logo: the first picture on the slide master, else on its first layout.
660
+ let logo;
661
+ const pictureOf = (part) => {
662
+ if (!files[part]) return null;
663
+ const xml = xmlText(files[part]);
664
+ const rels = relsOf(files, part);
665
+ for (const pic of xml.matchAll(/<p:pic>[\s\S]*?<\/p:pic>/g)) {
666
+ const embed = /<a:blip\b[^>]*r:embed="([^"]+)"/.exec(pic[0])?.[1];
667
+ const svgEmbed = /<asvg:svgBlip\b[^>]*r:embed="([^"]+)"/.exec(pic[0])?.[1];
668
+ const rel = rels[svgEmbed ?? ""] ?? rels[embed ?? ""];
669
+ if (rel) return rel.path;
670
+ }
671
+ return null;
672
+ };
673
+ const firstLayout = Object.values(masterRels).find((r) => /\/slideLayout$/.test(r.type))?.path;
674
+ const media = (master && pictureOf(master)) || (firstLayout && pictureOf(firstLayout));
675
+ if (media) {
676
+ const ext = /\.([a-z0-9]+)$/i.exec(media)?.[1]?.toLowerCase() ?? "";
677
+ const type = LOGO_TYPES[ext];
678
+ // Only a picture under the logo limit is unpacked (its declared size; fflate inflates into exactly that).
679
+ let declared = 0;
680
+ const got = type ? unzipSync(bytes, { filter: (f) => f.name === media && (declared = f.originalSize) <= MAX_LOGO_BYTES })[media] : null;
681
+ if (!type) notes.push(`The logo in the file is a .${ext} picture, which a deck cannot show: add it as PNG or SVG.`);
682
+ else if (declared > MAX_LOGO_BYTES) notes.push(`The logo in the file is ${Math.ceil(declared / 1024)} KB, over the 2 MB limit: add a smaller one.`);
683
+ else if (!got) notes.push("No logo was found in the file: add one.");
684
+ else if (got.byteLength > MAX_LOGO_BYTES) notes.push(`The logo in the file is ${Math.ceil(got.byteLength / 1024)} KB, over the 2 MB limit: add a smaller one.`);
685
+ else logo = { bytes: got, type, via: "slide master" };
686
+ if (!logo) details.push(`slide master picture ${media}`);
687
+ } else notes.push("No logo was found in the file: add one.");
688
+ const company = files["docProps/app.xml"] && /<Company>([^<]+)<\/Company>/.exec(xmlText(files["docProps/app.xml"]))?.[1]?.trim();
689
+ return { name: company ? decodeEntities(company).slice(0, 60) : undefined, colors, fonts, logo, logoCandidates: [], notes, details, source: { kind: "pptx", ref: fileName } };
690
+ }
691
+
692
+ // ---- PDF -------------------------------------------------------------------
693
+
694
+ const latin1 = (bytes) => {
695
+ let s = "";
696
+ for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
697
+ return s;
698
+ };
699
+ const toBytes = (s) => {
700
+ const out = new Uint8Array(s.length);
701
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 0xff;
702
+ return out;
703
+ };
704
+ // Inflate (zlib, or raw deflate) at most `cap` bytes: a stream that would
705
+ // grow past it is cut off there, so a PDF cannot balloon in memory. Null when
706
+ // the data does not inflate.
707
+ function inflateCapped(raw, cap, zlib) {
708
+ const chunks = [];
709
+ let size = 0;
710
+ let full = false;
711
+ const sink = (chunk) => {
712
+ if (full) return;
713
+ const room = cap - size;
714
+ if (chunk.length >= room) {
715
+ chunks.push(chunk.subarray(0, Math.max(room, 0)));
716
+ size = cap;
717
+ full = true;
718
+ throw new RangeError("cap");
719
+ }
720
+ chunks.push(chunk);
721
+ size += chunk.length;
722
+ };
723
+ const z = zlib ? new Unzlib(sink) : new Inflate(sink);
724
+ try {
725
+ // Small slices: fflate hands over all a slice inflates to at once, and deflate
726
+ // grows at most ~1032x, so one slice never holds more than about 1 MB.
727
+ for (let i = 0; i < raw.length && !full; i += 1024) z.push(raw.subarray(i, i + 1024), i + 1024 >= raw.length);
728
+ } catch {
729
+ if (!full) return size ? concat(chunks, size) : null;
730
+ }
731
+ return concat(chunks, size);
732
+ }
733
+ const concat = (chunks, size) => {
734
+ const out = new Uint8Array(size);
735
+ let at = 0;
736
+ for (const c of chunks) {
737
+ out.set(c, at);
738
+ at += c.length;
739
+ }
740
+ return out;
741
+ };
742
+
743
+ const HEAVY = /bold|black|heavy|semibold|demibold|extrabold|ultrabold|medium/i;
744
+
745
+ // A PDF font's BaseFont as a family: "ABCDEF+PlayfairDisplay-Bold" ->
746
+ // { family: "Playfair Display", heavy: true }.
747
+ export function pdfFontFamily(baseFont) {
748
+ let n = baseFont.replace(/#([0-9a-f]{2})/gi, (_, h) => String.fromCharCode(parseInt(h, 16))).replace(/^[A-Z]{6}\+/, "");
749
+ const [fam, ...style] = n.split(/[-,]/);
750
+ const styleText = style.join(" ");
751
+ n = fam.replace(/(PSMT|MT|PS)$/, "").replace(/_/g, " ");
752
+ // "PlayfairDisplay" -> "Playfair Display", "IBMPlexSans" -> "IBM Plex Sans", "SourceSans3" -> "Source Sans 3".
753
+ 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();
754
+ // A weight folded into the family name ("Inter SemiBold").
755
+ const folded = /\s(Thin|Light|Regular|Medium|Semi ?Bold|Bold|Extra ?Bold|Black|Heavy|Italic)$/i.exec(n);
756
+ if (folded) n = n.slice(0, folded.index);
757
+ return { family: n, heavy: HEAVY.test(styleText) || (!!folded && HEAVY.test(folded[1])) };
758
+ }
759
+
760
+ // A PDF's embedded fonts and the colours its first pages paint with. Content
761
+ // streams and object streams are inflated (FlateDecode); images and font
762
+ // programs are skipped.
763
+ export function extractPdf(bytes, fileName = "document.pdf") {
764
+ const text = latin1(bytes.subarray(0, Math.min(bytes.length, MAX_IMPORT_FILE_BYTES)));
765
+ if (!text.startsWith("%PDF")) throw new Error(`${fileName} is not a PDF`);
766
+ if (/\/Encrypt\b/.test(text)) throw new Error(`${fileName} is encrypted, so its fonts and colours cannot be read; export an unprotected copy`);
767
+ const chunks = [text];
768
+ let inflated = 0;
769
+ let streams = 0;
770
+ let undecoded = 0; // compressed streams we could not read
771
+ const re = /stream\r?\n/g;
772
+ for (let m; (m = re.exec(text)) && streams < 600 && inflated < MAX_PDF_INFLATE; ) {
773
+ const dictStart = text.lastIndexOf("obj", m.index);
774
+ const dict = text.slice(dictStart, m.index);
775
+ const end = text.indexOf("endstream", m.index);
776
+ if (end < 0) break;
777
+ re.lastIndex = end;
778
+ if (/\/Subtype\s*\/Image|\/FontFile|\/Length1|\/Length2|\/ICCBased|\/N\s+[134]\b.*\/Alternate|\/Type\s*\/XRef|\/Type\s*\/Metadata/.test(dict)) continue;
779
+ if (/\/Filter\s*\[?\s*\/(DCTDecode|JPXDecode|CCITTFaxDecode|JBIG2Decode)/.test(dict)) continue;
780
+ let body = text.slice(m.index + m[0].length, end);
781
+ if (/\/FlateDecode/.test(dict)) {
782
+ const raw = toBytes(body);
783
+ const out = inflateCapped(raw, MAX_PDF_INFLATE - inflated, true) ?? inflateCapped(raw, MAX_PDF_INFLATE - inflated, false);
784
+ if (!out) {
785
+ undecoded++;
786
+ continue;
787
+ }
788
+ body = latin1(out);
789
+ } else if (/\/Filter/.test(dict)) {
790
+ undecoded++;
791
+ continue;
792
+ }
793
+ inflated += body.length;
794
+ streams++;
795
+ chunks.push(body);
796
+ }
797
+
798
+ // Fonts.
799
+ const families = new Map();
800
+ for (const chunk of chunks) {
801
+ for (const m of chunk.matchAll(/\/BaseFont\s*\/([^\s/[\]<>()]+)/g)) {
802
+ const { family, heavy } = pdfFontFamily(m[1]);
803
+ if (!family || /^(Symbol|ZapfDingbats|Wingdings)/i.test(family)) continue;
804
+ const f = families.get(family) ?? { heavy: 0, regular: 0 };
805
+ f[heavy ? "heavy" : "regular"]++;
806
+ families.set(family, f);
807
+ }
808
+ }
809
+ const isMono = (f) => /mono|code|courier|consol|menlo/i.test(f);
810
+ const ranked = [...families.entries()].filter(([f]) => !isMono(f));
811
+ const fonts = /** @type {Record<string, string>} */ ({});
812
+ const body = [...ranked].sort((a, b) => b[1].regular - a[1].regular || b[1].heavy - a[1].heavy)[0]?.[0];
813
+ const display = [...ranked].filter(([f]) => f !== body).sort((a, b) => b[1].heavy - a[1].heavy)[0];
814
+ if (body) fonts.body = body;
815
+ fonts.display = display && display[1].heavy > 0 ? display[0] : body;
816
+ const mono = [...families.keys()].find(isMono);
817
+ if (mono) fonts.mono = mono;
818
+ for (const k of Object.keys(fonts)) if (!fonts[k]) delete fonts[k];
819
+
820
+ // Colours: fill operators, and the big filled rectangles that are page backgrounds.
821
+ const fills = new Tally();
822
+ const backgrounds = new Tally();
823
+ const n = "(-?[\\d.]+)";
824
+ 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");
825
+ for (const chunk of chunks.slice(1)) {
826
+ for (const m of chunk.matchAll(ops)) {
827
+ let c;
828
+ if (m[5]) {
829
+ const [C, M, Y, K] = [m[1], m[2], m[3], m[4]].map(Number);
830
+ 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 };
831
+ } 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 };
832
+ else c = { r: Math.round(+m[10] * 255), g: Math.round(+m[10] * 255), b: Math.round(+m[10] * 255), a: 1 };
833
+ if ([c.r, c.g, c.b].some((x) => Number.isNaN(x) || x < 0 || x > 255)) continue;
834
+ const stroke = /^[A-Z]+$/.test(m[5] ?? m[9] ?? m[11]);
835
+ if (stroke) continue;
836
+ fills.add(c, 1);
837
+ // The next path op: a page-sized rectangle filled right away is the background.
838
+ const after = chunk.slice(m.index + m[0].length, m.index + m[0].length + 160);
839
+ const rect = /^\s*(?:[^\n]*\n)??\s*-?[\d.]+\s+-?[\d.]+\s+([\d.]+)\s+([\d.]+)\s+re\s+f\*?\b/.exec(after);
840
+ if (rect && +rect[1] * +rect[2] >= 300_000) backgrounds.add(c, 1);
841
+ }
842
+ }
843
+ const colors = /** @type {Record<string, string>} */ ({});
844
+ const paper = backgrounds.ranked((c) => hsl(c).s < 0.4)[0];
845
+ // A white page is assumed only when the pages were read and drew no background.
846
+ const painted = fills.ranked().length > 0;
847
+ if (paper) colors.paper = paper.hex;
848
+ else if (painted) colors.paper = "#ffffff";
849
+ const paperL = colors.paper ? hsl(parseColor(colors.paper)).l : 1;
850
+ const ink = fills.ranked((c) => Math.abs(hsl(c).l - paperL) > 0.5 && hsl(c).s < 0.5)[0];
851
+ if (ink) colors.ink = ink.hex;
852
+ const accents = fills.ranked(isAccentLike);
853
+ if (accents[0]) colors.accent = accents[0].hex;
854
+ const second = accents.find((x) => x.hex !== colors.accent && hueGap(x.color, accents[0].color) >= 30);
855
+ if (second) colors.accent2 = second.hex;
856
+
857
+ const notes = /** @type {string[]} */ ([]);
858
+ const details = [];
859
+ if (!families.size) notes.push("No fonts could be read from the file: pick them by hand.");
860
+ if (undecoded) details.push(`${undecoded} compressed stream${undecoded > 1 ? "s" : ""} could not be decoded`);
861
+ if (painted) notes.push(`Colours were picked from the first pages${paper ? "" : " (they draw no background, so it is white)"}: check them in the sample.`);
862
+ else notes.push("No colours could be read from the file: pick them by hand.");
863
+ notes.push("A PDF doesn't say which picture is the logo: add one.");
864
+ return { colors, fonts, logoCandidates: [], notes, details, source: { kind: "pdf", ref: fileName } };
865
+ }
866
+
867
+ // Whether a draft read anything worth saving: a colour (beyond the white page
868
+ // a PDF is assumed to have) or a font. Saving an empty one would wipe a brand.
869
+ export function foundAnything(x) {
870
+ const colors = Object.entries(x.colors ?? {}).filter(([k, v]) => !(x.source?.kind === "pdf" && k === "paper" && v === "#ffffff"));
871
+ return colors.length > 0 || Object.values(x.fonts ?? {}).some(Boolean);
872
+ }
873
+
874
+ // Which extractor a file needs, by its bytes (a zip or a PDF) and then its name.
875
+ export function fileKind(bytes, name = "") {
876
+ if (bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) return "pdf";
877
+ if (bytes[0] === 0x50 && bytes[1] === 0x4b) return "pptx";
878
+ if (/\.pdf$/i.test(name)) return "pdf";
879
+ if (/\.(pptx|potx)$/i.test(name)) return "pptx";
880
+ return null;
881
+ }
882
+
883
+ export function extractFile(bytes, name) {
884
+ const kind = fileKind(bytes, name);
885
+ if (kind === "pdf") return extractPdf(bytes, name || "document.pdf");
886
+ if (kind === "pptx") return extractPptx(bytes, name || "presentation.pptx");
887
+ throw new Error(`${name || "the file"} is not a .pptx, .potx or .pdf`);
888
+ }
889
+
890
+ // ---- fonts -----------------------------------------------------------------
891
+
892
+ // Families Google Fonts serves (the pairings' and the most used), for when the
893
+ // live check (fonts.googleapis.com) is unavailable. Lower-case keys.
894
+ const KNOWN_GOOGLE = new Map(
895
+ [
896
+ ...FONT_PAIRINGS.flatMap((p) => [p.display, p.body, p.mono]),
897
+ "Roboto", "Open Sans", "Lato", "Montserrat", "Poppins", "Oswald", "Raleway", "Nunito", "Rubik", "Work Sans", "Noto Sans", "Noto Serif",
898
+ "Merriweather", "Lora", "PT Sans", "PT Serif", "Ubuntu", "Mukta", "Karla", "Barlow", "Mulish", "Quicksand", "Josefin Sans", "Libre Franklin",
899
+ "Cabin", "Arimo", "Tinos", "Cousine", "Carlito", "Caladea", "Heebo", "Hind", "Figtree", "Onest", "Instrument Sans", "Instrument Serif",
900
+ "Lexend", "Urbanist", "Red Hat Display", "Red Hat Text", "Albert Sans", "Be Vietnam Pro", "Epilogue", "Chivo", "Cormorant Garamond",
901
+ "EB Garamond", "Crimson Text", "Crimson Pro", "Spectral", "Libre Caslon Text", "DM Serif Display", "Bodoni Moda", "Abril Fatface",
902
+ "Bebas Neue", "Anton", "Archivo Black", "Righteous", "Comfortaa", "Varela Round", "Fira Sans", "Source Serif 4", "Source Sans 3",
903
+ "Roboto Slab", "Roboto Condensed", "Roboto Flex", "Inter Tight", "Space Grotesk", "Manrope", "Sora", "Outfit", "Syne", "Unbounded",
904
+ "Geist", "Geist Mono", "JetBrains Mono", "Fira Code", "IBM Plex Serif", "Noto Sans Mono", "Ubuntu Mono", "Inconsolata", "Overpass",
905
+ "Overpass Mono", "Questrial", "Assistant", "Exo 2", "Titillium Web", "Kanit", "Prompt", "Zilla Slab", "Bitter", "Arvo", "Domine",
906
+ "Libre Bodoni", "Gloock", "Young Serif", "Literata", "Atkinson Hyperlegible", "Golos Text", "Wix Madefor Display", "Wix Madefor Text",
907
+ "Funnel Display", "Funnel Sans", "Host Grotesk", "Parkinsans", "Afacad", "Gabarito", "Hanken Grotesk", "Readex Pro",
908
+ ].map((f) => [f.toLowerCase(), f]),
909
+ );
910
+
911
+ // The look of a family Google Fonts does not serve, for the closest pairing.
912
+ export function fontClass(family) {
913
+ const f = String(family).toLowerCase();
914
+ if (/mono|code|courier|consol|menlo|monaco/.test(f)) return "mono";
915
+ 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";
916
+ 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";
917
+ if (/rounded|nunito|quicksand|varela|comfortaa/.test(f)) return "friendly";
918
+ return "grotesk";
919
+ }
920
+ const CLASS_PAIRING = { serif: "editorial", geometric: "geometric", friendly: "friendly", grotesk: "inter", mono: "inter" };
921
+ // A serif body reads best as the literary pairing's body.
922
+ const BODY_PAIRING = { serif: "literary" };
923
+
924
+ const ROLE_WORDS = { display: "titles", body: "body text", mono: "labels" };
925
+
926
+ // The draft's fonts as Google Fonts families. A family Google serves is kept
927
+ // (with Google's spelling); any other maps to the closest pairing's family for
928
+ // that role, with a note saying so. isGoogleFont(family) -> boolean | Promise.
929
+ export async function resolveFonts(fonts = {}, { isGoogleFont } = {}) {
930
+ const out = {};
931
+ const notes = /** @type {string[]} */ ([]);
932
+ const known = async (family) => {
933
+ const hit = KNOWN_GOOGLE.get(family.toLowerCase());
934
+ if (hit) return hit;
935
+ // Google family names are letters, digits and spaces; nothing else is worth asking about.
936
+ if (!isGoogleFont || !/^[A-Za-z0-9]+(?: [A-Za-z0-9]+)*$/.test(family) || family.length > 60) return null;
937
+ try {
938
+ return (await isGoogleFont(family)) ? family : null;
939
+ } catch (e) {
940
+ // Could not ask: keep the family rather than swap it on a guess.
941
+ console.warn("[brand] Google Fonts check failed", family, e?.message ?? e);
942
+ unconfirmed.push(family);
943
+ return family;
944
+ }
945
+ };
946
+ const unconfirmed = [];
947
+ let pairing = null;
948
+ for (const role of ["display", "body", "mono"]) {
949
+ const family = fonts[role] ? normalizeFamily(fonts[role]) : null;
950
+ if (!family) continue;
951
+ const google = await known(family);
952
+ if (google) {
953
+ out[role] = google;
954
+ continue;
955
+ }
956
+ const cls = role === "mono" ? "mono" : fontClass(family);
957
+ const id = (role === "body" && BODY_PAIRING[cls]) || CLASS_PAIRING[cls];
958
+ const p = FONT_PAIRINGS.find((x) => x.id === id);
959
+ pairing ??= p;
960
+ out[role] = p[role];
961
+ notes.push(`${family} isn't a Google Font, so ${ROLE_WORDS[role]} use${role === "body" ? "s" : ""} ${p[role]}, the closest match.`);
962
+ }
963
+ if (!out.mono) {
964
+ const p =
965
+ pairing ??
966
+ FONT_PAIRINGS.find((x) => x.display === out.display && x.body === out.body) ??
967
+ FONT_PAIRINGS.find((x) => x.display === out.display) ??
968
+ FONT_PAIRINGS.find((x) => x.body === out.body) ??
969
+ FONT_PAIRINGS.find((x) => x.id === "inter");
970
+ out.mono = p.mono;
971
+ notes.push(`No label font was found, so labels use ${p.mono}.`);
972
+ }
973
+ for (const f of new Set(unconfirmed)) notes.push(`Couldn't check that ${f} is a Google Font, so it is kept: if the sample shows a fallback font, pick another.`);
974
+ // A role the source left empty takes the other's family.
975
+ if (!out.display && out.body) out.display = out.body;
976
+ if (!out.body && out.display) out.body = out.display;
977
+ return { fonts: out, notes };
978
+ }
979
+
980
+ // A draft read from a file as brandNode.createDraft's arguments (no undefined
981
+ // fields: Convex refuses them).
982
+ export function draftArgs(x) {
983
+ const args = { source: x.source, colors: x.colors, fonts: x.fonts, notes: x.notes };
984
+ if (x.details?.length) args.details = x.details;
985
+ if (x.name) args.name = x.name;
986
+ if (x.logo?.bytes) {
987
+ args.logoBytes = x.logo.bytes.slice().buffer;
988
+ args.logoType = x.logo.type;
989
+ }
990
+ return args;
991
+ }