@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.
@@ -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 resolver} from "@openpresentation/opf/composition";
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 locally (a URL, pkg: reference or unknown id), so the PPTX uses ${deck.lang}.`});
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 ? "japanese" : hangul ? "korean" : han ? "chinese-simplified" : null;
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 a catalog id, the run's tag when no catalog
455
- * record matches, or undefined when the runs carry no language; `lang` is the
456
- * dominant run tag itself. Nothing is reported here: languageDiagnostics()
457
- * reports against the final imported document, after FF-32 provenance.
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
- const match = lang === undefined ? null : matchCatalogLanguage(lang, catalogs);
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, match, ranked, rtlParagraphs, theme} = observed;
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 kept = imported.language === observed.language;
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.`});
@@ -213,9 +213,11 @@ function recordColors(record) {
213
213
 
214
214
  /**
215
215
  * Recover design.colorScheme from theme clrScheme colors.
216
- * An exact twelve-slot match with a catalog record yields its id (the clrScheme
217
- * name breaks ties). Otherwise the colors are inline: relative to the record
218
- * whose name the clrScheme carries, or as all readable slots.
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. The name must equal a
249
- * catalog theme name, and the package must corroborate it with that theme's
250
- * color scheme or its heading/body font pair, so a foreign deck that happens to
251
- * use a theme called "Bold" is not bound to the OPF theme.
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, colorSchemes, fontFamilies}) {
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 = colorSchemes.find(record => record.id === theme.colorScheme);
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.fontScheme);
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, 'unresolved-theme-reference');
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.14.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.14.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.14.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.14.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
- }