dom-to-pptx 1.0.9 → 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
  /**
@@ -324,7 +397,6 @@ function isIconElement(node) {
324
397
 
325
398
  if (hasContent(before) || hasContent(after)) return true;
326
399
  }
327
- console.log('Icon element:', node, cls);
328
400
  }
329
401
 
330
402
  return false;
package/src/utils.js CHANGED
@@ -573,3 +573,102 @@ export function generateBlurredSVG(w, h, color, radius, blurPx) {
573
573
  padding: padding,
574
574
  };
575
575
  }
576
+
577
+ // src/utils.js
578
+
579
+ // ... (keep all existing exports) ...
580
+
581
+ /**
582
+ * Traverses the target DOM and collects all unique font-family names used.
583
+ */
584
+ export function getUsedFontFamilies(root) {
585
+ const families = new Set();
586
+
587
+ function scan(node) {
588
+ if (node.nodeType === 1) { // Element
589
+ const style = window.getComputedStyle(node);
590
+ const fontList = style.fontFamily.split(',');
591
+ // The first font in the stack is the primary one
592
+ const primary = fontList[0].trim().replace(/['"]/g, '');
593
+ if (primary) families.add(primary);
594
+ }
595
+ for (const child of node.childNodes) {
596
+ scan(child);
597
+ }
598
+ }
599
+
600
+ // Handle array of roots or single root
601
+ const elements = Array.isArray(root) ? root : [root];
602
+ elements.forEach(el => {
603
+ const node = typeof el === 'string' ? document.querySelector(el) : el;
604
+ if (node) scan(node);
605
+ });
606
+
607
+ return families;
608
+ }
609
+
610
+ /**
611
+ * Scans document.styleSheets to find @font-face URLs for the requested families.
612
+ * Returns an array of { name, url } objects.
613
+ */
614
+ export async function getAutoDetectedFonts(usedFamilies) {
615
+ const foundFonts = [];
616
+ const processedUrls = new Set();
617
+
618
+ // Helper to extract clean URL from CSS src string
619
+ const extractUrl = (srcStr) => {
620
+ // Look for url("...") or url('...') or url(...)
621
+ // Prioritize woff, ttf, otf. Avoid woff2 if possible as handling is harder,
622
+ // but if it's the only one, take it (convert logic handles it best effort).
623
+ const matches = srcStr.match(/url\((['"]?)(.*?)\1\)/g);
624
+ if (!matches) return null;
625
+
626
+ // Filter for preferred formats
627
+ let chosenUrl = null;
628
+ for (const match of matches) {
629
+ const urlRaw = match.replace(/url\((['"]?)(.*?)\1\)/, '$2');
630
+ // Skip data URIs for now (unless you want to support base64 embedding)
631
+ if (urlRaw.startsWith('data:')) continue;
632
+
633
+ if (urlRaw.includes('.ttf') || urlRaw.includes('.otf') || urlRaw.includes('.woff')) {
634
+ chosenUrl = urlRaw;
635
+ break; // Found a good one
636
+ }
637
+ // Fallback
638
+ if (!chosenUrl) chosenUrl = urlRaw;
639
+ }
640
+ return chosenUrl;
641
+ };
642
+
643
+ for (const sheet of Array.from(document.styleSheets)) {
644
+ try {
645
+ // Accessing cssRules on cross-origin sheets (like Google Fonts) might fail
646
+ // if CORS headers aren't set. We wrap in try/catch.
647
+ const rules = sheet.cssRules || sheet.rules;
648
+ if (!rules) continue;
649
+
650
+ for (const rule of Array.from(rules)) {
651
+ if (rule.constructor.name === 'CSSFontFaceRule' || rule.type === 5) {
652
+ const familyName = rule.style.getPropertyValue('font-family').replace(/['"]/g, '').trim();
653
+
654
+ if (usedFamilies.has(familyName)) {
655
+ const src = rule.style.getPropertyValue('src');
656
+ const url = extractUrl(src);
657
+
658
+ if (url && !processedUrls.has(url)) {
659
+ processedUrls.add(url);
660
+ foundFonts.push({ name: familyName, url: url });
661
+ }
662
+ }
663
+ }
664
+ }
665
+ } catch (e) {
666
+ // SecurityError is common for external stylesheets (CORS).
667
+ // We cannot scan those automatically via CSSOM.
668
+ console.warn("error:", e);
669
+ console.warn('Cannot scan stylesheet for fonts (CORS restriction):', sheet.href);
670
+ }
671
+ }
672
+
673
+ return foundFonts;
674
+ }