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.
- package/CHANGELOG.md +13 -3
- package/README.md +56 -62
- package/dist/dom-to-pptx.bundle.js +45721 -663
- package/dist/dom-to-pptx.cjs +383 -7832
- package/dist/dom-to-pptx.cjs.map +1 -0
- package/dist/dom-to-pptx.min.js +55312 -347
- package/dist/dom-to-pptx.mjs +362 -7817
- package/dist/dom-to-pptx.mjs.map +1 -0
- package/package.json +83 -73
- package/rollup.config.js +63 -24
- package/src/font-embedder.js +159 -0
- package/src/font-utils.js +35 -0
- package/src/index.js +77 -5
- package/src/utils.js +99 -0
|
@@ -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.
|
|
31
|
-
* @param {HTMLElement | string | Array<HTMLElement | string>} target
|
|
32
|
-
* @param {Object} options
|
|
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
|
-
|
|
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
|
+
}
|