@openpresentation/opf-pptx 0.14.0 → 0.15.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/README.md +25 -23
- package/dist/background-import.js +33 -17
- package/dist/background.js +28 -25
- package/dist/chart-types.js +7 -22
- package/dist/document-provenance.js +195 -128
- package/dist/image-provenance.js +273 -0
- package/dist/index.d.ts +32 -17
- package/dist/index.js +268 -189
- package/dist/script-fonts.js +34 -60
- package/dist/theme-colors.js +15 -10
- package/dist/typeface-inventory.js +1 -1
- package/package.json +4 -4
- package/dist/slide-image-provenance.js +0 -177
package/dist/script-fonts.js
CHANGED
|
@@ -6,7 +6,12 @@
|
|
|
6
6
|
//
|
|
7
7
|
// When the resolver throws for a deck, the exporter writes what it wrote before FF-07 (lang="en-US", empty theme ea/cs, no
|
|
8
8
|
// rtl) and reports `language-export-unavailable`.
|
|
9
|
-
import {paragraphDirection, physicalAlignment, resolveScriptFonts as
|
|
9
|
+
import {paragraphDirection, physicalAlignment, resolveScriptFonts as coreResolveScriptFonts} from "@openpresentation/opf/composition";
|
|
10
|
+
|
|
11
|
+
// OPF 0.15 (FA-23): core resolves the font-scheme references a deck or a language object names against the document's
|
|
12
|
+
// catalogs and the host's registered `catalogs` (Catalog[]), the same option the slide context takes. `catalogs` is passed
|
|
13
|
+
// only when the host gave one, so core's own default applies otherwise.
|
|
14
|
+
const resolver = (input, options = {}, catalogs = undefined) => coreResolveScriptFonts(input, catalogs === undefined ? options : {...options, catalogs});
|
|
10
15
|
|
|
11
16
|
// RR-05: authored alignment is logical for right-to-left text (`left` is the start edge). Core owns the rule, and the
|
|
12
17
|
// paragraph-direction rule is the one the renderer shares (core FF-07).
|
|
@@ -18,12 +23,12 @@ const SCRIPT_SLOTS = [["ea", "eastAsian"], ["cs", "complexScript"]];
|
|
|
18
23
|
* Resolve the deck (slide 0, which also sets the theme) and every slide.
|
|
19
24
|
* Returns null when the resolver throws for the deck.
|
|
20
25
|
*/
|
|
21
|
-
export function planScriptFonts(presentation, report) {
|
|
26
|
+
export function planScriptFonts(presentation, report, {catalogs} = {}) {
|
|
22
27
|
const count = Array.isArray(presentation.slides) ? presentation.slides.length : 0;
|
|
23
28
|
let slides, deck;
|
|
24
29
|
try {
|
|
25
|
-
slides = Array.from({length: count}, (_, slideIndex) => resolver(presentation, {slideIndex}));
|
|
26
|
-
deck = slides[0] ?? resolver(presentation);
|
|
30
|
+
slides = Array.from({length: count}, (_, slideIndex) => resolver(presentation, {slideIndex}, catalogs));
|
|
31
|
+
deck = slides[0] ?? resolver(presentation, {}, catalogs);
|
|
27
32
|
} catch (error) {
|
|
28
33
|
// Keep exporting as before FF-07 rather than failing on the language model.
|
|
29
34
|
report?.({code: "language-export-unavailable", path: "language",
|
|
@@ -32,11 +37,11 @@ export function planScriptFonts(presentation, report) {
|
|
|
32
37
|
}
|
|
33
38
|
if (presentation.language !== undefined && deck.languageSource === "default") {
|
|
34
39
|
report?.({code: "language-unresolved", path: "language",
|
|
35
|
-
message: `The presentation language could not be resolved
|
|
40
|
+
message: `The presentation language could not be resolved (a BCP-47 tag whose script core does not know), so the PPTX uses ${deck.lang}.`});
|
|
36
41
|
}
|
|
37
42
|
// opf-pptx#168: which slides have notes (a presentation has one notes master; see reportPerSlideNotesScriptFonts).
|
|
38
43
|
const notes = (presentation.slides ?? []).map(slide => typeof slide?.notes === "string" ? slide.notes.trim() !== "" : Boolean(slide?.notes));
|
|
39
|
-
return {deck, slides, notes, report, lang: deck.lang, rtl: deck.rtl, presentation, contentEastAsian: contentEastAsianFonts(presentation, deck, report)};
|
|
44
|
+
return {deck, slides, notes, report, lang: deck.lang, rtl: deck.rtl, presentation, catalogs, contentEastAsian: contentEastAsianFonts(presentation, deck, report, catalogs)};
|
|
40
45
|
}
|
|
41
46
|
|
|
42
47
|
/**
|
|
@@ -50,7 +55,7 @@ export function runLanguageTag(plan, tag) {
|
|
|
50
55
|
if (!cache.has(tag)) {
|
|
51
56
|
let lang = tag;
|
|
52
57
|
try {
|
|
53
|
-
const resolved = resolver(plan.presentation, {language: tag});
|
|
58
|
+
const resolved = resolver(plan.presentation, {language: tag}, plan.catalogs);
|
|
54
59
|
if (resolved.languageSource !== "default") lang = resolved.lang;
|
|
55
60
|
} catch { /* an unresolvable tag is written as given */ }
|
|
56
61
|
cache.set(tag, lang.toLowerCase() === String(plan.lang).toLowerCase() ? undefined : lang);
|
|
@@ -87,7 +92,7 @@ function runScriptPlan(plan, slideIndex, lang) {
|
|
|
87
92
|
const cache = plan.runScripts ??= new Map(), key = `${slideIndex}|${lang}`;
|
|
88
93
|
if (!cache.has(key)) {
|
|
89
94
|
let resolved;
|
|
90
|
-
try { resolved = resolver(plan.presentation, {slideIndex, language: lang}); } catch { resolved = undefined; }
|
|
95
|
+
try { resolved = resolver(plan.presentation, {slideIndex, language: lang}, plan.catalogs); } catch { resolved = undefined; }
|
|
91
96
|
cache.set(key, resolved && resolved.languageSource !== "default" ? resolved : undefined);
|
|
92
97
|
}
|
|
93
98
|
return cache.get(key);
|
|
@@ -193,7 +198,7 @@ export function reportPerSlideNotesScriptFonts(plan, {themes, assignment}) {
|
|
|
193
198
|
// script of the text picks the language core resolves it for: kana is Japanese, hangul Korean, Han alone Simplified
|
|
194
199
|
// Chinese. Returns {heading, body} families, or null when the deck language already selects one or the text has none.
|
|
195
200
|
const KANA = /[\p{Script=Hiragana}\p{Script=Katakana}]/u, HANGUL = /\p{Script=Hangul}/u, HAN = /\p{Script=Han}/u;
|
|
196
|
-
function contentEastAsianFonts(presentation, deck, report) {
|
|
201
|
+
function contentEastAsianFonts(presentation, deck, report, catalogs) {
|
|
197
202
|
if (deck.sources.eastAsian !== "latin") return null;
|
|
198
203
|
let kana = false, hangul = false, han = false;
|
|
199
204
|
const seen = new Set();
|
|
@@ -206,10 +211,10 @@ function contentEastAsianFonts(presentation, deck, report) {
|
|
|
206
211
|
}
|
|
207
212
|
};
|
|
208
213
|
walk(presentation.slides);
|
|
209
|
-
const language = kana ? "
|
|
214
|
+
const language = kana ? "ja" : hangul ? "ko" : han ? "zh-Hans" : null;
|
|
210
215
|
if (!language) return null;
|
|
211
216
|
try {
|
|
212
|
-
const resolved = resolver({...presentation, language}, {slideIndex: 0});
|
|
217
|
+
const resolved = resolver({...presentation, language}, {slideIndex: 0}, catalogs);
|
|
213
218
|
return resolved.sources.eastAsian === "latin" ? null : {heading: resolved.heading.eastAsian, body: resolved.body.eastAsian};
|
|
214
219
|
} catch (error) {
|
|
215
220
|
report?.({code: "language-export-unavailable", path: "language",
|
|
@@ -425,36 +430,14 @@ export function partScriptFonts(path, xml, plan, slideIndex) {
|
|
|
425
430
|
const LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
|
|
426
431
|
const attribute = (xml, name) => new RegExp(`\\s${name}="([^"]*)"`).exec(xml)?.[1];
|
|
427
432
|
|
|
428
|
-
/**
|
|
429
|
-
* Match an OOXML run language to the OPF language to import. A catalog id is
|
|
430
|
-
* used when that record exports exactly this tag again: an exact BCP-47 tag,
|
|
431
|
-
* then a curated `ooxmlLang` (preferring the same primary language). A tag
|
|
432
|
-
* that core still resolves to a catalog record (for example en-NZ to English)
|
|
433
|
-
* is imported as the tag itself, so it round-trips.
|
|
434
|
-
* Returns {language, record, shared} or null when nothing matches.
|
|
435
|
-
*/
|
|
436
|
-
export function matchCatalogLanguage(lang, catalogs) {
|
|
437
|
-
const records = Array.isArray(catalogs?.languages) ? catalogs.languages : [];
|
|
438
|
-
const key = lang.toLowerCase(), primary = key.split("-")[0];
|
|
439
|
-
const lower = value => typeof value === "string" ? value.toLowerCase() : undefined;
|
|
440
|
-
const shared = records.filter(record => lower(record.bcp47) === key || lower(record.ooxmlLang) === key);
|
|
441
|
-
const exports = record => resolver({language: record.id}).lang.toLowerCase() === key;
|
|
442
|
-
const record = shared.find(record => lower(record.bcp47) === key && exports(record))
|
|
443
|
-
?? shared.find(record => lower(record.bcp47)?.split("-")[0] === primary && exports(record))
|
|
444
|
-
?? shared.find(exports);
|
|
445
|
-
if (record) return {language: record.id, record, shared};
|
|
446
|
-
const resolved = resolver({language: lang});
|
|
447
|
-
// A tag core cannot resolve falls back to its default language; that is no match.
|
|
448
|
-
const matched = resolved.languageSource !== "default" && resolved.languageId && records.find(candidate => candidate.id === resolved.languageId);
|
|
449
|
-
return matched ? {language: lang, record: matched, shared: []} : null;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
433
|
/**
|
|
453
434
|
* Observe the presentation language in run `lang`. `slides` and `theme` are
|
|
454
|
-
* XML strings. `language` is
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
435
|
+
* XML strings. `language` is the dominant run tag, imported as a BCP-47 tag
|
|
436
|
+
* string (OPF 0.15: language is an engine vocabulary, not a catalog), or
|
|
437
|
+
* undefined when the runs carry no language; `lang` is the same tag.
|
|
438
|
+
* `catalogs` are the host's registered catalogs, for the font schemes core
|
|
439
|
+
* resolves. Nothing is reported here: languageDiagnostics() reports against
|
|
440
|
+
* the final imported document, after FF-32 provenance.
|
|
458
441
|
*/
|
|
459
442
|
export function observeLanguage({slides, theme, themePath, slideThemes, catalogs}) {
|
|
460
443
|
const counts = new Map();
|
|
@@ -469,8 +452,7 @@ export function observeLanguage({slides, theme, themePath, slideThemes, catalogs
|
|
|
469
452
|
}
|
|
470
453
|
const ranked = [...counts].sort((left, right) => right[1] - left[1] || (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0));
|
|
471
454
|
const lang = ranked[0]?.[0];
|
|
472
|
-
|
|
473
|
-
return {lang, language: lang === undefined ? undefined : match?.language ?? lang, match, ranked, rtlParagraphs, theme, themePath, slideThemes};
|
|
455
|
+
return {lang, language: lang, ranked, rtlParagraphs, theme, themePath, slideThemes, catalogs};
|
|
474
456
|
}
|
|
475
457
|
|
|
476
458
|
/**
|
|
@@ -485,29 +467,27 @@ export function reconcileLanguage(groups, observed, report) {
|
|
|
485
467
|
if (index < 0 || observed.lang === undefined) return groups;
|
|
486
468
|
const stored = groups[index].ops.find(op => op.path?.length === 1 && op.path[0] === "language")?.value;
|
|
487
469
|
if (stored === undefined) return groups;
|
|
488
|
-
const resolved = resolver({language: stored});
|
|
470
|
+
const resolved = resolver({language: stored}, {}, observed.catalogs);
|
|
489
471
|
if (resolved.languageSource === "default" || resolved.lang.toLowerCase() === observed.lang.toLowerCase()) return groups;
|
|
490
472
|
report?.({code: "metadata-reference-changed", path: "language",
|
|
491
473
|
message: `Runs now use ${observed.lang}, not ${resolved.lang} of the stored language, so the stored language was not restored; the imported language follows the runs.`});
|
|
492
474
|
return groups.filter((_, position) => position !== index);
|
|
493
475
|
}
|
|
494
476
|
|
|
495
|
-
/**
|
|
496
|
-
* Diagnostics for the final imported document: several run languages, a run
|
|
497
|
-
* tag that maps ambiguously or to no catalog record (only when that observed
|
|
498
|
-
* language was kept), right-to-left paragraphs under a left-to-right
|
|
499
|
-
* language, and theme East Asian/complex-script fonts the document does not
|
|
500
|
-
* reproduce.
|
|
501
|
-
*/
|
|
502
477
|
/** Whether the language observed in the runs is written right to left. */
|
|
503
478
|
export function observedRtl(observed) {
|
|
504
479
|
if (observed.lang === undefined) return false;
|
|
505
|
-
try { return resolver({language: observed.language}).rtl === true; } catch { return false; }
|
|
480
|
+
try { return resolver({language: observed.language}, {}, observed.catalogs).rtl === true; } catch { return false; }
|
|
506
481
|
}
|
|
507
482
|
|
|
483
|
+
/**
|
|
484
|
+
* Diagnostics for the final imported document: several run languages,
|
|
485
|
+
* right-to-left paragraphs under a left-to-right language, and theme East
|
|
486
|
+
* Asian/complex-script fonts the document does not reproduce.
|
|
487
|
+
*/
|
|
508
488
|
export function languageDiagnostics(imported, observed, report) {
|
|
509
489
|
if (!report) return;
|
|
510
|
-
const {lang,
|
|
490
|
+
const {lang, ranked, rtlParagraphs, theme, catalogs} = observed;
|
|
511
491
|
if (lang === undefined) {
|
|
512
492
|
if (rtlParagraphs) report({code: "rtl-language-mismatch", path: "language", message: `${rtlParagraphs} right-to-left paragraph(s) carry no run language, so no presentation language was imported.`});
|
|
513
493
|
return;
|
|
@@ -518,19 +498,13 @@ export function languageDiagnostics(imported, observed, report) {
|
|
|
518
498
|
report({code: "mixed-run-languages", path: "language",
|
|
519
499
|
message: `Runs use ${languages.length} languages (${languages.map(([tag, count]) => `${tag} x${count}`).join(", ")}). OPF has one presentation language, so ${lang} was imported; a run in another language keeps it as its own lang where the text imports as rich text.`});
|
|
520
500
|
}
|
|
521
|
-
const
|
|
522
|
-
if (kept && !match) report({code: "language-uncatalogued", path: "language", message: `Run language ${lang} matches no languages catalog record; it was imported as a BCP-47 tag.`});
|
|
523
|
-
else if (kept && match.shared.length > 1 && !match.shared.some(record => record.bcp47?.toLowerCase() === lang.toLowerCase())) {
|
|
524
|
-
report({code: "language-ambiguous", path: "language",
|
|
525
|
-
message: `Run language ${lang} is the OOXML tag of ${match.shared.map(record => record.id).join(", ")}; ${match.language} was imported.`});
|
|
526
|
-
}
|
|
527
|
-
const resolved = resolver(imported);
|
|
501
|
+
const resolved = resolver(imported, {}, catalogs);
|
|
528
502
|
if (rtlParagraphs && !resolved.rtl) {
|
|
529
503
|
report({code: "rtl-language-mismatch", path: "language", message: `${rtlParagraphs} right-to-left paragraph(s) do not match the left-to-right language ${typeof imported.language === "string" ? imported.language : lang}; paragraph direction is not imported separately.`});
|
|
530
504
|
}
|
|
531
505
|
// The presentation theme carries the first slide's script fonts (planScriptFonts), so it is compared with that slide.
|
|
532
506
|
let themeResolved = resolved;
|
|
533
|
-
if (imported.slides?.length) { try { themeResolved = resolver(imported, {slideIndex: 0}); } catch { themeResolved = resolved; } }
|
|
507
|
+
if (imported.slides?.length) { try { themeResolved = resolver(imported, {slideIndex: 0}, catalogs); } catch { themeResolved = resolved; } }
|
|
534
508
|
if (theme && themeResolved) {
|
|
535
509
|
for (const {tag, slot, face} of themeScriptMismatches(theme, themeResolved)) {
|
|
536
510
|
report({code: "script-font-not-imported", path: "design.fontScheme",
|
|
@@ -542,7 +516,7 @@ export function languageDiagnostics(imported, observed, report) {
|
|
|
542
516
|
observed.slideThemes.forEach((slideTheme, index) => {
|
|
543
517
|
if (!slideTheme || slideTheme.path === observed.themePath) return;
|
|
544
518
|
let slideResolved;
|
|
545
|
-
try { slideResolved = resolver(imported, {slideIndex: index}); } catch { return; }
|
|
519
|
+
try { slideResolved = resolver(imported, {slideIndex: index}, catalogs); } catch { return; }
|
|
546
520
|
for (const {tag, slot, face} of themeScriptMismatches(slideTheme.xml, slideResolved)) {
|
|
547
521
|
report({code: "script-font-not-imported", path: `slides.${index}.design.fontScheme`,
|
|
548
522
|
message: `Slide ${index + 1}'s master theme ${tag} ${slot} font "${face}" differs from both its latin font and what the imported slide resolves, and is not represented in the imported OPF.`});
|
package/dist/theme-colors.js
CHANGED
|
@@ -213,9 +213,11 @@ function recordColors(record) {
|
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
215
|
* Recover design.colorScheme from theme clrScheme colors.
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
216
|
+
* `records` are the colour schemes a bare id can name in the imported document
|
|
217
|
+
* (FA-23: the host default catalog's, each with its key as `id`; none when the
|
|
218
|
+
* host registers no catalog). An exact twelve-slot match yields that bare id
|
|
219
|
+
* (the clrScheme name breaks ties). Otherwise the colors are inline: relative
|
|
220
|
+
* to the record whose name the clrScheme carries, or as all readable slots.
|
|
219
221
|
*/
|
|
220
222
|
export function recoverColorScheme({colors, unreadable, name}, records) {
|
|
221
223
|
const complete = unreadable.length === 0;
|
|
@@ -245,19 +247,22 @@ export function presentationThemePath(presentationRoot, presentationRels, relati
|
|
|
245
247
|
}
|
|
246
248
|
|
|
247
249
|
/**
|
|
248
|
-
* Identify a catalog theme from the native theme name.
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
250
|
+
* Identify a catalog theme from the native theme name. `themes` are the theme
|
|
251
|
+
* records a bare id can name (as for recoverColorScheme, each with its key as
|
|
252
|
+
* `id`); `colorScheme(theme)` and `fontFamilies(theme)` resolve the theme's own
|
|
253
|
+
* references (null when they resolve nowhere). The name must equal a theme
|
|
254
|
+
* name, and the package must corroborate it with that theme's color scheme or
|
|
255
|
+
* its heading/body font pair, so a foreign deck that happens to use a theme
|
|
256
|
+
* called "Bold" is not bound to the OPF theme.
|
|
252
257
|
*/
|
|
253
|
-
export function recoverTheme({themeName, colors, majorFont, minorFont}, {themes,
|
|
258
|
+
export function recoverTheme({themeName, colors, majorFont, minorFont}, {themes, colorScheme, fontFamilies}) {
|
|
254
259
|
if (!themeName) return {};
|
|
255
260
|
const theme = themes.find(record => record.name === themeName);
|
|
256
261
|
if (!theme) return {};
|
|
257
|
-
const scheme =
|
|
262
|
+
const scheme = colorScheme(theme);
|
|
258
263
|
const schemeColors = scheme ? recordColors(scheme) : null;
|
|
259
264
|
const colorMatch = !!schemeColors && THEME_SLOTS.every(([slot]) => schemeColors[slot] === colors[slot]);
|
|
260
|
-
const fonts = fontFamilies(theme
|
|
265
|
+
const fonts = fontFamilies(theme);
|
|
261
266
|
const fontMatch = !!fonts && fonts.heading === majorFont && fonts.body === minorFont;
|
|
262
267
|
return colorMatch || fontMatch ? {id: theme.id} : {unverified: theme.id};
|
|
263
268
|
}
|
|
@@ -117,7 +117,7 @@ export function checkTypefaces(input, options = {}) {
|
|
|
117
117
|
}
|
|
118
118
|
const reference = THEME_REFERENCE.exec(entry.typeface);
|
|
119
119
|
if (reference) {
|
|
120
|
-
if (entry.resolved === null) fail(entry, '
|
|
120
|
+
if (entry.resolved === null) fail(entry, 'theme-reference-unresolved');
|
|
121
121
|
else if (entry.resolved === '') { if (!allowEmpty || reference[2] === 'lt') fail(entry, 'empty-theme-reference'); }
|
|
122
122
|
else if (!chosen.has(entry.resolved)) fail(entry, 'foreign-theme-reference', {resolved: entry.resolved});
|
|
123
123
|
continue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
},
|
|
66
66
|
"dependencies": {
|
|
67
67
|
"@node-projects/jszip": "4.3.0",
|
|
68
|
-
"@openpresentation/opf": "^0.
|
|
68
|
+
"@openpresentation/opf": "^0.15.1",
|
|
69
69
|
"@rgrove/parse-xml": "4.2.3",
|
|
70
70
|
"fast-xml-parser": "^5.8.0",
|
|
71
71
|
"fflate": "^0.8.3",
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
"sharp": "0.35.5"
|
|
74
74
|
},
|
|
75
75
|
"peerDependencies": {
|
|
76
|
-
"@openpresentation/opf-render": "^0.
|
|
76
|
+
"@openpresentation/opf-render": "^0.15.0"
|
|
77
77
|
},
|
|
78
78
|
"peerDependenciesMeta": {
|
|
79
79
|
"@openpresentation/opf-render": {
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@expo-google-fonts/carlito": "0.4.1",
|
|
85
|
-
"@openpresentation/opf-render": "0.
|
|
85
|
+
"@openpresentation/opf-render": "0.15.0",
|
|
86
86
|
"esbuild": "0.28.2",
|
|
87
87
|
"playwright": "1.63.0"
|
|
88
88
|
},
|
|
@@ -1,177 +0,0 @@
|
|
|
1
|
-
import {XMLParser} from 'fast-xml-parser';
|
|
2
|
-
import {normalizeCrop, withoutSvgBlip} from './svg-image.js';
|
|
3
|
-
import {attachTextTags, decodeTextTag} from './code-provenance.js';
|
|
4
|
-
import {framedPictureTransform} from './image-geometry.js';
|
|
5
|
-
|
|
6
|
-
// A slide-level image (design.slideImage) exports as one native picture. Its
|
|
7
|
-
// tag records the OPF placement and the exact native geometry, so an unchanged
|
|
8
|
-
// picture imports back as design.slideImage while an edited one stays ordinary.
|
|
9
|
-
const TAG = 'OPF_SLIDE_IMAGE_V1', OVERLAY_TAG = 'OPF_SLIDE_IMAGE_OVERLAY_V1', REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
|
|
10
|
-
const EMUS_PER_INCH = 914400;
|
|
11
|
-
const parser = new XMLParser({ignoreAttributes:false, attributeNamePrefix:'', parseTagValue:false, trimValues:false});
|
|
12
|
-
const decoder = new TextDecoder('utf-8', {fatal:true}), encoder = new TextEncoder();
|
|
13
|
-
const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
14
|
-
// Whitespace-only text between elements is not identity: the vendored writer indents, PowerPoint drops it on save.
|
|
15
|
-
const canonical = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
|
|
16
|
-
? Object.fromEntries(Object.keys(item).sort().filter(key => !(key === '#text' && typeof item[key] === 'string' && !item[key].trim())).map(key => [key, item[key]])) : item);
|
|
17
|
-
const POSITIONS = ['background', 'top', 'bottom', 'left', 'right'];
|
|
18
|
-
|
|
19
|
-
export const slideImageName = slidePath => `OPF slide image ${slidePath}`;
|
|
20
|
-
export const slideImageOverlayName = slidePath => `OPF slide image overlay ${slidePath}`;
|
|
21
|
-
|
|
22
|
-
// DrawingML for the shared treatment: preset geometry (core guide values), a
|
|
23
|
-
// centered solid line, and blip recolor/alpha effects applied in that order.
|
|
24
|
-
const presetXml = shape => `<a:prstGeom prst="${shape?.preset ?? 'rect'}"><a:avLst>${Object.entries(shape?.adjust ?? {}).map(([name, value]) => `<a:gd name="${name}" fmla="val ${value}"/>`).join('')}</a:avLst></a:prstGeom>`;
|
|
25
|
-
const colorXml = ({hex, alpha}, extraAlpha = 1) => {
|
|
26
|
-
const value = Math.round(alpha * extraAlpha * 100000);
|
|
27
|
-
return `<a:srgbClr val="${hex}">${value < 100000 ? `<a:alpha val="${value}"/>` : ''}</a:srgbClr>`;
|
|
28
|
-
};
|
|
29
|
-
const lineXml = border => border ? `<a:ln w="${Math.round(border.width / 96 * EMUS_PER_INCH)}" algn="ctr"><a:solidFill>${colorXml(border)}</a:solidFill><a:miter lim="800000"/></a:ln>` : '';
|
|
30
|
-
function blipEffects(effects) {
|
|
31
|
-
let xml = '';
|
|
32
|
-
if (effects?.recolor?.type === 'grayscale') xml += '<a:grayscl/>';
|
|
33
|
-
if (effects?.recolor?.type === 'duotone') xml += `<a:duotone>${colorXml({...effects.recolor.dark, alpha: 1})}${colorXml({...effects.recolor.light, alpha: 1})}</a:duotone>`;
|
|
34
|
-
if (typeof effects?.opacity === 'number') xml += `<a:alphaModFix amt="${Math.round(effects.opacity * 100000)}"/>`;
|
|
35
|
-
return xml;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Native blip fill without its package-local relationship id.
|
|
39
|
-
function blipFillIdentity(blipFill) {
|
|
40
|
-
const {['a:blip']: blip, ...rest} = normalizeCrop(blipFill) ?? {};
|
|
41
|
-
const {['r:embed']: _embed, ...blipRest} = withoutSvgBlip(blip) ?? {};
|
|
42
|
-
return {...rest, 'a:blip': blipRest};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Place each generated slide-image picture at its frame (crop/fit through
|
|
47
|
-
* a:srcRect), then bind the picture to its manifest with a native tag.
|
|
48
|
-
* images: Map<objectName, {slide, box (inches), fill, treatment, path}>.
|
|
49
|
-
*/
|
|
50
|
-
export function placeSlideImages(entries, images, metadataFor, fail) {
|
|
51
|
-
if (!images.size) return;
|
|
52
|
-
const manifests = new Map(), overlays = new Map();
|
|
53
|
-
for (const part of Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path))) {
|
|
54
|
-
const xml = decoder.decode(entries[part]);
|
|
55
|
-
const next = xml.replace(/<p:pic>[\s\S]*?<\/p:pic>/g, picture => {
|
|
56
|
-
const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
57
|
-
const image = images.get(name);
|
|
58
|
-
if (!image) return picture;
|
|
59
|
-
const embed = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
|
|
60
|
-
const metadata = metadataFor(part, embed);
|
|
61
|
-
if (!metadata) fail(image.path);
|
|
62
|
-
const placed = framedPictureTransform(metadata, image.box, image.fill);
|
|
63
|
-
const emu = value => Math.round(value * EMUS_PER_INCH);
|
|
64
|
-
const attributes = `${placed.rotation ? ` rot="${placed.rotation * 60000}"` : ''}${placed.flipH ? ' flipH="1"' : ''}${placed.flipV ? ' flipV="1"' : ''}`;
|
|
65
|
-
picture = picture.replace(/<a:xfrm\b[^>]*>[\s\S]*?<\/a:xfrm>/, `<a:xfrm${attributes}><a:off x="${emu(placed.x)}" y="${emu(placed.y)}"/><a:ext cx="${emu(placed.w)}" cy="${emu(placed.h)}"/></a:xfrm>`);
|
|
66
|
-
picture = picture.replace(/<a:srcRect\b[^>]*\/>/, '');
|
|
67
|
-
if (placed.crop) picture = picture.replace('<a:stretch>', `<a:srcRect l="${placed.crop.l}" t="${placed.crop.t}" r="${placed.crop.r}" b="${placed.crop.b}"/><a:stretch>`);
|
|
68
|
-
const effects = image.effects;
|
|
69
|
-
if (effects) {
|
|
70
|
-
picture = picture.replace(/<a:prstGeom\b[\s\S]*?<\/a:prstGeom>/, presetXml(effects.shape) + lineXml(effects.border));
|
|
71
|
-
const blip = blipEffects(effects);
|
|
72
|
-
if (blip) picture = picture.replace(/<a:blip\b([^>]*?)(?:\/>|>([\s\S]*?)<\/a:blip>)/, (_, attributes, inner = '') => `<a:blip${attributes}>${blip}${inner}</a:blip>`);
|
|
73
|
-
}
|
|
74
|
-
const node = parser.parse(picture)['p:pic'];
|
|
75
|
-
manifests.set(name, {v:1, slide:image.slide, position:image.treatment.position, fill:image.fill, treatment:image.treatment,
|
|
76
|
-
...(image.content === true ? {content:true} : {}),
|
|
77
|
-
properties:node['p:spPr'], blipFill:blipFillIdentity(node['p:blipFill'])});
|
|
78
|
-
return picture;
|
|
79
|
-
});
|
|
80
|
-
const shaped = next.replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape => {
|
|
81
|
-
const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
|
|
82
|
-
const image = [...images.values()].find(candidate => slideImageOverlayName(candidate.slide) === name);
|
|
83
|
-
const overlay = image?.effects?.overlay;
|
|
84
|
-
if (!overlay) return shape;
|
|
85
|
-
const emu = value => Math.round(value / 96 * EMUS_PER_INCH);
|
|
86
|
-
shape = shape.replace(/<p:spPr>[\s\S]*?<\/p:spPr>/, `<p:spPr><a:xfrm><a:off x="${emu(overlay.box.x)}" y="${emu(overlay.box.y)}"/><a:ext cx="${emu(overlay.box.width)}" cy="${emu(overlay.box.height)}"/></a:xfrm>${presetXml(overlay.shape)}<a:solidFill>${colorXml(overlay, overlay.opacity)}</a:solidFill><a:ln><a:noFill/></a:ln></p:spPr>`);
|
|
87
|
-
const node = parser.parse(shape)['p:sp'];
|
|
88
|
-
overlays.set(name, {v:1, slide:image.slide, properties:node['p:spPr']});
|
|
89
|
-
return shape;
|
|
90
|
-
});
|
|
91
|
-
if (shaped !== xml) entries[part] = encoder.encode(shaped);
|
|
92
|
-
}
|
|
93
|
-
if (manifests.size !== images.size) throw new Error('Missing generated slide image picture.');
|
|
94
|
-
attachTextTags(entries, manifests, TAG, 'opfSlideImage', 'slide image', {pictures:true});
|
|
95
|
-
attachTextTags(entries, overlays, OVERLAY_TAG, 'opfSlideImageOverlay', 'slide image overlay');
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function readTags(container, relationships, entries) {
|
|
99
|
-
const tags = [];
|
|
100
|
-
let unreadable = false;
|
|
101
|
-
for (const link of array(container?.['p:tags'])) {
|
|
102
|
-
const rel = relationships.get(link['r:id']);
|
|
103
|
-
if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) { unreadable = true; continue; }
|
|
104
|
-
try { tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag'])); } catch { unreadable = true; }
|
|
105
|
-
}
|
|
106
|
-
return {tags, unreadable};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function validTreatment(treatment, position) {
|
|
110
|
-
return treatment && typeof treatment === 'object' && !Array.isArray(treatment) && treatment.position === position
|
|
111
|
-
&& POSITIONS.includes(position) && treatment.src === undefined;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Recover design.slideImage from an unchanged tagged picture. Returns the
|
|
116
|
-
* consumed picture indexes and the slide design fields to restore.
|
|
117
|
-
* readPicture(picture) returns the ordinary imported image payload.
|
|
118
|
-
*/
|
|
119
|
-
export function importSlideImage(pictures, shapes, relationships, entries, slideIndex, readPicture, report) {
|
|
120
|
-
const consumed = new Set(), consumedShapes = new Set(), found = [];
|
|
121
|
-
for (const [index, picture] of pictures.entries()) {
|
|
122
|
-
const {tags, unreadable} = readTags(picture['p:nvPicPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries);
|
|
123
|
-
const own = tags.filter(tag => tag.name?.toUpperCase() === TAG);
|
|
124
|
-
if (!own.length) continue;
|
|
125
|
-
found.push({index, picture, own, unreadable, others: tags.some(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== TAG)});
|
|
126
|
-
}
|
|
127
|
-
if (!found.length) return {consumed, consumedShapes};
|
|
128
|
-
const invalid = () => report({code:'invalid-slide-image-provenance', message:'An edited, ambiguous or invalid tagged OPF slide image was imported as an ordinary picture. Its native placement is not reconstructed as design.slideImage.'});
|
|
129
|
-
if (found.length > 1) { invalid(); return {consumed, consumedShapes}; }
|
|
130
|
-
const [{index, picture, own, unreadable, others}] = found;
|
|
131
|
-
try {
|
|
132
|
-
if (unreadable || others || own.length !== 1) throw Error('Ambiguous slide image identity.');
|
|
133
|
-
const manifest = decodeTextTag(own[0].val);
|
|
134
|
-
if (manifest?.v !== 1 || manifest.slide !== `slides.${slideIndex}` || !['crop', 'fit'].includes(manifest.fill) || !validTreatment(manifest.treatment, manifest.position)) throw Error('Invalid slide image manifest.');
|
|
135
|
-
if (manifest.content !== undefined && manifest.content !== true) throw Error('Invalid slide image content flag.');
|
|
136
|
-
if (picture['p:nvPicPr']?.['p:cNvPr']?.name !== slideImageName(manifest.slide)) throw Error('Slide image identity changed.');
|
|
137
|
-
if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(normalizeCrop(manifest.blipFill))) throw Error('Slide image geometry or effects changed.');
|
|
138
|
-
const item = readPicture(picture);
|
|
139
|
-
const src = item?.payload?.image?.src;
|
|
140
|
-
if (typeof src !== 'string') throw Error('Slide image bytes are unavailable.');
|
|
141
|
-
consumed.add(index);
|
|
142
|
-
const treatment = {...manifest.treatment};
|
|
143
|
-
if (treatment.overlay !== undefined) {
|
|
144
|
-
// The scrim is a separate native shape; recover it only while it is unchanged.
|
|
145
|
-
const overlay = findOverlay(shapes, relationships, entries, manifest.slide);
|
|
146
|
-
if (overlay === null) {
|
|
147
|
-
delete treatment.overlay;
|
|
148
|
-
report({code:'invalid-slide-image-provenance', message:'The tagged OPF slide image overlay is missing, edited or ambiguous. The slide image is recovered without its overlay; any remaining native shape is imported as ordinary content.'});
|
|
149
|
-
} else consumedShapes.add(overlay);
|
|
150
|
-
}
|
|
151
|
-
// FF-53: a picture that replaced the slide's root `image` (same source as the slide image) restores that
|
|
152
|
-
// payload too, so the round trip keeps `slide.image` with its alt text as well as design.slideImage.
|
|
153
|
-
const alt = item.payload.image.alt;
|
|
154
|
-
const content = manifest.content === true ? {image: {src, ...(typeof alt === 'string' && alt ? {alt} : {})}} : {};
|
|
155
|
-
return {consumed, consumedShapes, design: {slideImage: {...treatment, src}, ...(manifest.fill === 'fit' ? {imageFill: 'fit'} : {})}, ...content};
|
|
156
|
-
} catch {
|
|
157
|
-
invalid();
|
|
158
|
-
return {consumed, consumedShapes};
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function findOverlay(shapes, relationships, entries, slide) {
|
|
163
|
-
const matches = [];
|
|
164
|
-
for (const [index, shape] of shapes.entries()) {
|
|
165
|
-
const {tags, unreadable} = readTags(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries);
|
|
166
|
-
const own = tags.filter(tag => tag.name?.toUpperCase() === OVERLAY_TAG);
|
|
167
|
-
if (!own.length) continue;
|
|
168
|
-
try {
|
|
169
|
-
if (unreadable || own.length !== 1 || tags.some(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== OVERLAY_TAG)) throw Error('Ambiguous overlay identity.');
|
|
170
|
-
const manifest = decodeTextTag(own[0].val);
|
|
171
|
-
if (manifest?.v !== 1 || manifest.slide !== slide || shape['p:nvSpPr']?.['p:cNvPr']?.name !== slideImageOverlayName(slide)) throw Error('Invalid overlay identity.');
|
|
172
|
-
if (canonical(shape['p:spPr']) !== canonical(manifest.properties)) throw Error('Overlay changed.');
|
|
173
|
-
matches.push(index);
|
|
174
|
-
} catch { return null; }
|
|
175
|
-
}
|
|
176
|
-
return matches.length === 1 ? matches[0] : null;
|
|
177
|
-
}
|