pptx-browser 4.1.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/src/fonts.js ADDED
@@ -0,0 +1,676 @@
1
+ /**
2
+ * fonts.js — Font resolution, loading, and custom registration.
3
+ *
4
+ * Priority order for font resolution (highest first):
5
+ * 1. User-registered custom fonts (registerFont)
6
+ * 2. Fonts embedded in the PPTX file (extractEmbeddedFonts)
7
+ * 3. Already-loaded system/document fonts (checked via FontFaceSet)
8
+ * 4. MS Office → Google Fonts mapping (loaded on demand)
9
+ * 5. Direct Google Fonts attempt for unknown fonts
10
+ * 6. Generic family fallback (sans-serif / serif / monospace)
11
+ */
12
+
13
+ import { g1 } from './utils.js';
14
+
15
+ // ── MS Office → Google Fonts mapping ────────────────────────────────────────
16
+ // key = lowercase MS font name (exact match)
17
+ // value = { google: string|null, weights: number[], generic: string }
18
+ // google: null → web-safe, use as-is (no loading needed)
19
+ // string → Google Fonts family name to load instead
20
+ // generic: fallback CSS generic family
21
+
22
+ const MS_FONT_MAP = {
23
+ // ── Calibri family (metric-compatible substitutes exist) ──────────────────
24
+ 'calibri': { google: 'Carlito', weights: [400,700], generic: 'sans-serif' },
25
+ 'calibri light': { google: 'Carlito', weights: [300], generic: 'sans-serif' },
26
+ 'calibri (body)': { google: 'Carlito', weights: [400,700], generic: 'sans-serif' },
27
+
28
+ // ── Cambria (metric-compatible) ───────────────────────────────────────────
29
+ 'cambria': { google: 'Caladea', weights: [400,700], generic: 'serif' },
30
+ 'cambria math': { google: 'Caladea', weights: [400], generic: 'serif' },
31
+
32
+ // ── Aptos — Microsoft 365 default since 2023 ─────────────────────────────
33
+ 'aptos': { google: 'Inter', weights: [300,400,600,700], generic: 'sans-serif' },
34
+ 'aptos display': { google: 'Inter', weights: [700,800], generic: 'sans-serif' },
35
+ 'aptos narrow': { google: 'Inter', weights: [400,700], generic: 'sans-serif' },
36
+ 'aptos serif': { google: 'Lora', weights: [400,700], generic: 'serif' },
37
+ 'aptos mono': { google: 'Roboto Mono', weights: [400,700], generic: 'monospace' },
38
+
39
+ // ── Web-safe fonts — available in all browsers, no loading needed ─────────
40
+ 'arial': { google: null, weights: [], generic: 'sans-serif' },
41
+ 'arial black': { google: null, weights: [], generic: 'sans-serif' },
42
+ 'times new roman': { google: null, weights: [], generic: 'serif' },
43
+ 'times': { google: null, weights: [], generic: 'serif' },
44
+ 'helvetica': { google: null, weights: [], generic: 'sans-serif' },
45
+ 'verdana': { google: null, weights: [], generic: 'sans-serif' },
46
+ 'tahoma': { google: null, weights: [], generic: 'sans-serif' },
47
+ 'trebuchet ms': { google: null, weights: [], generic: 'sans-serif' },
48
+ 'georgia': { google: null, weights: [], generic: 'serif' },
49
+ 'courier new': { google: null, weights: [], generic: 'monospace' },
50
+ 'courier': { google: null, weights: [], generic: 'monospace' },
51
+ 'impact': { google: null, weights: [], generic: 'sans-serif' },
52
+ 'comic sans ms': { google: null, weights: [], generic: 'cursive' },
53
+ 'palatino': { google: null, weights: [], generic: 'serif' },
54
+ 'lucida console': { google: null, weights: [], generic: 'monospace' },
55
+ 'lucida sans unicode': { google: null, weights: [], generic: 'sans-serif' },
56
+
57
+ // ── Common Office fonts ───────────────────────────────────────────────────
58
+ 'arial narrow': { google: 'Arimo', weights: [400,700], generic: 'sans-serif' },
59
+ 'candara': { google: 'Nunito', weights: [300,400,700], generic: 'sans-serif' },
60
+ 'consolas': { google: 'Roboto Mono', weights: [400,700], generic: 'monospace' },
61
+ 'constantia': { google: 'Libre Baskerville', weights: [400,700], generic: 'serif' },
62
+ 'corbel': { google: 'Lato', weights: [300,400,700], generic: 'sans-serif' },
63
+ 'franklin gothic medium': { google: 'Libre Franklin', weights: [500], generic: 'sans-serif' },
64
+ 'franklin gothic book': { google: 'Libre Franklin', weights: [400], generic: 'sans-serif' },
65
+ 'franklin gothic heavy': { google: 'Libre Franklin', weights: [800], generic: 'sans-serif' },
66
+ 'gill sans mt': { google: 'Quattrocento Sans', weights: [400,700], generic: 'sans-serif' },
67
+ 'gill sans': { google: 'Quattrocento Sans', weights: [400,700], generic: 'sans-serif' },
68
+ 'century gothic': { google: 'Josefin Sans', weights: [300,400,700], generic: 'sans-serif' },
69
+ 'century schoolbook': { google: 'EB Garamond', weights: [400,700], generic: 'serif' },
70
+ 'garamond': { google: 'EB Garamond', weights: [400,700], generic: 'serif' },
71
+ 'palatino linotype': { google: 'EB Garamond', weights: [400,700], generic: 'serif' },
72
+ 'book antiqua': { google: 'EB Garamond', weights: [400,700], generic: 'serif' },
73
+ 'rockwell': { google: 'Roboto Slab', weights: [400,700], generic: 'serif' },
74
+ 'rockwell extra bold': { google: 'Roboto Slab', weights: [800], generic: 'serif' },
75
+ 'segoe ui': { google: 'Inter', weights: [300,400,600,700], generic: 'sans-serif' },
76
+ 'segoe ui light': { google: 'Inter', weights: [300], generic: 'sans-serif' },
77
+ 'segoe ui semibold': { google: 'Inter', weights: [600], generic: 'sans-serif' },
78
+ 'segoe ui semilight': { google: 'Inter', weights: [350], generic: 'sans-serif' },
79
+ 'helvetica neue': { google: 'Nunito Sans', weights: [300,400,700], generic: 'sans-serif' },
80
+ 'myriad pro': { google: 'Source Sans 3', weights: [300,400,600,700], generic: 'sans-serif' },
81
+ 'futura': { google: 'Josefin Sans', weights: [300,400,700], generic: 'sans-serif' },
82
+ 'tw cen mt': { google: 'Pathway Gothic One', weights: [400], generic: 'sans-serif' },
83
+ 'bookman old style': { google: 'Libre Baskerville', weights: [400,700], generic: 'serif' },
84
+ 'frutiger': { google: 'Raleway', weights: [300,400,700], generic: 'sans-serif' },
85
+ 'optima': { google: 'Questrial', weights: [400], generic: 'sans-serif' },
86
+ 'univers': { google: 'Nunito Sans', weights: [300,400,700], generic: 'sans-serif' },
87
+
88
+ // ── Google Fonts already at their canonical names ─────────────────────────
89
+ 'open sans': { google: 'Open Sans', weights: [300,400,600,700], generic: 'sans-serif' },
90
+ 'lato': { google: 'Lato', weights: [300,400,700], generic: 'sans-serif' },
91
+ 'montserrat': { google: 'Montserrat', weights: [300,400,600,700], generic: 'sans-serif' },
92
+ 'raleway': { google: 'Raleway', weights: [300,400,700], generic: 'sans-serif' },
93
+ 'roboto': { google: 'Roboto', weights: [300,400,700], generic: 'sans-serif' },
94
+ 'roboto mono': { google: 'Roboto Mono', weights: [400,700], generic: 'monospace' },
95
+ 'roboto slab': { google: 'Roboto Slab', weights: [300,400,700], generic: 'serif' },
96
+ 'oswald': { google: 'Oswald', weights: [400,700], generic: 'sans-serif' },
97
+ 'playfair display': { google: 'Playfair Display', weights: [400,700], generic: 'serif' },
98
+ 'merriweather': { google: 'Merriweather', weights: [300,400,700], generic: 'serif' },
99
+ 'nunito': { google: 'Nunito', weights: [300,400,700], generic: 'sans-serif' },
100
+ 'nunito sans': { google: 'Nunito Sans', weights: [300,400,700], generic: 'sans-serif' },
101
+ 'poppins': { google: 'Poppins', weights: [300,400,600,700], generic: 'sans-serif' },
102
+ 'inter': { google: 'Inter', weights: [300,400,600,700], generic: 'sans-serif' },
103
+ 'work sans': { google: 'Work Sans', weights: [300,400,600,700], generic: 'sans-serif' },
104
+ 'dm sans': { google: 'DM Sans', weights: [300,400,500,700], generic: 'sans-serif' },
105
+ 'dm serif display': { google: 'DM Serif Display', weights: [400], generic: 'serif' },
106
+ 'ubuntu': { google: 'Ubuntu', weights: [300,400,700], generic: 'sans-serif' },
107
+ 'ubuntu mono': { google: 'Ubuntu Mono', weights: [400,700], generic: 'monospace' },
108
+ 'source sans pro': { google: 'Source Sans 3', weights: [300,400,600,700], generic: 'sans-serif' },
109
+ 'source serif pro': { google: 'Source Serif 4', weights: [300,400,700], generic: 'serif' },
110
+ 'source code pro': { google: 'Source Code Pro', weights: [400,700], generic: 'monospace' },
111
+ 'exo 2': { google: 'Exo 2', weights: [300,400,700], generic: 'sans-serif' },
112
+ 'titillium web': { google: 'Titillium Web', weights: [300,400,600,700], generic: 'sans-serif' },
113
+ 'fira sans': { google: 'Fira Sans', weights: [300,400,600,700], generic: 'sans-serif' },
114
+ 'fira mono': { google: 'Fira Mono', weights: [400,700], generic: 'monospace' },
115
+ 'josefin sans': { google: 'Josefin Sans', weights: [300,400,700], generic: 'sans-serif' },
116
+ 'josefin slab': { google: 'Josefin Slab', weights: [300,400,700], generic: 'serif' },
117
+ 'barlow': { google: 'Barlow', weights: [300,400,600,700], generic: 'sans-serif' },
118
+ 'barlow condensed': { google: 'Barlow Condensed', weights: [300,400,600,700], generic: 'sans-serif' },
119
+ 'cabin': { google: 'Cabin', weights: [400,700], generic: 'sans-serif' },
120
+ 'crimson text': { google: 'Crimson Text', weights: [400,700], generic: 'serif' },
121
+ 'libre baskerville': { google: 'Libre Baskerville', weights: [400,700], generic: 'serif' },
122
+ 'libre franklin': { google: 'Libre Franklin', weights: [400,700], generic: 'sans-serif' },
123
+ 'eb garamond': { google: 'EB Garamond', weights: [400,700], generic: 'serif' },
124
+ 'spectral': { google: 'Spectral', weights: [300,400,700], generic: 'serif' },
125
+ 'arvo': { google: 'Arvo', weights: [400,700], generic: 'serif' },
126
+ 'pt sans': { google: 'PT Sans', weights: [400,700], generic: 'sans-serif' },
127
+ 'pt serif': { google: 'PT Serif', weights: [400,700], generic: 'serif' },
128
+ 'pt mono': { google: 'PT Mono', weights: [400], generic: 'monospace' },
129
+ 'karla': { google: 'Karla', weights: [300,400,700], generic: 'sans-serif' },
130
+ 'mukta': { google: 'Mukta', weights: [300,400,700], generic: 'sans-serif' },
131
+ 'hind': { google: 'Hind', weights: [300,400,700], generic: 'sans-serif' },
132
+ 'noto sans': { google: 'Noto Sans', weights: [300,400,700], generic: 'sans-serif' },
133
+ 'noto serif': { google: 'Noto Serif', weights: [300,400,700], generic: 'serif' },
134
+ };
135
+
136
+ // ── Internal state ──────────────────────────────────────────────────────────
137
+
138
+ /** Set of font family names already loaded (prevents duplicate requests). */
139
+ const _loadedFonts = new Set();
140
+
141
+ /** User-registered custom fonts: family name → array of FontFace objects. */
142
+ const _customFonts = new Map();
143
+
144
+ /** Fonts confirmed available on this system via font-check. */
145
+ const _systemFonts = new Set();
146
+
147
+ // ── Canvas font-availability probe ──────────────────────────────────────────
148
+
149
+ /** Shared measurement canvas — created once and reused. */
150
+ let _probeCanvas = null;
151
+ let _probeCtx = null;
152
+
153
+ function getProbeCtx() {
154
+ if (!_probeCtx) {
155
+ _probeCanvas = typeof OffscreenCanvas !== 'undefined'
156
+ ? new OffscreenCanvas(300, 10)
157
+ : Object.assign(document.createElement('canvas'), { width: 300, height: 10 });
158
+ _probeCtx = _probeCanvas.getContext('2d');
159
+ }
160
+ return _probeCtx;
161
+ }
162
+
163
+ const PROBE_TEXT = 'mmmmmmmmmmlllllllllliiiiiiiiiixxxxxxxxxx';
164
+ const PROBE_SIZES = [20, 40]; // measure at 2 sizes to avoid coincidental matches
165
+
166
+ /**
167
+ * Check whether a font family is available in this browser/system.
168
+ * Uses a double-size measurement trick: if the font metrics differ from both
169
+ * monospace and serif, the font is available.
170
+ *
171
+ * Returns true if the font appears to be present.
172
+ */
173
+ export function isFontAvailable(family) {
174
+ if (_systemFonts.has(family)) return true;
175
+
176
+ // If FontFaceSet has it, it's definitely loaded
177
+ if (typeof document !== 'undefined' && document.fonts) {
178
+ if (document.fonts.check(`16px "${family}"`)) {
179
+ _systemFonts.add(family);
180
+ return true;
181
+ }
182
+ }
183
+
184
+ // Fallback: canvas measurement trick (compare against two baseline fonts)
185
+ try {
186
+ const ctx = getProbeCtx();
187
+ const baselines = ['monospace', 'serif'];
188
+
189
+ for (const size of PROBE_SIZES) {
190
+ for (const baseline of baselines) {
191
+ ctx.font = `${size}px ${baseline}`;
192
+ const baseW = ctx.measureText(PROBE_TEXT).width;
193
+ ctx.font = `${size}px "${family}", ${baseline}`;
194
+ const testW = ctx.measureText(PROBE_TEXT).width;
195
+ if (Math.abs(testW - baseW) > 0.5) {
196
+ _systemFonts.add(family);
197
+ return true;
198
+ }
199
+ }
200
+ }
201
+ } catch (_) {}
202
+
203
+ return false;
204
+ }
205
+
206
+ // ── Custom font registration ─────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Register a custom font so the renderer uses it for matching typeface names
210
+ * in the PPTX. You can register the same family multiple times with different
211
+ * descriptors to provide bold/italic variants.
212
+ *
213
+ * @param {string} family
214
+ * The exact font family name as used in the PPTX (e.g. "Brand Sans")
215
+ * OR the MS Office name it should replace (e.g. "Calibri")
216
+ * @param {string | URL | File | ArrayBuffer | Uint8Array} source
217
+ * Where to load the font from:
218
+ * - string / URL → a URL (https:// or data:)
219
+ * - File → a File object from <input type="file">
220
+ * - ArrayBuffer / Uint8Array → raw font bytes (ttf / woff / woff2 / otf)
221
+ * @param {FontFaceDescriptors} [descriptors]
222
+ * Optional FontFace descriptors: { weight, style, unicodeRange, … }
223
+ * Defaults to { weight: 'normal', style: 'normal' }
224
+ * @returns {Promise<FontFace>} the registered FontFace
225
+ *
226
+ * @example
227
+ * // From a URL
228
+ * await renderer.registerFont('Brand Sans', '/fonts/brand-sans.woff2');
229
+ *
230
+ * // Bold variant
231
+ * await renderer.registerFont('Brand Sans', '/fonts/brand-sans-bold.woff2', { weight: '700' });
232
+ *
233
+ * // From a File input
234
+ * const [file] = e.target.files;
235
+ * await renderer.registerFont('Brand Sans', file);
236
+ *
237
+ * // Override Calibri globally
238
+ * await renderer.registerFont('Calibri', '/fonts/my-calibri.woff2');
239
+ */
240
+ export async function registerFont(family, source, descriptors = {}) {
241
+ let fontSource;
242
+
243
+ if (typeof source === 'string' || source instanceof URL) {
244
+ fontSource = source.toString();
245
+ } else if (source instanceof File || source instanceof Blob) {
246
+ const buf = await source.arrayBuffer();
247
+ fontSource = buf;
248
+ } else if (source instanceof Uint8Array) {
249
+ fontSource = source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
250
+ } else if (source instanceof ArrayBuffer) {
251
+ fontSource = source;
252
+ } else {
253
+ throw new TypeError(`registerFont: unsupported source type. Expected string, URL, File, Blob, ArrayBuffer, or Uint8Array.`);
254
+ }
255
+
256
+ const desc = { weight: 'normal', style: 'normal', ...descriptors };
257
+ const face = new FontFace(family, typeof fontSource === 'string' ? `url(${fontSource})` : fontSource, desc);
258
+
259
+ await face.load();
260
+ document.fonts.add(face);
261
+
262
+ // Track in our registry
263
+ const variants = _customFonts.get(family) ?? [];
264
+ variants.push(face);
265
+ _customFonts.set(family, variants);
266
+
267
+ // Mark as loaded so we skip the Google Fonts request for this name
268
+ _loadedFonts.add(family);
269
+ _systemFonts.add(family);
270
+
271
+ // Also register under the lowercase key so resolveFontFamily finds it
272
+ const lower = family.toLowerCase().trim();
273
+ if (!MS_FONT_MAP[lower]) {
274
+ MS_FONT_MAP[lower] = { google: null, weights: [], generic: 'sans-serif', _custom: true };
275
+ } else {
276
+ MS_FONT_MAP[lower]._custom = true;
277
+ }
278
+
279
+ console.log(`[pptx-canvas-renderer] Custom font registered: "${family}" (${desc.weight} ${desc.style})`);
280
+ return face;
281
+ }
282
+
283
+ /**
284
+ * Register multiple fonts at once from an object map.
285
+ * @param {Record<string, string | { url: string, weight?: string, style?: string }[]>} fontMap
286
+ *
287
+ * @example
288
+ * await renderer.registerFonts({
289
+ * 'Brand Sans': '/fonts/brand-sans.woff2',
290
+ * 'Brand Serif': [
291
+ * { url: '/fonts/brand-serif.woff2', weight: '400' },
292
+ * { url: '/fonts/brand-serif-bold.woff2', weight: '700' },
293
+ * ]
294
+ * });
295
+ */
296
+ export async function registerFonts(fontMap) {
297
+ const promises = [];
298
+ for (const [family, spec] of Object.entries(fontMap)) {
299
+ if (typeof spec === 'string') {
300
+ promises.push(registerFont(family, spec));
301
+ } else if (Array.isArray(spec)) {
302
+ for (const variant of spec) {
303
+ const { url, ...desc } = variant;
304
+ promises.push(registerFont(family, url, desc));
305
+ }
306
+ }
307
+ }
308
+ await Promise.all(promises);
309
+ }
310
+
311
+ // ── Embedded PPTX fonts ──────────────────────────────────────────────────────
312
+
313
+ /**
314
+ * Detect fonts embedded in the PPTX file (stored in ppt/fonts/).
315
+ *
316
+ * PPTX embeds fonts as .fntdata files — a proprietary Microsoft format that
317
+ * cannot be used directly as a web font. This function detects which fonts
318
+ * are embedded and returns diagnostic info so the renderer can decide what to do.
319
+ *
320
+ * @param {Document} presDoc — parsed presentation.xml
321
+ * @param {object} presRels — relationship map from getRels()
322
+ * @returns {EmbeddedFontInfo[]}
323
+ *
324
+ * @typedef {object} EmbeddedFontInfo
325
+ * @property {string} family — font family name
326
+ * @property {boolean} hasRegular
327
+ * @property {boolean} hasBold
328
+ * @property {boolean} hasItalic
329
+ * @property {boolean} hasBoldItalic
330
+ * @property {string[]} paths — ZIP paths to .fntdata files (not directly usable as web fonts)
331
+ */
332
+ export function detectEmbeddedFonts(presDoc, presRels) {
333
+ if (!presDoc) return [];
334
+ const embeddedFontLst = g1(presDoc, 'embeddedFontLst');
335
+ if (!embeddedFontLst) return [];
336
+
337
+ const result = [];
338
+
339
+ for (const embeddedFont of embeddedFontLst.children) {
340
+ if (embeddedFont.localName !== 'embeddedFont') continue;
341
+
342
+ const fontEl = g1(embeddedFont, 'font');
343
+ const family = fontEl ? fontEl.getAttribute('typeface') : null;
344
+ if (!family) continue;
345
+
346
+ const variants = { regular: false, bold: false, italic: false, boldItalic: false };
347
+ const paths = [];
348
+
349
+ for (const variant of ['regular', 'bold', 'italic', 'boldItalic']) {
350
+ const el = g1(embeddedFont, variant);
351
+ if (!el) continue;
352
+ const rId = el.getAttribute('r:id') || el.getAttribute('id');
353
+ const rel = presRels?.[rId];
354
+ if (rel) {
355
+ paths.push(rel.fullPath);
356
+ variants[variant] = true;
357
+ }
358
+ }
359
+
360
+ result.push({
361
+ family,
362
+ hasRegular: variants.regular,
363
+ hasBold: variants.bold,
364
+ hasItalic: variants.italic,
365
+ hasBoldItalic: variants.boldItalic,
366
+ paths,
367
+ note: '.fntdata files are a proprietary Microsoft format and cannot be used as web fonts directly. Use registerFont() with a compatible woff2/ttf version instead.',
368
+ });
369
+ }
370
+
371
+ return result;
372
+ }
373
+
374
+ // ── Font resolution ──────────────────────────────────────────────────────────
375
+
376
+ /**
377
+ * Resolve a font name to its best available web equivalent.
378
+ *
379
+ * Priority:
380
+ * 1. Custom registered font (user-provided or embedded)
381
+ * 2. Already available in document.fonts / system
382
+ * 3. MS→Google mapping
383
+ * 4. Same name (hope it's available on the system)
384
+ *
385
+ * Returns the final CSS font family name to use.
386
+ */
387
+ export function resolveFontFamily(name) {
388
+ if (!name) return 'sans-serif';
389
+
390
+ // Theme font tokens — resolved at call site via themeData
391
+ if (name === '+mj-lt' || name === '+mj') return 'serif';
392
+ if (name === '+mn-lt' || name === '+mn') return 'sans-serif';
393
+
394
+ // Custom registered font takes top priority
395
+ if (_customFonts.has(name)) return name;
396
+
397
+ const lower = name.toLowerCase().trim();
398
+ const mapped = MS_FONT_MAP[lower];
399
+
400
+ if (mapped) {
401
+ // Custom override for a known MS font name
402
+ if (mapped._custom) return name;
403
+ // Web-safe — use as-is
404
+ if (mapped.google === null) return name;
405
+ // Check if the substitute is available (may already be on the system)
406
+ if (mapped.google && isFontAvailable(mapped.google)) return mapped.google;
407
+ // Return the mapped name (it will be loaded by loadGoogleFontsFor)
408
+ return mapped.google || name;
409
+ }
410
+
411
+ // Unknown font — return as-is; may be available on the system
412
+ return name;
413
+ }
414
+
415
+ /**
416
+ * Get the generic family fallback for a font name.
417
+ * Used to build robust CSS font stacks.
418
+ */
419
+ export function getGenericFamily(name) {
420
+ if (!name) return 'sans-serif';
421
+ const lower = name.toLowerCase().trim();
422
+ return MS_FONT_MAP[lower]?.generic ?? 'sans-serif';
423
+ }
424
+
425
+ // ── Google Fonts loader ──────────────────────────────────────────────────────
426
+
427
+ /**
428
+ * Load Google Fonts substitutes for any MS Office fonts used in the slide.
429
+ * Skips fonts that are:
430
+ * - Already loaded (tracked in _loadedFonts)
431
+ * - Web-safe (google: null)
432
+ * - User-registered custom fonts
433
+ * - Already available on the system
434
+ *
435
+ * @param {Set<string>} fontNames — raw font names from the PPTX
436
+ * @param {object} themeData — { majorFont, minorFont }
437
+ */
438
+ export async function loadGoogleFontsFor(fontNames, themeData) {
439
+ // Collect candidates (font names + theme fonts)
440
+ const candidates = [...fontNames];
441
+ if (themeData?.majorFont) candidates.push(themeData.majorFont);
442
+ if (themeData?.minorFont) candidates.push(themeData.minorFont);
443
+
444
+ // Map: googleFamilyName → Set<weight>
445
+ const toLoad = new Map();
446
+
447
+ for (const name of candidates) {
448
+ if (!name || name.startsWith('+')) continue;
449
+
450
+ // Skip theme tokens
451
+ if (name === '+mj-lt' || name === '+mn-lt' || name === '+mj' || name === '+mn') continue;
452
+
453
+ // Skip custom-registered fonts
454
+ if (_customFonts.has(name)) continue;
455
+
456
+ const lower = name.toLowerCase().trim();
457
+ const mapped = MS_FONT_MAP[lower];
458
+
459
+ let googleName = null;
460
+ let weights = [400, 700];
461
+
462
+ if (mapped) {
463
+ if (mapped._custom) continue; // user-registered override
464
+ if (mapped.google === null) continue; // web-safe
465
+ googleName = mapped.google;
466
+ weights = mapped.weights.length ? mapped.weights : [400, 700];
467
+ } else {
468
+ // Unknown font — try to load it directly from Google Fonts
469
+ // (works for fonts like "Pacifico", "Dancing Script", etc.)
470
+ googleName = name;
471
+ }
472
+
473
+ if (!googleName) continue;
474
+
475
+ // Skip if already loaded or confirmed system-available
476
+ if (_loadedFonts.has(googleName)) continue;
477
+ if (isFontAvailable(googleName)) {
478
+ _loadedFonts.add(googleName);
479
+ continue;
480
+ }
481
+
482
+ const ws = toLoad.get(googleName) ?? new Set();
483
+ weights.forEach(w => ws.add(w));
484
+ toLoad.set(googleName, ws);
485
+ }
486
+
487
+ if (toLoad.size === 0) return;
488
+
489
+ // Mark as pending immediately (prevents duplicate concurrent requests)
490
+ for (const name of toLoad.keys()) _loadedFonts.add(name);
491
+
492
+ // Build a single batched Google Fonts CSS2 request
493
+ // Format: family=Name:ital,wght@0,400;0,700;1,400;1,700
494
+ const params = [...toLoad.entries()].map(([family, weightSet]) => {
495
+ const wArr = [...weightSet].sort((a, b) => a - b);
496
+ const specs = [
497
+ ...wArr.map(w => `0,${w}`),
498
+ ...wArr.map(w => `1,${w}`),
499
+ ].join(';');
500
+ return `family=${encodeURIComponent(`${family}:ital,wght@${specs}`)}`;
501
+ });
502
+
503
+ const url = `https://fonts.googleapis.com/css2?${params.join('&')}&display=swap`;
504
+
505
+ try {
506
+ const link = document.createElement('link');
507
+ link.rel = 'stylesheet';
508
+ link.href = url;
509
+ document.head.appendChild(link);
510
+
511
+ // Wait for stylesheet to load
512
+ await new Promise(resolve => {
513
+ link.onload = resolve;
514
+ link.onerror = () => {
515
+ console.warn('[pptx-canvas-renderer] Google Fonts failed to load:', url);
516
+ resolve();
517
+ };
518
+ setTimeout(resolve, 5000); // hard timeout
519
+ });
520
+
521
+ // Let the browser parse and prepare all font faces
522
+ if (document.fonts?.ready) {
523
+ await Promise.race([
524
+ document.fonts.ready,
525
+ new Promise(r => setTimeout(r, 2000)),
526
+ ]);
527
+ }
528
+ } catch (err) {
529
+ console.warn('[pptx-canvas-renderer] Font loading error:', err);
530
+ }
531
+ }
532
+
533
+ // ── Font collection ──────────────────────────────────────────────────────────
534
+
535
+ /**
536
+ * Scan a set of parsed XML documents and collect all distinct font names
537
+ * referenced in run properties (<a:latin>, <a:ea>, <a:cs> elements).
538
+ *
539
+ * @param {Document[]} xmlDocs
540
+ * @returns {Set<string>}
541
+ */
542
+ export function collectUsedFonts(xmlDocs) {
543
+ const names = new Set();
544
+ for (const doc of xmlDocs) {
545
+ if (!doc) continue;
546
+ const els = doc.getElementsByTagName('*');
547
+ for (let i = 0; i < els.length; i++) {
548
+ const el = els[i];
549
+ const ln = el.localName;
550
+ if (ln === 'latin' || ln === 'ea' || ln === 'cs') {
551
+ const tf = el.getAttribute('typeface');
552
+ // Skip theme font tokens ('+mj-lt', '+mn-lt', etc.)
553
+ if (tf && !tf.startsWith('+')) names.add(tf);
554
+ }
555
+ }
556
+ }
557
+ return names;
558
+ }
559
+
560
+ // ── Font building ────────────────────────────────────────────────────────────
561
+
562
+ /**
563
+ * Build a canvas font string using the full OOXML inheritance chain.
564
+ *
565
+ * Inheritance order (lowest → highest priority):
566
+ * lstStyle defRPr → paragraph pPr defRPr → run rPr
567
+ *
568
+ * @param {Element|null} rPr — run properties (<a:rPr>)
569
+ * @param {Element|null} paraDefRPr — paragraph default run properties (<a:defRPr> in <a:pPr>)
570
+ * @param {number} scaledPxPerEmu — combined scale factor (px per EMU)
571
+ * @param {object} themeData — { majorFont, minorFont }
572
+ * @param {number} [defSz=1800] — default font size in 100ths of a point
573
+ * @param {Element|null} [lstDefRPr] — list style default run properties
574
+ * @returns {{ fontStr, sz, szPx, bold, italic, family, generic }}
575
+ */
576
+ export function buildFontInherited(rPr, paraDefRPr, scaledPxPerEmu, themeData, defSz = 1800, lstDefRPr = null) {
577
+ // ── Size (100ths of a point) ───────────────────────────────────────────────
578
+ let sz = defSz;
579
+ if (lstDefRPr) { const v = lstDefRPr.getAttribute('sz'); if (v) sz = parseInt(v, 10); }
580
+ if (paraDefRPr){ const v = paraDefRPr.getAttribute('sz'); if (v) sz = parseInt(v, 10); }
581
+ if (rPr) { const v = rPr.getAttribute('sz'); if (v) sz = parseInt(v, 10); }
582
+
583
+ // ── Bold / italic ─────────────────────────────────────────────────────────
584
+ // Explicit '0' beats inherited '1'
585
+ let bold = false, italic = false;
586
+ if (lstDefRPr) {
587
+ if (lstDefRPr.getAttribute('b') === '1') bold = true;
588
+ if (lstDefRPr.getAttribute('i') === '1') italic = true;
589
+ }
590
+ if (paraDefRPr) {
591
+ const b = paraDefRPr.getAttribute('b');
592
+ const i = paraDefRPr.getAttribute('i');
593
+ if (b === '1') bold = true; else if (b === '0') bold = false;
594
+ if (i === '1') italic = true; else if (i === '0') italic = false;
595
+ }
596
+ if (rPr) {
597
+ const b = rPr.getAttribute('b');
598
+ const i = rPr.getAttribute('i');
599
+ if (b === '1') bold = true; else if (b === '0') bold = false;
600
+ if (i === '1') italic = true; else if (i === '0') italic = false;
601
+ }
602
+
603
+ // ── Font family ───────────────────────────────────────────────────────────
604
+ let rawFamily = themeData?.minorFont ?? 'Calibri';
605
+
606
+ function applyFamilyFromEl(el) {
607
+ if (!el) return;
608
+ // Try <a:latin> child first, then typeface attr on the element itself
609
+ const latin = g1(el, 'latin');
610
+ const tf = latin ? latin.getAttribute('typeface') : el.getAttribute('typeface');
611
+ if (!tf) return;
612
+ if (tf === '+mj-lt' || tf === '+mj') { rawFamily = themeData?.majorFont ?? rawFamily; return; }
613
+ if (tf === '+mn-lt' || tf === '+mn') { rawFamily = themeData?.minorFont ?? rawFamily; return; }
614
+ rawFamily = tf;
615
+ }
616
+
617
+ applyFamilyFromEl(lstDefRPr);
618
+ applyFamilyFromEl(paraDefRPr);
619
+ applyFamilyFromEl(rPr);
620
+
621
+ const family = resolveFontFamily(rawFamily);
622
+ const generic = getGenericFamily(rawFamily);
623
+
624
+ // ── Build canvas font string ──────────────────────────────────────────────
625
+ // sz (100ths of pt) → px: sz / 100 pt × 12700 EMU/pt × scaledPxPerEmu px/EMU
626
+ // = sz × 127 × scaledPxPerEmu
627
+ const szPx = sz * 127 * scaledPxPerEmu;
628
+ const weight = bold ? 'bold' : 'normal';
629
+ const style = italic ? 'italic ' : '';
630
+ // Build a font stack: preferred font → generic family
631
+ const fontStr = `${style}${weight} ${szPx}px "${family}", ${generic}`;
632
+
633
+ return { fontStr, sz, szPx, bold, italic, family, generic, rawFamily };
634
+ }
635
+
636
+ /**
637
+ * Simpler font builder for cases without inheritance.
638
+ */
639
+ export function buildFont(rPr, scaledPxPerEmu, themeData, defSz = 1800) {
640
+ return buildFontInherited(rPr, null, scaledPxPerEmu, themeData, defSz);
641
+ }
642
+
643
+ // ── Registry inspection ──────────────────────────────────────────────────────
644
+
645
+ /**
646
+ * List all currently registered custom fonts.
647
+ * @returns {{ family: string, weight: string, style: string }[]}
648
+ */
649
+ export function listRegisteredFonts() {
650
+ const result = [];
651
+ for (const [family, faces] of _customFonts) {
652
+ for (const face of faces) {
653
+ result.push({ family, weight: face.weight, style: face.style, status: face.status });
654
+ }
655
+ }
656
+ return result;
657
+ }
658
+
659
+ /**
660
+ * Remove all registered custom fonts.
661
+ * Useful when re-using the renderer with a different brand kit.
662
+ */
663
+ export function clearRegisteredFonts() {
664
+ for (const [, faces] of _customFonts) {
665
+ for (const face of faces) {
666
+ try { document.fonts.delete(face); } catch (_) {}
667
+ }
668
+ }
669
+ _customFonts.clear();
670
+ // Remove custom overrides from MS_FONT_MAP
671
+ for (const [key, val] of Object.entries(MS_FONT_MAP)) {
672
+ if (val._custom) delete MS_FONT_MAP[key];
673
+ }
674
+ // Clear system font cache entries that were custom
675
+ // (they'll be re-detected next time)
676
+ }