dom-to-pptx 1.0.8 → 1.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.
@@ -0,0 +1,159 @@
1
+ // src/font-embedder.js
2
+ import opentype from 'opentype.js';
3
+ import { fontToEot } from './font-utils.js';
4
+
5
+ const START_RID = 201314;
6
+
7
+ export class PPTXEmbedFonts {
8
+ constructor() {
9
+ this.zip = null;
10
+ this.rId = START_RID;
11
+ this.fonts = []; // { name, data, rid }
12
+ }
13
+
14
+ async loadZip(zip) {
15
+ this.zip = zip;
16
+ }
17
+
18
+ /**
19
+ * Reads the font name from the buffer using opentype.js
20
+ */
21
+ getFontInfo(fontBuffer) {
22
+ try {
23
+ const font = opentype.parse(fontBuffer);
24
+ const names = font.names;
25
+ // Prefer English name, fallback to others
26
+ const fontFamily = names.fontFamily.en || Object.values(names.fontFamily)[0];
27
+ return { name: fontFamily };
28
+ } catch (e) {
29
+ console.warn('Could not parse font info', e);
30
+ return { name: 'Unknown' };
31
+ }
32
+ }
33
+
34
+ async addFont(fontFace, fontBuffer, type) {
35
+ // Convert to EOT/fntdata for PPTX compatibility
36
+ const eotData = await fontToEot(type, fontBuffer);
37
+ const rid = this.rId++;
38
+ this.fonts.push({ name: fontFace, data: eotData, rid });
39
+ }
40
+
41
+ async updateFiles() {
42
+ await this.updateContentTypesXML();
43
+ await this.updatePresentationXML();
44
+ await this.updateRelsPresentationXML();
45
+ this.updateFontFiles();
46
+ }
47
+
48
+ async generateBlob() {
49
+ if (!this.zip) throw new Error('Zip not loaded');
50
+ return this.zip.generateAsync({
51
+ type: 'blob',
52
+ compression: 'DEFLATE',
53
+ compressionOptions: { level: 6 },
54
+ });
55
+ }
56
+
57
+ // --- XML Manipulation Methods ---
58
+
59
+ async updateContentTypesXML() {
60
+ const file = this.zip.file('[Content_Types].xml');
61
+ if (!file) throw new Error('[Content_Types].xml not found');
62
+
63
+ const xmlStr = await file.async('string');
64
+ const parser = new DOMParser();
65
+ const doc = parser.parseFromString(xmlStr, 'text/xml');
66
+
67
+ const types = doc.getElementsByTagName('Types')[0];
68
+ const defaults = Array.from(doc.getElementsByTagName('Default'));
69
+
70
+ const hasFntData = defaults.some(el => el.getAttribute('Extension') === 'fntdata');
71
+
72
+ if (!hasFntData) {
73
+ const el = doc.createElement('Default');
74
+ el.setAttribute('Extension', 'fntdata');
75
+ el.setAttribute('ContentType', 'application/x-fontdata');
76
+ types.insertBefore(el, types.firstChild);
77
+ }
78
+
79
+ this.zip.file('[Content_Types].xml', new XMLSerializer().serializeToString(doc));
80
+ }
81
+
82
+ async updatePresentationXML() {
83
+ const file = this.zip.file('ppt/presentation.xml');
84
+ if (!file) throw new Error('ppt/presentation.xml not found');
85
+
86
+ const xmlStr = await file.async('string');
87
+ const parser = new DOMParser();
88
+ const doc = parser.parseFromString(xmlStr, 'text/xml');
89
+ const presentation = doc.getElementsByTagName('p:presentation')[0];
90
+
91
+ // Enable embedding flags
92
+ presentation.setAttribute('saveSubsetFonts', 'true');
93
+ presentation.setAttribute('embedTrueTypeFonts', 'true');
94
+
95
+ // Find or create embeddedFontLst
96
+ let embeddedFontLst = presentation.getElementsByTagName('p:embeddedFontLst')[0];
97
+
98
+ if (!embeddedFontLst) {
99
+ embeddedFontLst = doc.createElement('p:embeddedFontLst');
100
+
101
+ // Insert before defaultTextStyle or at end
102
+ const defaultTextStyle = presentation.getElementsByTagName('p:defaultTextStyle')[0];
103
+ if (defaultTextStyle) {
104
+ presentation.insertBefore(embeddedFontLst, defaultTextStyle);
105
+ } else {
106
+ presentation.appendChild(embeddedFontLst);
107
+ }
108
+ }
109
+
110
+ // Add font references
111
+ this.fonts.forEach(font => {
112
+ // Check if already exists
113
+ const existing = Array.from(embeddedFontLst.getElementsByTagName('p:font'))
114
+ .find(node => node.getAttribute('typeface') === font.name);
115
+
116
+ if (!existing) {
117
+ const embedFont = doc.createElement('p:embeddedFont');
118
+
119
+ const fontNode = doc.createElement('p:font');
120
+ fontNode.setAttribute('typeface', font.name);
121
+ embedFont.appendChild(fontNode);
122
+
123
+ const regular = doc.createElement('p:regular');
124
+ regular.setAttribute('r:id', `rId${font.rid}`);
125
+ embedFont.appendChild(regular);
126
+
127
+ embeddedFontLst.appendChild(embedFont);
128
+ }
129
+ });
130
+
131
+ this.zip.file('ppt/presentation.xml', new XMLSerializer().serializeToString(doc));
132
+ }
133
+
134
+ async updateRelsPresentationXML() {
135
+ const file = this.zip.file('ppt/_rels/presentation.xml.rels');
136
+ if (!file) throw new Error('presentation.xml.rels not found');
137
+
138
+ const xmlStr = await file.async('string');
139
+ const parser = new DOMParser();
140
+ const doc = parser.parseFromString(xmlStr, 'text/xml');
141
+ const relationships = doc.getElementsByTagName('Relationships')[0];
142
+
143
+ this.fonts.forEach(font => {
144
+ const rel = doc.createElement('Relationship');
145
+ rel.setAttribute('Id', `rId${font.rid}`);
146
+ rel.setAttribute('Target', `fonts/${font.rid}.fntdata`);
147
+ rel.setAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/font');
148
+ relationships.appendChild(rel);
149
+ });
150
+
151
+ this.zip.file('ppt/_rels/presentation.xml.rels', new XMLSerializer().serializeToString(doc));
152
+ }
153
+
154
+ updateFontFiles() {
155
+ this.fonts.forEach(font => {
156
+ this.zip.file(`ppt/fonts/${font.rid}.fntdata`, font.data);
157
+ });
158
+ }
159
+ }
@@ -0,0 +1,35 @@
1
+ // src/font-utils.js
2
+ import { Font } from 'fonteditor-core';
3
+ import pako from 'pako';
4
+
5
+ /**
6
+ * Converts various font formats to EOT (Embedded OpenType),
7
+ * which is highly compatible with PowerPoint embedding.
8
+ * @param {string} type - 'ttf', 'woff', or 'otf'
9
+ * @param {ArrayBuffer} fontBuffer - The raw font data
10
+ */
11
+ export async function fontToEot(type, fontBuffer) {
12
+ const options = {
13
+ type,
14
+ hinting: true,
15
+ // inflate is required for WOFF decoding
16
+ inflate: type === 'woff' ? pako.inflate : undefined,
17
+ };
18
+
19
+ const font = Font.create(fontBuffer, options);
20
+
21
+ const eotBuffer = font.write({
22
+ type: 'eot',
23
+ toBuffer: true,
24
+ });
25
+
26
+ if (eotBuffer instanceof ArrayBuffer) {
27
+ return eotBuffer;
28
+ }
29
+
30
+ // Ensure we return an ArrayBuffer
31
+ return eotBuffer.buffer.slice(
32
+ eotBuffer.byteOffset,
33
+ eotBuffer.byteOffset + eotBuffer.byteLength
34
+ );
35
+ }
package/src/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // src/index.js
2
2
  import * as PptxGenJSImport from 'pptxgenjs';
3
3
  import html2canvas from 'html2canvas';
4
+ import { PPTXEmbedFonts } from './font-embedder.js';
5
+ import JSZip from 'jszip';
4
6
 
5
7
  // Normalize import
6
8
  const PptxGenJS = PptxGenJSImport?.default ?? PptxGenJSImport;
@@ -20,6 +22,8 @@ import {
20
22
  generateCompositeBorderSVG,
21
23
  isClippedByParent,
22
24
  generateCustomShapeSVG,
25
+ getUsedFontFamilies,
26
+ getAutoDetectedFonts,
23
27
  } from './utils.js';
24
28
  import { getProcessedImage } from './image-processor.js';
25
29
 
@@ -27,9 +31,12 @@ const PPI = 96;
27
31
  const PX_TO_INCH = 1 / PPI;
28
32
 
29
33
  /**
30
- * Main export function. Accepts single element or an array.
31
- * @param {HTMLElement | string | Array<HTMLElement | string>} target - The root element(s) to convert.
32
- * @param {Object} options - { fileName: string }
34
+ * Main export function.
35
+ * @param {HTMLElement | string | Array<HTMLElement | string>} target
36
+ * @param {Object} options
37
+ * @param {string} [options.fileName]
38
+ * @param {Array<{name: string, url: string}>} [options.fonts] - Explicit fonts
39
+ * @param {boolean} [options.autoEmbedFonts=true] - Attempt to auto-detect and embed used fonts
33
40
  */
34
41
  export async function exportToPptx(target, options = {}) {
35
42
  const resolvePptxConstructor = (pkg) => {
@@ -59,8 +66,74 @@ export async function exportToPptx(target, options = {}) {
59
66
  await processSlide(root, slide, pptx);
60
67
  }
61
68
 
69
+ // 3. Font Embedding Logic
70
+ let finalBlob;
71
+ let fontsToEmbed = options.fonts || [];
72
+
73
+ if (options.autoEmbedFonts) {
74
+ // A. Scan DOM for used font families
75
+ const usedFamilies = getUsedFontFamilies(elements);
76
+
77
+ // B. Scan CSS for URLs matches
78
+ const detectedFonts = await getAutoDetectedFonts(usedFamilies);
79
+
80
+ // C. Merge (Avoid duplicates)
81
+ const explicitNames = new Set(fontsToEmbed.map(f => f.name));
82
+ for (const autoFont of detectedFonts) {
83
+ if (!explicitNames.has(autoFont.name)) {
84
+ fontsToEmbed.push(autoFont);
85
+ }
86
+ }
87
+
88
+ if (detectedFonts.length > 0) {
89
+ console.log('Auto-detected fonts:', detectedFonts.map(f => f.name));
90
+ }
91
+ }
92
+
93
+ if (fontsToEmbed.length > 0) {
94
+ // Generate initial PPTX
95
+ const initialBlob = await pptx.write({ outputType: 'blob' });
96
+
97
+ // Load into Embedder
98
+ const zip = await JSZip.loadAsync(initialBlob);
99
+ const embedder = new PPTXEmbedFonts();
100
+ await embedder.loadZip(zip);
101
+
102
+ // Fetch and Embed
103
+ for (const fontCfg of fontsToEmbed) {
104
+ try {
105
+ const response = await fetch(fontCfg.url);
106
+ if (!response.ok) throw new Error(`Failed to fetch ${fontCfg.url}`);
107
+ const buffer = await response.arrayBuffer();
108
+
109
+ // Infer type
110
+ const ext = fontCfg.url.split('.').pop().split(/[?#]/)[0].toLowerCase();
111
+ let type = 'ttf';
112
+ if (['woff', 'otf'].includes(ext)) type = ext;
113
+
114
+ await embedder.addFont(fontCfg.name, buffer, type);
115
+ } catch (e) {
116
+ console.warn(`Failed to embed font: ${fontCfg.name} (${fontCfg.url})`, e);
117
+ }
118
+ }
119
+
120
+ await embedder.updateFiles();
121
+ finalBlob = await embedder.generateBlob();
122
+ } else {
123
+ // No fonts to embed
124
+ finalBlob = await pptx.write({ outputType: 'blob' });
125
+ }
126
+
127
+ // 4. Download
62
128
  const fileName = options.fileName || 'export.pptx';
63
- pptx.writeFile({ fileName });
129
+ const url = URL.createObjectURL(finalBlob);
130
+ const a = document.createElement('a');
131
+ a.href = url;
132
+ a.download = fileName;
133
+ document.body.appendChild(a);
134
+ a.click();
135
+ document.body.removeChild(a);
136
+ URL.revokeObjectURL(url);
64
137
  }
65
138
 
66
139
  /**
@@ -173,29 +246,69 @@ async function processSlide(root, slide, pptx) {
173
246
  * Optimized html2canvas wrapper
174
247
  * Now strictly captures the node itself, not the root.
175
248
  */
249
+ /**
250
+ * Optimized html2canvas wrapper
251
+ * Includes fix for cropped icons by adjusting styles in the cloned document.
252
+ */
176
253
  async function elementToCanvasImage(node, widthPx, heightPx) {
177
254
  return new Promise((resolve) => {
255
+ // 1. Assign a temp ID to locate the node inside the cloned document
256
+ const originalId = node.id;
257
+ const tempId = 'pptx-capture-' + Math.random().toString(36).substr(2, 9);
258
+ node.id = tempId;
259
+
178
260
  const width = Math.max(Math.ceil(widthPx), 1);
179
261
  const height = Math.max(Math.ceil(heightPx), 1);
180
262
  const style = window.getComputedStyle(node);
181
263
 
182
- // Optimized: Capture ONLY the specific node
183
264
  html2canvas(node, {
184
265
  backgroundColor: null,
185
266
  logging: false,
186
- scale: 2, // Slight quality boost
267
+ scale: 3, // Higher scale for sharper icons
268
+ useCORS: true, // critical for external fonts/images
269
+ onclone: (clonedDoc) => {
270
+ const clonedNode = clonedDoc.getElementById(tempId);
271
+ if (clonedNode) {
272
+ // --- FIX: PREVENT ICON CLIPPING ---
273
+ // 1. Force overflow visible so glyphs bleeding out aren't cut
274
+ clonedNode.style.overflow = 'visible';
275
+
276
+ // 2. Adjust alignment for Icons to prevent baseline clipping
277
+ // (Applies to <i>, <span>, or standard icon classes)
278
+ const tag = clonedNode.tagName;
279
+ if (tag === 'I' || tag === 'SPAN' || clonedNode.className.includes('fa-')) {
280
+ // Flex center helps align the glyph exactly in the middle of the box
281
+ // preventing top/bottom cropping due to line-height mismatches.
282
+ clonedNode.style.display = 'inline-flex';
283
+ clonedNode.style.justifyContent = 'center';
284
+ clonedNode.style.alignItems = 'center';
285
+
286
+ // Remove margins that might offset the capture
287
+ clonedNode.style.margin = '0';
288
+
289
+ // Ensure the font fits
290
+ clonedNode.style.lineHeight = '1';
291
+ clonedNode.style.verticalAlign = 'middle';
292
+ }
293
+ }
294
+ },
187
295
  })
188
296
  .then((canvas) => {
297
+ // Restore the original ID
298
+ if (originalId) node.id = originalId;
299
+ else node.removeAttribute('id');
300
+
189
301
  const destCanvas = document.createElement('canvas');
190
302
  destCanvas.width = width;
191
303
  destCanvas.height = height;
192
304
  const ctx = destCanvas.getContext('2d');
193
305
 
194
- // Draw the captured canvas into our sized canvas
195
- // html2canvas might return a larger canvas if scale > 1, so we fit it
306
+ // Draw captured canvas.
307
+ // We simply draw it to fill the box. Since we centered it in 'onclone',
308
+ // the glyph should now be visible within the bounds.
196
309
  ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, width, height);
197
310
 
198
- // Apply border radius clipping
311
+ // --- Border Radius Clipping (Existing Logic) ---
199
312
  let tl = parseFloat(style.borderTopLeftRadius) || 0;
200
313
  let tr = parseFloat(style.borderTopRightRadius) || 0;
201
314
  let br = parseFloat(style.borderBottomRightRadius) || 0;
@@ -234,12 +347,61 @@ async function elementToCanvasImage(node, widthPx, heightPx) {
234
347
  resolve(destCanvas.toDataURL('image/png'));
235
348
  })
236
349
  .catch((e) => {
350
+ if (originalId) node.id = originalId;
351
+ else node.removeAttribute('id');
237
352
  console.warn('Canvas capture failed for node', node, e);
238
353
  resolve(null);
239
354
  });
240
355
  });
241
356
  }
242
357
 
358
+ /**
359
+ * Helper to identify elements that should be rendered as icons (Images).
360
+ * Detects Custom Elements AND generic tags (<i>, <span>) with icon classes/pseudo-elements.
361
+ */
362
+ function isIconElement(node) {
363
+ // 1. Custom Elements (hyphenated tags) or Explicit Library Tags
364
+ const tag = node.tagName.toUpperCase();
365
+ if (
366
+ tag.includes('-') ||
367
+ [
368
+ 'MATERIAL-ICON',
369
+ 'ICONIFY-ICON',
370
+ 'REMIX-ICON',
371
+ 'ION-ICON',
372
+ 'EVA-ICON',
373
+ 'BOX-ICON',
374
+ 'FA-ICON',
375
+ ].includes(tag)
376
+ ) {
377
+ return true;
378
+ }
379
+
380
+ // 2. Class-based Icons (FontAwesome, Bootstrap, Material symbols) on <i> or <span>
381
+ if (tag === 'I' || tag === 'SPAN') {
382
+ const cls = node.getAttribute('class') || '';
383
+ if (
384
+ typeof cls === 'string' &&
385
+ (cls.includes('fa-') ||
386
+ cls.includes('fas') ||
387
+ cls.includes('far') ||
388
+ cls.includes('fab') ||
389
+ cls.includes('bi-') ||
390
+ cls.includes('material-icons') ||
391
+ cls.includes('icon'))
392
+ ) {
393
+ // Double-check: Must have pseudo-element content to be a CSS icon
394
+ const before = window.getComputedStyle(node, '::before').content;
395
+ const after = window.getComputedStyle(node, '::after').content;
396
+ const hasContent = (c) => c && c !== 'none' && c !== 'normal' && c !== '""';
397
+
398
+ if (hasContent(before) || hasContent(after)) return true;
399
+ }
400
+ }
401
+
402
+ return false;
403
+ }
404
+
243
405
  /**
244
406
  * Replaces createRenderItem.
245
407
  * Returns { items: [], job: () => Promise, stopRecursion: boolean }
@@ -375,30 +537,18 @@ function prepareRenderItem(node, config, domOrder, pptx, effectiveZIndex, comput
375
537
  }
376
538
 
377
539
  // --- ASYNC JOB: Icons and Other Elements ---
378
- if (
379
- node.tagName.toUpperCase() === 'MATERIAL-ICON' ||
380
- node.tagName.toUpperCase() === 'ICONIFY-ICON' ||
381
- node.tagName.toUpperCase() === 'REMIX-ICON' ||
382
- node.tagName.toUpperCase() === 'ION-ICON' ||
383
- node.tagName.toUpperCase() === 'EVA-ICON' ||
384
- node.tagName.toUpperCase() === 'BOX-ICON' ||
385
- node.tagName.toUpperCase() === 'FA-ICON' ||
386
- node.tagName.includes('-')
387
- ) {
540
+ if (isIconElement(node)) {
388
541
  const item = {
389
542
  type: 'image',
390
543
  zIndex,
391
544
  domOrder,
392
- options: { x, y, w, h, rotate: rotation, data: null }, // Data null initially
545
+ options: { x, y, w, h, rotate: rotation, data: null },
393
546
  };
394
-
395
- // Create Job
396
547
  const job = async () => {
397
548
  const pngData = await elementToCanvasImage(node, widthPx, heightPx);
398
549
  if (pngData) item.options.data = pngData;
399
550
  else item.skip = true;
400
551
  };
401
-
402
552
  return { items: [item], job, stopRecursion: true };
403
553
  }
404
554