@openpresentation/opf-pptx 0.9.1 → 0.11.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/DEPENDENCY-NOTES.md +1 -1
- package/README.md +108 -17
- package/dist/background-import.js +59 -3
- package/dist/background.js +86 -1
- package/dist/body-text-import.js +86 -0
- package/dist/code-provenance.js +9 -3
- package/dist/document-provenance.js +998 -0
- package/dist/font-weights.js +63 -0
- package/dist/furniture-fields.js +176 -0
- package/dist/furniture-provenance.js +138 -18
- package/dist/image-geometry.js +77 -5
- package/dist/index.d.ts +112 -2
- package/dist/index.js +873 -110
- package/dist/media-provenance.js +237 -0
- package/dist/native-text-style.js +71 -0
- package/dist/package-fonts.js +161 -0
- package/dist/script-fonts.js +290 -0
- package/dist/slide-image-provenance.js +169 -0
- package/dist/table-import.js +1 -53
- package/dist/theme-colors.js +204 -0
- package/dist/typeface-inventory.js +227 -0
- package/package.json +15 -7
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
+
import {validatePresentation} from '@openpresentation/opf';
|
|
3
|
+
import {attachTextTags, decodeTextTag} from './code-provenance.js';
|
|
4
|
+
import {sourceLineParagraphs} from './text-provenance.js';
|
|
5
|
+
|
|
6
|
+
// A video payload exports as the preview's placeholder: a frame, a play badge,
|
|
7
|
+
// a play triangle and caption lines (FF-29). PPTX has no native field for the
|
|
8
|
+
// OPF video value, so every shape of the group carries one OPF_MEDIA_V1 shape
|
|
9
|
+
// tag unless provenance is off. Full-mode frames hold storable authored values;
|
|
10
|
+
// references-only frames carry no source or metadata. Import recognizes an
|
|
11
|
+
// unchanged, fully tagged group and rebuilds the video block; any other tagged
|
|
12
|
+
// group drops only its tagged decoration shapes, keeps the caption text and
|
|
13
|
+
// reports invalid-media-provenance. A shape name alone never removes a shape.
|
|
14
|
+
const TAG = 'OPF_MEDIA_V1', REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
15
|
+
const ROLES = ['frame', 'badge', 'play', 'caption'];
|
|
16
|
+
const MAX_VALUE_BYTES = 256 * 1024;
|
|
17
|
+
const parser = new XMLParser({ignoreAttributes: false, attributeNamePrefix: '', parseTagValue: false, trimValues: false});
|
|
18
|
+
const decoder = new TextDecoder('utf-8', {fatal: true}), encoder = new TextEncoder();
|
|
19
|
+
const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
20
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
21
|
+
const canonical = value => JSON.stringify(value, (_key, item) => object(item) ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
|
|
22
|
+
const sizeOf = value => encoder.encode(JSON.stringify(value)).byteLength;
|
|
23
|
+
const sourceOf = value => typeof value === 'string' ? value : value?.src;
|
|
24
|
+
const webSource = source => typeof source === 'string' && /^https?:\/\//i.test(source) ? source : null;
|
|
25
|
+
const hasData = value => typeof value === 'string' ? /^data:/i.test(value) : Array.isArray(value) ? value.some(hasData) : object(value) && Object.values(value).some(hasData);
|
|
26
|
+
const mediaPath = path => typeof path === 'string' && /^slides\.\d+\.(?:blocks\.\d+\.)*video$/.test(path);
|
|
27
|
+
const currentPath = (path, slideIndex) => path.replace(/^slides\.\d+/, `slides.${slideIndex}`);
|
|
28
|
+
|
|
29
|
+
// Exact emitted-line change detection, not authentication: tags remain writable.
|
|
30
|
+
// Full mode stores no caption words here. References-only carries line
|
|
31
|
+
// structure alone and derives its caption from the current native text.
|
|
32
|
+
export function mediaTextFingerprint(text) {
|
|
33
|
+
let a = 0xdeadbeef, b = 0x41c6ce57;
|
|
34
|
+
for (let index = 0; index < text.length; index++) {
|
|
35
|
+
a = Math.imul(a ^ text.charCodeAt(index), 2654435761);
|
|
36
|
+
b = Math.imul(b ^ text.charCodeAt(index), 1597334677);
|
|
37
|
+
}
|
|
38
|
+
return `${text.length}:${(a >>> 0).toString(16).padStart(8, '0')}${(b >>> 0).toString(16).padStart(8, '0')}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function resolvedSource(value, assets) {
|
|
42
|
+
let source = sourceOf(value);
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
while (typeof source === 'string' && source.startsWith('asset:')) {
|
|
45
|
+
const id = source.slice(6);
|
|
46
|
+
if (seen.has(id) || !Object.hasOwn(assets, id)) return undefined;
|
|
47
|
+
seen.add(id);
|
|
48
|
+
source = sourceOf(assets[id]);
|
|
49
|
+
}
|
|
50
|
+
return source;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The caption the placeholder shows: title, else source, else "Media". */
|
|
54
|
+
export function mediaCaption(value) {
|
|
55
|
+
const asset = typeof value === 'string' ? {src: value} : object(value) ? value : {src: ''};
|
|
56
|
+
return asset.title || asset.src || 'Media';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The frame record: the authored value and, in 'full' provenance mode, the
|
|
61
|
+
* asset registry entries it references (without data: sources), so an
|
|
62
|
+
* `asset:` reference still resolves after import. Data: sources and oversized
|
|
63
|
+
* values are omitted with a diagnostic. References-only stores only identity;
|
|
64
|
+
* its imported value comes from the current native hyperlink and caption.
|
|
65
|
+
*/
|
|
66
|
+
export function mediaFrameRecord(value, path, presentation, mode, report = () => {}) {
|
|
67
|
+
const record = {v: 1, role: 'frame', path};
|
|
68
|
+
if (mode === 'references-only') return {...record, nativeOnly: true};
|
|
69
|
+
const omit = reason => {
|
|
70
|
+
report({code: 'media-provenance-omitted', path, message: `The video value is not stored in the PPTX because ${reason}; import keeps current native content.`});
|
|
71
|
+
// Late checks run after video/assets are added. Omission is identity-only.
|
|
72
|
+
return {v: 1, role: 'frame', path, omitted: true};
|
|
73
|
+
};
|
|
74
|
+
if (hasData(value)) return omit('it embeds a data: source without an exported media part');
|
|
75
|
+
if (sizeOf(value) > MAX_VALUE_BYTES) return omit('it exceeds the media provenance size limit');
|
|
76
|
+
record.video = value;
|
|
77
|
+
if (mode === 'full') {
|
|
78
|
+
const assets = Object.create(null);
|
|
79
|
+
let source = typeof value === 'string' ? value : value?.src;
|
|
80
|
+
for (let depth = 0; typeof source === 'string' && source.startsWith('asset:') && depth < 8; depth++) {
|
|
81
|
+
const id = source.slice(6), asset = presentation.assets?.[id];
|
|
82
|
+
if (asset === undefined || Object.hasOwn(assets, id) || hasData(asset) || sizeOf(asset) > MAX_VALUE_BYTES) return omit('a referenced asset cannot be stored');
|
|
83
|
+
assets[id] = asset;
|
|
84
|
+
source = typeof asset === 'string' ? asset : asset?.src;
|
|
85
|
+
}
|
|
86
|
+
if (Object.keys(assets).length) record.assets = assets;
|
|
87
|
+
if (resolvedSource(value, assets) === undefined) return omit('a referenced asset cannot be resolved');
|
|
88
|
+
}
|
|
89
|
+
if (sizeOf(record) > MAX_VALUE_BYTES) return omit('it exceeds the media provenance size limit');
|
|
90
|
+
return record;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function attachMediaTags(entries, records) {
|
|
94
|
+
attachTextTags(entries, records, TAG, 'opfMedia', 'media');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readTags(shape, relationships, entries) {
|
|
98
|
+
const tags = [];
|
|
99
|
+
let unreadable = false;
|
|
100
|
+
for (const link of array(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst']?.['p:tags'])) {
|
|
101
|
+
const rel = relationships.get(link['r:id']);
|
|
102
|
+
if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) { unreadable = true; continue; }
|
|
103
|
+
try { tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag'])); } catch { unreadable = true; }
|
|
104
|
+
}
|
|
105
|
+
return {tags, unreadable};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const validAsset = (id, asset) => /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id) && validatePresentation({$schema: 'https://openpresentation.org/schema/opf/v1', name: 'Media', assets: {[id]: asset}, slides: [{title: 'Media'}]}).valid;
|
|
109
|
+
const validVideo = value => validatePresentation({$schema: 'https://openpresentation.org/schema/opf/v1', name: 'Media', slides: [{video: value}]}).valid;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns {items, consumed, assets}. items are {shape, payload} for restored
|
|
113
|
+
* video blocks (the frame supplies the position); consumed holds every shape
|
|
114
|
+
* import must skip; assets are registry entries to add when absent.
|
|
115
|
+
*/
|
|
116
|
+
export function importMediaGroups(shapes, paragraphs, relationships, entries, slideIndex, report, registry = Object.create(null)) {
|
|
117
|
+
const groups = new Map(), items = [], consumed = new Set(), assets = Object.create(null), captionShapes = new Set();
|
|
118
|
+
for (const [index, shape] of shapes.entries()) {
|
|
119
|
+
const {tags, unreadable} = readTags(shape, relationships, entries);
|
|
120
|
+
const own = tags.filter(tag => String(tag.name ?? '').toUpperCase() === TAG);
|
|
121
|
+
if (!own.length) continue;
|
|
122
|
+
let record = null;
|
|
123
|
+
try {
|
|
124
|
+
if (unreadable || own.length !== 1 || tags.some(tag => /^OPF_/i.test(tag.name ?? '') && String(tag.name).toUpperCase() !== TAG)) throw Error('ambiguous');
|
|
125
|
+
if (typeof own[0].val !== 'string' || own[0].val.length > MAX_VALUE_BYTES * 2) throw Error('oversized');
|
|
126
|
+
record = decodeTextTag(own[0].val);
|
|
127
|
+
if (!object(record) || record.v !== 1 || !ROLES.includes(record.role) || !mediaPath(record.path)) throw Error('invalid');
|
|
128
|
+
const allowed = ['v', 'role', 'path', ...(record.role === 'frame' ? ['video', 'assets', 'omitted', 'nativeOnly'] : record.role === 'caption' ? ['line', 'count', 'boundary', 'separator', 'fingerprint'] : [])];
|
|
129
|
+
if (Object.keys(record).some(key => !allowed.includes(key))) throw Error('unknown field');
|
|
130
|
+
if (record.role === 'caption' && (record.boundary !== undefined && !['hard', 'soft', 'end'].includes(record.boundary) || record.separator !== undefined && (typeof record.separator !== 'string' || !/^(?:\r\n|\r|\n)?$/.test(record.separator)) || record.fingerprint !== undefined && (typeof record.fingerprint !== 'string' || !/^\d+:[0-9a-f]{16}$/.test(record.fingerprint)))) throw Error('invalid caption evidence');
|
|
131
|
+
if (record.role === 'frame' && (record.omitted !== undefined && record.omitted !== true || record.nativeOnly !== undefined && record.nativeOnly !== true || (record.omitted || record.nativeOnly) && (Object.hasOwn(record, 'video') || Object.hasOwn(record, 'assets')) || record.omitted && record.nativeOnly)) throw Error('inconsistent frame');
|
|
132
|
+
} catch { record = null; }
|
|
133
|
+
const key = record?.path ?? `slides.${slideIndex}`;
|
|
134
|
+
if (!groups.has(key)) groups.set(key, {path: key, members: [], invalid: false});
|
|
135
|
+
const group = groups.get(key);
|
|
136
|
+
if (!record) { group.invalid = true; group.members.push({index, shape, role: null}); continue; }
|
|
137
|
+
if (record.role === 'caption') captionShapes.add(shape);
|
|
138
|
+
group.members.push({index, shape, record, role: record.role});
|
|
139
|
+
}
|
|
140
|
+
for (const group of groups.values()) {
|
|
141
|
+
const name = (member, suffix) => member.shape['p:nvSpPr']?.['p:cNvPr']?.name === `OPF media ${group.path} ${suffix}`;
|
|
142
|
+
const text = member => (paragraphs[member.index] ?? []).map(paragraph => paragraph.text).join('\n');
|
|
143
|
+
const byRole = role => group.members.filter(member => member.role === role);
|
|
144
|
+
const [frame] = byRole('frame'), captions = byRole('caption').sort((a, b) => a.record.line - b.record.line);
|
|
145
|
+
const decorations = group.members.filter(member => ['frame', 'badge', 'play'].includes(member.role));
|
|
146
|
+
const path = currentPath(group.path, slideIndex);
|
|
147
|
+
// Current characters supply the text. Soft wraps insert nothing; full
|
|
148
|
+
// source separators retain CR/LF spelling. Restricted mode has only
|
|
149
|
+
// structural hard breaks, represented as LF without hidden source bytes.
|
|
150
|
+
let caption;
|
|
151
|
+
if (captions.length && captions.every((member, index) => member.record.line === index && member.record.count === captions.length && name(member, `caption line ${index}`))) try {
|
|
152
|
+
caption = sourceLineParagraphs(captions.map(member => {
|
|
153
|
+
let data = frame?.record.nativeOnly === true
|
|
154
|
+
? {boundary: member.record.boundary, separator: member.record.boundary === 'hard' ? '\n' : ''}
|
|
155
|
+
: member.record;
|
|
156
|
+
// Parent full tags omitted separators. Explicit soft/end inserts no
|
|
157
|
+
// character; an old hard boundary cannot establish CR/LF spelling.
|
|
158
|
+
if (!Object.hasOwn(data, 'separator') && ['soft', 'end'].includes(data.boundary)) data = {...data, separator: ''};
|
|
159
|
+
return {index: member.index, data};
|
|
160
|
+
}), paragraphs)[0].text;
|
|
161
|
+
} catch { /* Missing or damaged boundaries use current native fallback. */ }
|
|
162
|
+
const links = array(frame?.shape['p:nvSpPr']?.['p:cNvPr']?.['a:hlinkClick']);
|
|
163
|
+
const rel = links.length === 1 ? relationships.get(links[0]['r:id']) : undefined;
|
|
164
|
+
const href = rel?.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink' && rel.targetMode === 'External' ? webSource(rel.target) : null;
|
|
165
|
+
let reason = null, video, restoredAssets;
|
|
166
|
+
if (group.invalid) reason = 'a media tag is ambiguous or unreadable';
|
|
167
|
+
else if (byRole('frame').length !== 1 || byRole('badge').length !== 1 || byRole('play').length !== 1 || !captions.length) reason = 'placeholder shapes were removed or duplicated';
|
|
168
|
+
else if (!decorations.every(member => name(member, member.role) && !text(member)) || !captions.every(member => name(member, `caption line ${member.record.line}`))) reason = 'placeholder shapes were renamed or given text';
|
|
169
|
+
else if (!captions.every((member, index) => member.record.line === index && member.record.count === captions.length)) reason = 'caption lines were removed or duplicated';
|
|
170
|
+
else if (links.length && !href) reason = 'the native hyperlink is ambiguous or unsupported';
|
|
171
|
+
else if (caption === undefined) reason = 'its caption source boundaries are missing or invalid';
|
|
172
|
+
else if (frame.record.nativeOnly === true) {
|
|
173
|
+
if (!href) reason = 'no native web hyperlink is available';
|
|
174
|
+
else if (caption === '') reason = 'its current caption was cleared';
|
|
175
|
+
else video = caption === href ? href : {src: href, title: caption};
|
|
176
|
+
}
|
|
177
|
+
else if (frame.record.omitted || !Object.hasOwn(frame.record, 'video')) reason = 'the video value was omitted at export';
|
|
178
|
+
else if (!validVideo(frame.record.video)) reason = 'the stored video value is not valid OPF';
|
|
179
|
+
else if (hasData(frame.record.video) || hasData(frame.record.assets)) reason = 'the stored video contains an unsupported data: source';
|
|
180
|
+
else if (frame.record.assets !== undefined && (!object(frame.record.assets) || !Object.entries(frame.record.assets).every(([id, asset]) => validAsset(id, asset)))) reason = 'the stored assets are not valid OPF';
|
|
181
|
+
else {
|
|
182
|
+
const source = resolvedSource(frame.record.video, frame.record.assets ?? {});
|
|
183
|
+
const needed = new Set();
|
|
184
|
+
let reference = sourceOf(frame.record.video);
|
|
185
|
+
while (typeof reference === 'string' && reference.startsWith('asset:') && !needed.has(reference.slice(6))) {
|
|
186
|
+
const id = reference.slice(6);
|
|
187
|
+
needed.add(id);
|
|
188
|
+
reference = sourceOf(frame.record.assets?.[id]);
|
|
189
|
+
}
|
|
190
|
+
if (Object.keys(frame.record.assets ?? {}).some(id => !needed.has(id))) reason = 'the stored assets contain unrelated entries';
|
|
191
|
+
else if (source === undefined) reason = 'the stored asset reference cannot be resolved';
|
|
192
|
+
else if (webSource(source) !== href) {
|
|
193
|
+
if (!href) reason = 'its source hyperlink was removed';
|
|
194
|
+
else if (caption === '') reason = 'its current caption was cleared';
|
|
195
|
+
else {
|
|
196
|
+
// Preserve the current URL and caption; hidden metadata describes the old source.
|
|
197
|
+
video = caption === href ? href : {src: href, title: caption};
|
|
198
|
+
report({code: 'media-source-changed', path, message: `The video hyperlink at ${path} changed; import keeps the current URL and caption.`});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else if (!captions.every(member => typeof member.record.fingerprint === 'string' && mediaTextFingerprint(text(member)) === member.record.fingerprint)) reason = 'its caption text was edited or exact caption evidence is missing';
|
|
202
|
+
// Line evidence is not authentication: bind the stored authored title
|
|
203
|
+
// to exact current characters and validated boundaries as well.
|
|
204
|
+
else if (caption !== mediaCaption(frame.record.video)) reason = 'its stored caption disagrees with current native text';
|
|
205
|
+
else if (!href && caption !== source) reason = 'its source has no current native hyperlink or source caption';
|
|
206
|
+
else {
|
|
207
|
+
const conflict = Object.entries(frame.record.assets ?? {}).some(([id, asset]) => Object.hasOwn(registry, id) && canonical(registry[id]) !== canonical(asset));
|
|
208
|
+
if (conflict && !href) reason = 'its stored assets conflict with another video';
|
|
209
|
+
else if (conflict && caption === '') reason = 'its current caption was cleared';
|
|
210
|
+
else if (conflict) {
|
|
211
|
+
video = caption === href ? href : {src: href, title: caption};
|
|
212
|
+
report({code: 'media-asset-conflict', path, message: `Stored asset IDs at ${path} conflict with another video; import keeps this placeholder's current URL and caption.`});
|
|
213
|
+
}
|
|
214
|
+
else { video = structuredClone(frame.record.video); restoredAssets = frame.record.assets; }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (!reason) {
|
|
218
|
+
for (const member of group.members) consumed.add(member.shape);
|
|
219
|
+
items.push({shape: frame.shape, payload: {type: 'video', video}});
|
|
220
|
+
if (object(restoredAssets)) for (const [id, asset] of Object.entries(restoredAssets)) {
|
|
221
|
+
registry[id] = structuredClone(asset);
|
|
222
|
+
assets[id] = structuredClone(asset);
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
// Tagged decoration carries no content; caption lines stay ordinary text.
|
|
227
|
+
for (const member of group.members) if (member.role && member.role !== 'caption' && !text(member)) consumed.add(member.shape);
|
|
228
|
+
// Preserve the current frame URL independently of failed tag evidence.
|
|
229
|
+
// Decoration links never supply authority. Other schemes remain inert.
|
|
230
|
+
if (rel?.type === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink' && rel.targetMode === 'External' && typeof rel.target === 'string') {
|
|
231
|
+
items.push({shape: frame.shape, payload: {type: 'text', text: href ? [{text: href, link: href}] : rel.target}});
|
|
232
|
+
}
|
|
233
|
+
report({code: 'invalid-media-provenance', path,
|
|
234
|
+
message: `The video placeholder at ${path} was not restored as a video because ${reason}. Its current caption stays as text and its current frame URL is retained as linked text when it is a web URL.`});
|
|
235
|
+
}
|
|
236
|
+
return {items, consumed, assets, captionShapes};
|
|
237
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {readBackgroundColor} from './background.js';
|
|
2
|
+
import {splitWeightFace} from './font-weights.js';
|
|
3
|
+
|
|
4
|
+
const boolean = value => ['1','true','on'].includes(value) ? true : ['0','false','off'].includes(value) ? false : undefined;
|
|
5
|
+
|
|
6
|
+
const themeFamilies = context => ['a:majorFont', 'a:minorFont'].map(key => context.fonts?.[key]?.['a:latin']?.typeface?.trim().toLowerCase()).filter(Boolean);
|
|
7
|
+
|
|
8
|
+
export function nativeRunStyle(properties, context, relationships, report, kind = 'table') {
|
|
9
|
+
const result = {};
|
|
10
|
+
for (const [native, opf] of [['b','bold'], ['i','italic']]) {
|
|
11
|
+
const value = boolean(properties[native]);
|
|
12
|
+
if (value !== undefined) result[opf] = value;
|
|
13
|
+
}
|
|
14
|
+
for (const [native, opf] of [['u','underline'], ['strike','strikethrough']]) {
|
|
15
|
+
if (properties[native] !== undefined) result[opf] = properties[native] !== 'none' && properties[native] !== 'noStrike';
|
|
16
|
+
}
|
|
17
|
+
const size = Number(properties.sz);
|
|
18
|
+
if (Number.isFinite(size) && size > 0) result.fontSize = size / 100;
|
|
19
|
+
const baseline = Number(properties.baseline);
|
|
20
|
+
if (Number.isFinite(baseline)) {
|
|
21
|
+
result.superscript = baseline > 0;
|
|
22
|
+
result.subscript = baseline < 0;
|
|
23
|
+
}
|
|
24
|
+
const font = properties['a:latin']?.typeface;
|
|
25
|
+
if (properties['a:latin'] && !font) report(`unsupported-${kind}-font`, 'The native text font has no usable Latin face or theme reference.');
|
|
26
|
+
if (font) {
|
|
27
|
+
const match = font.match(/^\+(mj|mn)-(lt|ea|cs)$/);
|
|
28
|
+
const family = match ? context.fonts?.[match[1] === 'mj' ? 'a:majorFont' : 'a:minorFont']?.[{lt:'a:latin',ea:'a:ea',cs:'a:cs'}[match[2]]]?.typeface : font;
|
|
29
|
+
if (family) {
|
|
30
|
+
result.fontFamily = family;
|
|
31
|
+
// The exporter writes a bundled family's weight faces (Roboto SemiBold) under their
|
|
32
|
+
// native style-link names. OPF runs carry the family plus bold, never the face name.
|
|
33
|
+
// A typeface that is itself a theme font was authored as a family; leave it as is.
|
|
34
|
+
const face = themeFamilies(context).includes(family.trim().toLowerCase()) ? undefined : splitWeightFace(family);
|
|
35
|
+
if (face) {
|
|
36
|
+
result.fontFamily = face.family;
|
|
37
|
+
if (face.weight >= 600) result.bold = true;
|
|
38
|
+
if (face.italic) result.italic = true;
|
|
39
|
+
if (face.weight !== 400 && face.weight !== 700) report(`approximate-${kind}-font-weight`, 'OPF text runs represent regular or bold only; the native weight face was imported as its family, with bold for weights of 600 and above.');
|
|
40
|
+
}
|
|
41
|
+
} else report(`unsupported-${kind}-font`, 'The theme font reference could not be resolved from this archive.');
|
|
42
|
+
}
|
|
43
|
+
if (properties['a:solidFill']) {
|
|
44
|
+
const color = readBackgroundColor(properties['a:solidFill'], context);
|
|
45
|
+
if (color) result.color = color.hex + (color.alpha < 1 ? Math.round(color.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '');
|
|
46
|
+
else report(`unsupported-${kind}-text-color`, 'The native text color or its transforms could not be resolved.');
|
|
47
|
+
} else if (properties['a:noFill']) {
|
|
48
|
+
result.color = '#00000000';
|
|
49
|
+
}
|
|
50
|
+
if (['a:gradFill','a:blipFill','a:pattFill','a:grpFill'].some(key => properties[key])) report(`unsupported-${kind}-text-fill`, 'Only solid native text fills are represented by OPF runs.');
|
|
51
|
+
const hyperlink = properties['a:hlinkClick'];
|
|
52
|
+
if (hyperlink) {
|
|
53
|
+
const relationship = relationships.get(hyperlink['r:id']);
|
|
54
|
+
if (relationship?.type.endsWith('/hyperlink') && relationship.targetMode === 'External' && relationship.target && !hyperlink.action) result.link = relationship.target;
|
|
55
|
+
else report(`unsupported-${kind}-link`, 'The native hyperlink action or relationship cannot be represented as a URL.');
|
|
56
|
+
}
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Merge at the native property level before conversion: an explicit normal run
|
|
61
|
+
// overrides a bold paragraph default, and a new fill replaces the inherited fill.
|
|
62
|
+
export function mergeNativeRunProperties(...levels) {
|
|
63
|
+
const result = {};
|
|
64
|
+
const fills = ['a:solidFill','a:noFill','a:gradFill','a:blipFill','a:pattFill','a:grpFill','_opfUnresolvedTableFill'];
|
|
65
|
+
for (const level of levels) {
|
|
66
|
+
if (!level) continue;
|
|
67
|
+
if (fills.some(key => Object.hasOwn(level, key))) for (const key of fills) delete result[key];
|
|
68
|
+
Object.assign(result, level);
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import {unzipSync, zipSync} from 'fflate';
|
|
2
|
+
import {inventoryPptxTypefaces, packageFontsUsed, headingPairs, titlesOfParts} from './typeface-inventory.js';
|
|
3
|
+
|
|
4
|
+
// Package-level font post-processing (FF-08, font-fidelity-everywhere). The
|
|
5
|
+
// vendored PptxGenJS bytes stay untouched; these passes rewrite its output so
|
|
6
|
+
// the package names only the fonts the document chose:
|
|
7
|
+
// - chart text properties (data labels, axes, legend, titles) use the chart's
|
|
8
|
+
// body font in latin/ea/cs instead of the hard-coded Arial;
|
|
9
|
+
// - each chart's embedded workbook uses the same fonts in its styles and
|
|
10
|
+
// theme, with no Office script supplements;
|
|
11
|
+
// - run pitchFamily follows the font's catalog type (monospace is fixed pitch);
|
|
12
|
+
// - docProps/app.xml "Fonts Used" lists the fonts the package actually uses.
|
|
13
|
+
|
|
14
|
+
const decoder = new TextDecoder(), encoder = new TextEncoder();
|
|
15
|
+
const text = bytes => decoder.decode(bytes);
|
|
16
|
+
const escapeXml = value => String(value).replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char]));
|
|
17
|
+
|
|
18
|
+
// OOXML pitchFamily: low bits are the pitch (1 fixed, 2 variable), high bits
|
|
19
|
+
// the family (0x10 roman, 0x20 swiss, 0x30 modern). These are the values
|
|
20
|
+
// PowerPoint writes for Consolas/Courier New, Georgia/Times New Roman and
|
|
21
|
+
// Calibri/Arial respectively.
|
|
22
|
+
export const PITCH_FAMILY = Object.freeze({monospace: 49, serif: 18, 'sans-serif': 34});
|
|
23
|
+
|
|
24
|
+
// family -> pitchFamily. A scheme's type describes its major/minor families,
|
|
25
|
+
// not inline heading/body overrides. Each slide's resolved scheme wins, then
|
|
26
|
+
// catalog records where every record agrees, then the code role (monospace).
|
|
27
|
+
// Families with no known type keep the vendor value.
|
|
28
|
+
export function fontPitchFamilies(slideFonts, records) {
|
|
29
|
+
const pitch = new Map();
|
|
30
|
+
const typed = record => [record?.major, record?.minor].filter(family => typeof family === 'string' && family).map(family => [family, PITCH_FAMILY[record.type]]);
|
|
31
|
+
for (const fonts of slideFonts) for (const [family, value] of typed(fonts.scheme)) if (value !== undefined && !pitch.has(family)) pitch.set(family, value);
|
|
32
|
+
const catalog = new Map();
|
|
33
|
+
for (const record of records) for (const [family, value] of typed(record)) catalog.set(family, catalog.has(family) && catalog.get(family) !== value ? undefined : value);
|
|
34
|
+
for (const [family, value] of catalog) if (value !== undefined && !pitch.has(family)) pitch.set(family, value);
|
|
35
|
+
for (const fonts of slideFonts) if (fonts.code && !pitch.has(fonts.code)) pitch.set(fonts.code, PITCH_FAMILY.monospace);
|
|
36
|
+
return pitch;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writePitchFamilies(xml, pitch) {
|
|
40
|
+
if (!pitch.size || !xml.includes('pitchFamily=')) return xml;
|
|
41
|
+
return xml.replace(/<a:(latin|ea|cs|sym|buFont)\b([^<>]*?)\/>/g, (node, element, attributes) => {
|
|
42
|
+
const face = /\btypeface="([^"]*)"/.exec(attributes)?.[1];
|
|
43
|
+
if (face === undefined || !/\bpitchFamily="/.test(attributes)) return node;
|
|
44
|
+
const value = pitch.get(unescapeFace(face));
|
|
45
|
+
if (value === undefined) return node;
|
|
46
|
+
return `<a:${element}${attributes.replace(/\bpitchFamily="[^"]*"/, `pitchFamily="${value}"`)}/>`;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Chart parts: every text property element names the chart font in all three
|
|
51
|
+
// script slots, in schema order (after fills/underline, before sym/hlink/rtl).
|
|
52
|
+
export function writeChartFonts(xml, family) {
|
|
53
|
+
const face = escapeXml(family);
|
|
54
|
+
const slots = `<a:latin typeface="${face}"/><a:ea typeface="${face}"/><a:cs typeface="${face}"/>`;
|
|
55
|
+
return xml.replace(/<a:(defRPr|rPr)\b([^<>]*?)(?:\/>|>([\s\S]*?)<\/a:\1>)/g, (node, element, attributes, body) => {
|
|
56
|
+
if (attributes.endsWith('/')) attributes = attributes.slice(0, -1);
|
|
57
|
+
const content = (body ?? '').replace(/<a:(latin|ea|cs)\b[^<>]*?(?:\/>|>\s*<\/a:\1>)/g, '');
|
|
58
|
+
const tail = /<a:(?:sym|hlinkClick|hlinkMouseOver|rtl|extLst)\b/.exec(content);
|
|
59
|
+
const at = tail ? tail.index : content.length;
|
|
60
|
+
return `<a:${element}${attributes}>${content.slice(0, at)}${slots}${content.slice(at)}</a:${element}>`;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Embedded chart workbook: every SpreadsheetML font uses the chart body font
|
|
65
|
+
// (each <font> in styles.xml, whether under <fonts>, <dxfs> or elsewhere, and
|
|
66
|
+
// any rich-text <rFont> in shared strings or sheets); the workbook theme uses
|
|
67
|
+
// the chart heading/body fonts with empty ea/cs slots and no Office script
|
|
68
|
+
// supplements. Parts without fonts are kept byte for byte.
|
|
69
|
+
export function writeWorkbookFonts(bytes, fonts) {
|
|
70
|
+
const entries = unzipSync(bytes);
|
|
71
|
+
const styles = entries['xl/styles.xml'], theme = entries['xl/theme/theme1.xml'];
|
|
72
|
+
const setVal = node => node.replace(/\bval="[^"]*"/, `val="${escapeXml(fonts.body)}"`);
|
|
73
|
+
if (styles) {
|
|
74
|
+
entries['xl/styles.xml'] = encoder.encode(text(styles).replace(/<font\b[^>]*>[\s\S]*?<\/font>/g, font =>
|
|
75
|
+
font.replace(/<name\b[^>]*?\bval="[^"]*"/g, setVal)));
|
|
76
|
+
}
|
|
77
|
+
for (const path of Object.keys(entries)) {
|
|
78
|
+
if (!/^xl\/(?:sharedStrings|worksheets\/[^/]+)\.xml$/.test(path)) continue;
|
|
79
|
+
const xml = text(entries[path]);
|
|
80
|
+
if (xml.includes('<rFont')) entries[path] = encoder.encode(xml.replace(/<rFont\b[^>]*?\bval="[^"]*"/g, setVal));
|
|
81
|
+
}
|
|
82
|
+
if (theme) {
|
|
83
|
+
const collection = (name, family) => `<a:${name}><a:latin typeface="${escapeXml(family)}"/><a:ea typeface=""/><a:cs typeface=""/></a:${name}>`;
|
|
84
|
+
entries['xl/theme/theme1.xml'] = encoder.encode(text(theme)
|
|
85
|
+
.replace(/<a:majorFont>[\s\S]*?<\/a:majorFont>/, collection('majorFont', fonts.heading))
|
|
86
|
+
.replace(/<a:minorFont>[\s\S]*?<\/a:minorFont>/, collection('minorFont', fonts.body)));
|
|
87
|
+
}
|
|
88
|
+
// The caller's package normalization rezips nested packages deterministically.
|
|
89
|
+
return zipSync(entries, {level: 0});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Rewrites chart parts and their workbooks for charts the exporter created,
|
|
93
|
+
// found through each slide's chart graphic frames (per-slide fonts apply).
|
|
94
|
+
export function applyChartFonts(entries, chartFonts, parseRelationships) {
|
|
95
|
+
if (!chartFonts.size) return;
|
|
96
|
+
for (const part of Object.keys(entries)) {
|
|
97
|
+
if (!/^ppt\/slides\/slide\d+\.xml$/.test(part)) continue;
|
|
98
|
+
const relationships = parseRelationships(entries, part);
|
|
99
|
+
for (const [frame] of text(entries[part]).matchAll(/<p:graphicFrame>[\s\S]*?<\/p:graphicFrame>/g)) {
|
|
100
|
+
const fonts = chartFonts.get(frame.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1]);
|
|
101
|
+
const chartPart = relationships.get(frame.match(/<c:chart\b[^>]*\br:id="([^"]+)"/)?.[1])?.path;
|
|
102
|
+
if (!fonts || !chartPart || !entries[chartPart]) continue;
|
|
103
|
+
entries[chartPart] = encoder.encode(writeChartFonts(text(entries[chartPart]), fonts.body));
|
|
104
|
+
for (const relationship of parseRelationships(entries, chartPart).values()) {
|
|
105
|
+
if (relationship.type.endsWith('/package') && /\.xlsx$/i.test(relationship.path) && entries[relationship.path]) {
|
|
106
|
+
entries[relationship.path] = writeWorkbookFonts(entries[relationship.path], fonts);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function applyPitchFamilies(entries, pitch) {
|
|
114
|
+
for (const part of Object.keys(entries)) {
|
|
115
|
+
if (/^ppt\/.*\.xml$/.test(part)) {
|
|
116
|
+
const xml = text(entries[part]), next = writePitchFamilies(xml, pitch);
|
|
117
|
+
if (next !== xml) entries[part] = encoder.encode(next);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// docProps/app.xml: regenerate the "Fonts Used" group of HeadingPairs and
|
|
123
|
+
// TitlesOfParts from the fonts the finished package uses, and name the theme
|
|
124
|
+
// the way the theme part does. Other groups (slide titles) are kept.
|
|
125
|
+
export function writeFontsUsed(appXml, fonts, themeName) {
|
|
126
|
+
const pairs = headingPairs(appXml), titles = titlesOfParts(appXml);
|
|
127
|
+
if (!pairs || !titles) return appXml;
|
|
128
|
+
const groups = [];
|
|
129
|
+
let offset = 0;
|
|
130
|
+
for (const [name, count] of pairs) {
|
|
131
|
+
groups.push([name, titles.slice(offset, offset + count)]);
|
|
132
|
+
offset += count;
|
|
133
|
+
}
|
|
134
|
+
const fontsGroup = groups.find(([name]) => name === 'Fonts Used');
|
|
135
|
+
if (fontsGroup) fontsGroup[1] = fonts;
|
|
136
|
+
else groups.unshift(['Fonts Used', fonts]);
|
|
137
|
+
const themeGroup = groups.find(([name]) => name === 'Theme');
|
|
138
|
+
if (themeGroup && themeName && themeGroup[1].length === 1) themeGroup[1] = [themeName];
|
|
139
|
+
const kept = groups.filter(([, items]) => items.length);
|
|
140
|
+
const variants = kept.map(([name, items]) => `<vt:variant><vt:lpstr>${escapeXml(name)}</vt:lpstr></vt:variant><vt:variant><vt:i4>${items.length}</vt:i4></vt:variant>`).join('');
|
|
141
|
+
const items = kept.flatMap(([, list]) => list).map(item => `<vt:lpstr>${escapeXml(item)}</vt:lpstr>`).join('');
|
|
142
|
+
return appXml
|
|
143
|
+
.replace(/<HeadingPairs>[\s\S]*?<\/HeadingPairs>/, `<HeadingPairs><vt:vector size="${kept.length * 2}" baseType="variant">${variants}</vt:vector></HeadingPairs>`)
|
|
144
|
+
.replace(/<TitlesOfParts>[\s\S]*?<\/TitlesOfParts>/, `<TitlesOfParts><vt:vector size="${kept.reduce((sum, [, list]) => sum + list.length, 0)}" baseType="lpstr">${items}</vt:vector></TitlesOfParts>`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Runs on the normalized output map (path -> [bytes, options]) after every
|
|
148
|
+
// other part rewrite, so the list reflects the final theme and slides.
|
|
149
|
+
export function finalizeFontsUsed(output) {
|
|
150
|
+
const app = output['docProps/app.xml'];
|
|
151
|
+
if (!app) return;
|
|
152
|
+
const parts = {};
|
|
153
|
+
for (const [path, [bytes]] of Object.entries(output)) if (/^ppt\/.*\.xml$/.test(path)) parts[path] = bytes;
|
|
154
|
+
const fonts = packageFontsUsed(inventoryPptxTypefaces(parts, {nested: false}));
|
|
155
|
+
const themeName = /<a:theme\b[^>]*\bname="([^"]*)"/.exec(parts['ppt/theme/theme1.xml'] ? text(parts['ppt/theme/theme1.xml']) : '')?.[1];
|
|
156
|
+
output['docProps/app.xml'] = [encoder.encode(writeFontsUsed(text(app[0]), fonts, themeName === undefined ? undefined : unescapeFace(themeName))), app[1]];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function unescapeFace(value) {
|
|
160
|
+
return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, name) => ({amp: '&', lt: '<', gt: '>', quot: '"', apos: "'"}[name]));
|
|
161
|
+
}
|