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/fntdata.js ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * fntdata.js — Embedded PPTX font decoder.
3
+ *
4
+ * PowerPoint embeds fonts as .fntdata files inside ppt/fonts/.
5
+ * These are standard TrueType/OpenType fonts XOR-obfuscated with a key
6
+ * derived from the relationship ID, as specified in ECMA-376 §15.2.12.
7
+ *
8
+ * The 32-byte XOR key is constructed from the relationship GUID and applied
9
+ * to the first 32 bytes of the font data. The rest of the file is plain TTF.
10
+ *
11
+ * After decoding, the font is loaded via the FontFace API so it becomes
12
+ * available for canvas text rendering automatically.
13
+ *
14
+ * Usage:
15
+ * await loadEmbeddedFonts(renderer._files, renderer._allRels);
16
+ * // fonts are now available — renderer will use them automatically
17
+ *
18
+ * Or via the renderer instance:
19
+ * await renderer.loadEmbeddedFonts();
20
+ */
21
+
22
+ // ── XOR key derivation (ECMA-376 §15.2.12) ────────────────────────────────────
23
+
24
+ /**
25
+ * Derive the 32-byte obfuscation key from a relationship ID string.
26
+ *
27
+ * The relationship ID is a GUID in the form:
28
+ * {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}
29
+ *
30
+ * The key is constructed by parsing the GUID into its binary representation
31
+ * and XOR-interleaving according to the spec.
32
+ *
33
+ * @param {string} rId — relationship GUID (from fontRef in presentation.xml)
34
+ * @returns {Uint8Array} 32-byte key
35
+ */
36
+ function deriveObfuscationKey(rId) {
37
+ // Extract hex digits from GUID, stripping braces and dashes
38
+ const hex = rId.replace(/[{}\-]/g, '');
39
+ if (hex.length < 32) {
40
+ // Pad or repeat if too short (non-standard GUID)
41
+ const padded = hex.padEnd(32, '0');
42
+ return hexToBytes(padded.slice(0, 32));
43
+ }
44
+
45
+ // ECMA-376 specifies the key as the GUID bytes in a specific byte order:
46
+ // Data1 (4 bytes, little-endian), Data2 (2 bytes, little-endian),
47
+ // Data3 (2 bytes, little-endian), Data4 (8 bytes, big-endian)
48
+ const data1 = hex.slice(0, 8); // 4 bytes
49
+ const data2 = hex.slice(8, 12); // 2 bytes
50
+ const data3 = hex.slice(12, 16); // 2 bytes
51
+ const data4 = hex.slice(16, 32); // 8 bytes
52
+
53
+ const key = new Uint8Array(16);
54
+ // Data1: little-endian
55
+ key[0] = parseInt(data1.slice(6, 8), 16);
56
+ key[1] = parseInt(data1.slice(4, 6), 16);
57
+ key[2] = parseInt(data1.slice(2, 4), 16);
58
+ key[3] = parseInt(data1.slice(0, 2), 16);
59
+ // Data2: little-endian
60
+ key[4] = parseInt(data2.slice(2, 4), 16);
61
+ key[5] = parseInt(data2.slice(0, 2), 16);
62
+ // Data3: little-endian
63
+ key[6] = parseInt(data3.slice(2, 4), 16);
64
+ key[7] = parseInt(data3.slice(0, 2), 16);
65
+ // Data4: big-endian
66
+ for (let i = 0; i < 8; i++) {
67
+ key[8 + i] = parseInt(data4.slice(i * 2, i * 2 + 2), 16);
68
+ }
69
+
70
+ // The spec says the 32-byte key is formed by repeating the 16-byte GUID key twice
71
+ const key32 = new Uint8Array(32);
72
+ key32.set(key);
73
+ key32.set(key, 16);
74
+ return key32;
75
+ }
76
+
77
+ function hexToBytes(hex) {
78
+ const bytes = new Uint8Array(hex.length / 2);
79
+ for (let i = 0; i < bytes.length; i++) {
80
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
81
+ }
82
+ return bytes;
83
+ }
84
+
85
+ // ── Decode font data ──────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Decode an .fntdata buffer by XOR-ing the first 32 bytes with the key.
89
+ * Returns a Uint8Array containing valid TTF/OTF font data.
90
+ *
91
+ * @param {Uint8Array} data — raw .fntdata file contents
92
+ * @param {Uint8Array} key — 32-byte XOR key from deriveObfuscationKey()
93
+ * @returns {Uint8Array} — decoded font bytes
94
+ */
95
+ function decodeFontData(data, key) {
96
+ const decoded = new Uint8Array(data);
97
+ // XOR only the first 32 bytes
98
+ for (let i = 0; i < Math.min(32, decoded.length); i++) {
99
+ decoded[i] ^= key[i];
100
+ }
101
+ return decoded;
102
+ }
103
+
104
+ /**
105
+ * Detect if decoded bytes look like a valid font.
106
+ * TTF starts with 0x00010000, OTF with 'OTTO', woff with 'wOFF'.
107
+ */
108
+ function isValidFont(bytes) {
109
+ if (bytes.length < 4) return false;
110
+ const sig = (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3];
111
+ return sig === 0x00010000 // TTF
112
+ || sig === 0x4F54544F // 'OTTO' - OTF CFF
113
+ || sig === 0x74727565 // 'true'
114
+ || sig === 0x774F4646 // 'wOFF'
115
+ || sig === 0x774F4632; // 'wOF2'
116
+ }
117
+
118
+ // ── Font registry ─────────────────────────────────────────────────────────────
119
+
120
+ /** Tracks which embedded fonts have been loaded to avoid double-loading. */
121
+ const _loadedEmbedded = new Set();
122
+
123
+ // ── Main decoder ──────────────────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Parse presentation.xml's <p:embeddedFontLst> to find embedded fonts,
127
+ * decode them, and load them via FontFace API.
128
+ *
129
+ * @param {Record<string,Uint8Array>} files — ZIP file map
130
+ * @param {object} presRels — rels for presentation.xml
131
+ * @returns {Promise<EmbeddedFontResult[]>}
132
+ */
133
+ export async function loadEmbeddedFonts(files, presRels) {
134
+ const results = [];
135
+
136
+ // Find presentation.xml
137
+ const presXml = files['ppt/presentation.xml'];
138
+ if (!presXml) return results;
139
+
140
+ const parser = new DOMParser();
141
+ const presDoc = parser.parseFromString(new TextDecoder().decode(presXml), 'application/xml');
142
+
143
+ const embeddedFontLst = presDoc.querySelector('embeddedFontLst') ||
144
+ [...presDoc.getElementsByTagName('*')].find(el => el.localName === 'embeddedFontLst');
145
+
146
+ if (!embeddedFontLst) return results;
147
+
148
+ // Each <p:embeddedFont> has a <p:font typeface="..."/> and <p:regular/bold/italic/boldItalic r:id="rId..."/>
149
+ const embeddedFonts = [...embeddedFontLst.children].filter(el => el.localName === 'embeddedFont');
150
+
151
+ for (const fontEl of embeddedFonts) {
152
+ const fontDescEl = [...fontEl.children].find(el => el.localName === 'font');
153
+ if (!fontDescEl) continue;
154
+
155
+ const typeface = fontDescEl.getAttribute('typeface') || fontDescEl.getAttribute('t');
156
+ if (!typeface) continue;
157
+
158
+ const variants = [
159
+ { el: [...fontEl.children].find(e => e.localName === 'regular'), weight: '400', style: 'normal' },
160
+ { el: [...fontEl.children].find(e => e.localName === 'bold'), weight: '700', style: 'normal' },
161
+ { el: [...fontEl.children].find(e => e.localName === 'italic'), weight: '400', style: 'italic' },
162
+ { el: [...fontEl.children].find(e => e.localName === 'boldItalic'),weight: '700', style: 'italic' },
163
+ ];
164
+
165
+ for (const { el, weight, style } of variants) {
166
+ if (!el) continue;
167
+
168
+ // Get rId — attribute may be r:id or just id
169
+ const rId = el.getAttribute('r:id') || el.getAttribute('id') || el.getAttributeNS(
170
+ 'http://schemas.openxmlformats.org/officeDocument/2006/relationships', 'id'
171
+ );
172
+ if (!rId) continue;
173
+
174
+ const cacheKey = `${typeface}:${weight}:${style}`;
175
+ if (_loadedEmbedded.has(cacheKey)) {
176
+ results.push({ typeface, weight, style, status: 'already-loaded' });
177
+ continue;
178
+ }
179
+
180
+ // Resolve relationship to file path
181
+ const rel = presRels ? presRels[rId] : null;
182
+ if (!rel) {
183
+ results.push({ typeface, weight, style, status: 'rel-not-found', rId });
184
+ continue;
185
+ }
186
+
187
+ const rawData = files[rel.fullPath];
188
+ if (!rawData) {
189
+ results.push({ typeface, weight, style, status: 'file-not-found', path: rel.fullPath });
190
+ continue;
191
+ }
192
+
193
+ // Derive decryption key from the rId
194
+ // The rId used for key derivation is the GUID from the relationship target, not the Id
195
+ // In practice many tools use the rId string directly; we try both approaches.
196
+ let decoded = null;
197
+ const key = deriveObfuscationKey(rId);
198
+ const attempt1 = decodeFontData(rawData, key);
199
+
200
+ if (isValidFont(attempt1)) {
201
+ decoded = attempt1;
202
+ } else {
203
+ // Try with the full target path's basename as GUID
204
+ const basename = rel.fullPath.split('/').pop().replace('.fntdata', '');
205
+ const key2 = deriveObfuscationKey(basename);
206
+ const attempt2 = decodeFontData(rawData, key2);
207
+ if (isValidFont(attempt2)) {
208
+ decoded = attempt2;
209
+ } else {
210
+ // Try raw (some .fntdata files aren't actually obfuscated)
211
+ if (isValidFont(rawData)) {
212
+ decoded = rawData;
213
+ }
214
+ }
215
+ }
216
+
217
+ if (!decoded) {
218
+ results.push({ typeface, weight, style, status: 'decode-failed', path: rel.fullPath });
219
+ continue;
220
+ }
221
+
222
+ // Load via FontFace API
223
+ try {
224
+ const fontFace = new FontFace(typeface, decoded.buffer, { weight, style });
225
+ await fontFace.load();
226
+ document.fonts.add(fontFace);
227
+ _loadedEmbedded.add(cacheKey);
228
+ results.push({ typeface, weight, style, status: 'loaded', path: rel.fullPath });
229
+ } catch (err) {
230
+ results.push({ typeface, weight, style, status: 'load-failed', error: err.message });
231
+ }
232
+ }
233
+ }
234
+
235
+ return results;
236
+ }
237
+
238
+ /**
239
+ * Get info about embedded fonts without loading them.
240
+ * Useful for displaying what fonts are embedded in a PPTX.
241
+ *
242
+ * @param {Record<string,Uint8Array>} files
243
+ * @returns {EmbeddedFontInfo[]}
244
+ */
245
+ export function listEmbeddedFonts(files) {
246
+ const presXml = files['ppt/presentation.xml'];
247
+ if (!presXml) return [];
248
+
249
+ const presDoc = new DOMParser().parseFromString(
250
+ new TextDecoder().decode(presXml), 'application/xml'
251
+ );
252
+
253
+ const embeddedFonts = [...presDoc.getElementsByTagName('*')]
254
+ .filter(el => el.localName === 'embeddedFont');
255
+
256
+ return embeddedFonts.map(fontEl => {
257
+ const fontDescEl = [...fontEl.children].find(el => el.localName === 'font');
258
+ const typeface = fontDescEl ? (fontDescEl.getAttribute('typeface') || fontDescEl.getAttribute('t') || '') : '';
259
+ const variants = ['regular', 'bold', 'italic', 'boldItalic']
260
+ .filter(v => [...fontEl.children].some(el => el.localName === v));
261
+ return { typeface, variants, loaded: variants.some(v => _loadedEmbedded.has(
262
+ `${typeface}:${v === 'bold' || v === 'boldItalic' ? '700' : '400'}:${v.includes('talic') ? 'italic' : 'normal'}`
263
+ ))};
264
+ }).filter(f => f.typeface);
265
+ }