@openpresentation/opf-pptx 0.13.2 → 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.
@@ -4,43 +4,31 @@
4
4
  // reads it back on import. Design: core docs/programs/font-fidelity-everywhere/
5
5
  // script-font-model.md ("OOXML mapping").
6
6
  //
7
- // Core releases before the resolver (0.11.0 and earlier) export no
8
- // resolveScriptFonts. The exporter then writes exactly what it wrote before
9
- // FF-07 (lang="en-US", empty theme ea/cs, no rtl) and reports
10
- // `language-export-unavailable` for a document that names a language. A core
11
- // with the resolver but without paragraphDirection() marks no paragraph
12
- // direction and reports `paragraph-direction-unavailable` for an RTL deck.
13
- import * as opfCore from "@openpresentation/opf";
14
-
15
- const resolver = typeof opfCore.resolveScriptFonts === "function" ? opfCore.resolveScriptFonts : null;
16
- // RR-05: authored alignment is logical for right-to-left text (`left` is the start edge). Core owns the rule; a core without it
17
- // reports no line directions either, so the fallback never has anything to flip.
18
- export const physicalAlignment = typeof opfCore.physicalAlignment === "function" ? opfCore.physicalAlignment : alignment => alignment;
19
- // The one paragraph-direction rule shared with the renderer (core FF-07).
20
- const paragraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
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
+ // rtl) and reports `language-export-unavailable`.
9
+ import {paragraphDirection, physicalAlignment, resolveScriptFonts as coreResolveScriptFonts} from "@openpresentation/opf/composition";
21
10
 
22
- const SCRIPT_SLOTS = [["ea", "eastAsian"], ["cs", "complexScript"]];
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});
23
15
 
24
- /** Whether the installed core exports the FF-18 language/script resolver. */
25
- export function scriptFontsAvailable() {
26
- return resolver !== null;
27
- }
16
+ // RR-05: authored alignment is logical for right-to-left text (`left` is the start edge). Core owns the rule, and the
17
+ // paragraph-direction rule is the one the renderer shares (core FF-07).
18
+ export {physicalAlignment};
19
+
20
+ const SCRIPT_SLOTS = [["ea", "eastAsian"], ["cs", "complexScript"]];
28
21
 
29
22
  /**
30
23
  * Resolve the deck (slide 0, which also sets the theme) and every slide.
31
- * Returns null when core has no resolver.
24
+ * Returns null when the resolver throws for the deck.
32
25
  */
33
- export function planScriptFonts(presentation, report) {
34
- if (!resolver) {
35
- if (presentation.language !== undefined) report?.({code: "language-export-unavailable", path: "language",
36
- message: "The installed @openpresentation/opf has no resolveScriptFonts, so the PPTX keeps lang=\"en-US\", empty theme East Asian/complex-script fonts and left-to-right paragraphs. Use a core release with the FF-18 language model."});
37
- return null;
38
- }
26
+ export function planScriptFonts(presentation, report, {catalogs} = {}) {
39
27
  const count = Array.isArray(presentation.slides) ? presentation.slides.length : 0;
40
28
  let slides, deck;
41
29
  try {
42
- slides = Array.from({length: count}, (_, slideIndex) => resolver(presentation, {slideIndex}));
43
- deck = slides[0] ?? resolver(presentation);
30
+ slides = Array.from({length: count}, (_, slideIndex) => resolver(presentation, {slideIndex}, catalogs));
31
+ deck = slides[0] ?? resolver(presentation, {}, catalogs);
44
32
  } catch (error) {
45
33
  // Keep exporting as before FF-07 rather than failing on the language model.
46
34
  report?.({code: "language-export-unavailable", path: "language",
@@ -49,17 +37,65 @@ export function planScriptFonts(presentation, report) {
49
37
  }
50
38
  if (presentation.language !== undefined && deck.languageSource === "default") {
51
39
  report?.({code: "language-unresolved", path: "language",
52
- message: `The presentation language could not be resolved locally (a URL, pkg: reference or unknown id), so the PPTX uses ${deck.lang}.`});
53
- }
54
- let rtl = deck.rtl;
55
- if (rtl && !paragraphDirection) {
56
- rtl = false;
57
- report?.({code: "paragraph-direction-unavailable", path: "language",
58
- message: "The installed @openpresentation/opf has no paragraphDirection, so right-to-left paragraphs are not marked; the preview and export must share that rule. Use a core release that exports it."});
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}.`});
59
41
  }
60
42
  // opf-pptx#168: which slides have notes (a presentation has one notes master; see reportPerSlideNotesScriptFonts).
61
43
  const notes = (presentation.slides ?? []).map(slide => typeof slide?.notes === "string" ? slide.notes.trim() !== "" : Boolean(slide?.notes));
62
- return {deck, slides, notes, report, lang: deck.lang, rtl, 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)};
45
+ }
46
+
47
+ /**
48
+ * FA-13: the OOXML tag a run's own `lang` (TextRun.lang) is written with: core's curated tag for the language (fr becomes
49
+ * fr-FR), or undefined when it is the deck language (the run then follows the deck). Without a plan the tag is written as given.
50
+ */
51
+ export function runLanguageTag(plan, tag) {
52
+ if (typeof tag !== "string" || !tag) return undefined;
53
+ if (!plan) return tag.toLowerCase() === "en-us" ? undefined : tag;
54
+ const cache = plan.runLanguages ??= new Map();
55
+ if (!cache.has(tag)) {
56
+ let lang = tag;
57
+ try {
58
+ const resolved = resolver(plan.presentation, {language: tag}, plan.catalogs);
59
+ if (resolved.languageSource !== "default") lang = resolved.lang;
60
+ } catch { /* an unresolvable tag is written as given */ }
61
+ cache.set(tag, lang.toLowerCase() === String(plan.lang).toLowerCase() ? undefined : lang);
62
+ }
63
+ return cache.get(tag);
64
+ }
65
+
66
+ /**
67
+ * FA-13: a run with its own language (TextRun.lang) names the East Asian and complex-script faces of that language, so a Han
68
+ * run tagged ja-JP draws in the Japanese face and one tagged zh-CN in the Simplified Chinese face, as the preview does. Only a
69
+ * slot with a script-specific font for the run's language is written; every other run keeps following the theme. Runs the
70
+ * exporter writes carry no ea/cs of their own (stripRunScriptFonts), so this runs after that strip. `xml` is a slide part.
71
+ */
72
+ export function runLanguageFonts(xml, plan, slideIndex) {
73
+ if (!plan || !xml.includes(' altLang="en-US"')) return xml;
74
+ return xml.replace(/<p:(sp|graphicFrame)>[\s\S]*?<\/p:\1>/g, shape => {
75
+ const heading = /<p:cNvPr\b[^>]*\bname="OPF heading /.test(shape);
76
+ return shape.replace(OWN_LANGUAGE_RUN, (run, lang) => {
77
+ const resolved = runScriptPlan(plan, slideIndex, lang);
78
+ if (!resolved) return run;
79
+ const slots = heading ? resolved.heading : resolved.body;
80
+ const latin = /<a:latin typeface="([^"]*)"/.exec(run)?.[1];
81
+ const insert = SCRIPT_SLOTS.map(([element, slot]) => {
82
+ const face = slots?.[slot];
83
+ return resolved.sources?.[slot] !== "latin" && face && escapeAttribute(face) !== latin && !new RegExp(`<a:${element}\\b`).test(run) ? `<a:${element} typeface="${escapeAttribute(face)}"/>` : "";
84
+ }).join("");
85
+ return insert ? run.replace(/(<a:latin\b[^>]*\/>)/, `$1${insert}`) : run;
86
+ });
87
+ });
88
+ }
89
+
90
+ /** The script fonts of one slide for a run language (FA-13), memoized; undefined when the language cannot be resolved. */
91
+ function runScriptPlan(plan, slideIndex, lang) {
92
+ const cache = plan.runScripts ??= new Map(), key = `${slideIndex}|${lang}`;
93
+ if (!cache.has(key)) {
94
+ let resolved;
95
+ try { resolved = resolver(plan.presentation, {slideIndex, language: lang}, plan.catalogs); } catch { resolved = undefined; }
96
+ cache.set(key, resolved && resolved.languageSource !== "default" ? resolved : undefined);
97
+ }
98
+ return cache.get(key);
63
99
  }
64
100
 
65
101
  // ---------------------------------------------------------------------------
@@ -162,7 +198,7 @@ export function reportPerSlideNotesScriptFonts(plan, {themes, assignment}) {
162
198
  // script of the text picks the language core resolves it for: kana is Japanese, hangul Korean, Han alone Simplified
163
199
  // Chinese. Returns {heading, body} families, or null when the deck language already selects one or the text has none.
164
200
  const KANA = /[\p{Script=Hiragana}\p{Script=Katakana}]/u, HANGUL = /\p{Script=Hangul}/u, HAN = /\p{Script=Han}/u;
165
- function contentEastAsianFonts(presentation, deck, report) {
201
+ function contentEastAsianFonts(presentation, deck, report, catalogs) {
166
202
  if (deck.sources.eastAsian !== "latin") return null;
167
203
  let kana = false, hangul = false, han = false;
168
204
  const seen = new Set();
@@ -175,10 +211,10 @@ function contentEastAsianFonts(presentation, deck, report) {
175
211
  }
176
212
  };
177
213
  walk(presentation.slides);
178
- const language = kana ? "japanese" : hangul ? "korean" : han ? "chinese-simplified" : null;
214
+ const language = kana ? "ja" : hangul ? "ko" : han ? "zh-Hans" : null;
179
215
  if (!language) return null;
180
216
  try {
181
- const resolved = resolver({...presentation, language}, {slideIndex: 0});
217
+ const resolved = resolver({...presentation, language}, {slideIndex: 0}, catalogs);
182
218
  return resolved.sources.eastAsian === "latin" ? null : {heading: resolved.heading.eastAsian, body: resolved.body.eastAsian};
183
219
  } catch (error) {
184
220
  report?.({code: "language-export-unavailable", path: "language",
@@ -274,7 +310,9 @@ function runScriptFonts(xml, resolved, headingShape) {
274
310
  });
275
311
  }
276
312
 
277
- const RUN_LANGUAGE = /(<a:(?:rPr|endParaRPr|defRPr)\b[^>]*?\slang=")en-US(")/g;
313
+ // A run with its own language (TextRun.lang, FA-13) is written with an altLang attribute; the deck language never replaces it.
314
+ const RUN_LANGUAGE = /(<a:(?:rPr|endParaRPr|defRPr)\b[^>]*?\slang=")en-US("(?! altLang="))/g;
315
+ const OWN_LANGUAGE_RUN = /<a:rPr\b[^>]*?\slang="([^"]+)" altLang="en-US"[^>]*[^/]>[\s\S]*?<\/a:rPr>/g;
278
316
 
279
317
  const decodeText = value => value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|(amp|lt|gt|quot|apos));/gi, (entity, decimal, hex, name) =>
280
318
  decimal ? String.fromCodePoint(Number(decimal)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : {amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'"}[name.toLowerCase()]);
@@ -392,41 +430,14 @@ export function partScriptFonts(path, xml, plan, slideIndex) {
392
430
  const LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
393
431
  const attribute = (xml, name) => new RegExp(`\\s${name}="([^"]*)"`).exec(xml)?.[1];
394
432
 
395
- /**
396
- * Match an OOXML run language to the OPF language to import. A catalog id is
397
- * used when that record exports exactly this tag again: an exact BCP-47 tag,
398
- * then a curated `ooxmlLang` (preferring the same primary language). A tag
399
- * that core still resolves to a catalog record (for example en-NZ to English)
400
- * is imported as the tag itself, so it round-trips. Without core's resolver,
401
- * a record whose tag is the primary language alone is accepted too.
402
- * Returns {language, record, shared} or null when nothing matches.
403
- */
404
- export function matchCatalogLanguage(lang, catalogs) {
405
- const records = Array.isArray(catalogs?.languages) ? catalogs.languages : [];
406
- const key = lang.toLowerCase(), primary = key.split("-")[0];
407
- const lower = value => typeof value === "string" ? value.toLowerCase() : undefined;
408
- const shared = records.filter(record => lower(record.bcp47) === key || lower(record.ooxmlLang) === key);
409
- const exports = record => !resolver || resolver({language: record.id}).lang.toLowerCase() === key;
410
- const record = shared.find(record => lower(record.bcp47) === key && exports(record))
411
- ?? shared.find(record => lower(record.bcp47)?.split("-")[0] === primary && exports(record))
412
- ?? shared.find(exports);
413
- if (record) return {language: record.id, record, shared};
414
- if (resolver) {
415
- const resolved = resolver({language: lang});
416
- // A tag core cannot resolve falls back to its default language; that is no match.
417
- const matched = resolved.languageSource !== "default" && resolved.languageId && records.find(candidate => candidate.id === resolved.languageId);
418
- return matched ? {language: lang, record: matched, shared: []} : null;
419
- }
420
- const byPrimary = records.find(candidate => lower(candidate.bcp47) === primary);
421
- return byPrimary ? {language: byPrimary.id, record: byPrimary, shared: []} : null;
422
- }
423
-
424
433
  /**
425
434
  * Observe the presentation language in run `lang`. `slides` and `theme` are
426
- * XML strings. `language` is a catalog id, the run's tag when no catalog
427
- * record matches, or undefined when the runs carry no language; `lang` is the
428
- * dominant run tag itself. Nothing is reported here: languageDiagnostics()
429
- * 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.
430
441
  */
431
442
  export function observeLanguage({slides, theme, themePath, slideThemes, catalogs}) {
432
443
  const counts = new Map();
@@ -441,8 +452,7 @@ export function observeLanguage({slides, theme, themePath, slideThemes, catalogs
441
452
  }
442
453
  const ranked = [...counts].sort((left, right) => right[1] - left[1] || (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0));
443
454
  const lang = ranked[0]?.[0];
444
- const match = lang === undefined ? null : matchCatalogLanguage(lang, catalogs);
445
- 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};
446
456
  }
447
457
 
448
458
  /**
@@ -454,32 +464,30 @@ export function observeLanguage({slides, theme, themePath, slideThemes, catalogs
454
464
  */
455
465
  export function reconcileLanguage(groups, observed, report) {
456
466
  const index = groups.findIndex(item => item.field === "language");
457
- if (index < 0 || observed.lang === undefined || !resolver) return groups;
467
+ if (index < 0 || observed.lang === undefined) return groups;
458
468
  const stored = groups[index].ops.find(op => op.path?.length === 1 && op.path[0] === "language")?.value;
459
469
  if (stored === undefined) return groups;
460
- const resolved = resolver({language: stored});
470
+ const resolved = resolver({language: stored}, {}, observed.catalogs);
461
471
  if (resolved.languageSource === "default" || resolved.lang.toLowerCase() === observed.lang.toLowerCase()) return groups;
462
472
  report?.({code: "metadata-reference-changed", path: "language",
463
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.`});
464
474
  return groups.filter((_, position) => position !== index);
465
475
  }
466
476
 
467
- /**
468
- * Diagnostics for the final imported document: several run languages, a run
469
- * tag that maps ambiguously or to no catalog record (only when that observed
470
- * language was kept), right-to-left paragraphs under a left-to-right
471
- * language, and theme East Asian/complex-script fonts the document does not
472
- * reproduce.
473
- */
474
477
  /** Whether the language observed in the runs is written right to left. */
475
478
  export function observedRtl(observed) {
476
479
  if (observed.lang === undefined) return false;
477
- try { return resolver ? resolver({language: observed.language}).rtl === true : observed.match?.record.direction === "rtl"; } catch { return false; }
480
+ try { return resolver({language: observed.language}, {}, observed.catalogs).rtl === true; } catch { return false; }
478
481
  }
479
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
+ */
480
488
  export function languageDiagnostics(imported, observed, report) {
481
489
  if (!report) return;
482
- const {lang, match, ranked, rtlParagraphs, theme} = observed;
490
+ const {lang, ranked, rtlParagraphs, theme, catalogs} = observed;
483
491
  if (lang === undefined) {
484
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.`});
485
493
  return;
@@ -488,22 +496,15 @@ export function languageDiagnostics(imported, observed, report) {
488
496
  const languages = observedRtl(observed) ? ranked.filter(([tag]) => tag === lang || tag !== "en-US") : ranked;
489
497
  if (languages.length > 1) {
490
498
  report({code: "mixed-run-languages", path: "language",
491
- message: `Runs use ${languages.length} languages (${languages.map(([tag, count]) => `${tag} x${count}`).join(", ")}). OPF has one presentation language, so ${lang} was imported.`});
492
- }
493
- const kept = imported.language === observed.language;
494
- 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.`});
495
- else if (kept && match.shared.length > 1 && !match.shared.some(record => record.bcp47?.toLowerCase() === lang.toLowerCase())) {
496
- report({code: "language-ambiguous", path: "language",
497
- message: `Run language ${lang} is the OOXML tag of ${match.shared.map(record => record.id).join(", ")}; ${match.language} was imported.`});
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.`});
498
500
  }
499
- const resolved = resolver ? resolver(imported) : null;
500
- const rtl = resolved ? resolved.rtl : match?.record.direction === "rtl";
501
- if (rtlParagraphs && !rtl) {
501
+ const resolved = resolver(imported, {}, catalogs);
502
+ if (rtlParagraphs && !resolved.rtl) {
502
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.`});
503
504
  }
504
505
  // The presentation theme carries the first slide's script fonts (planScriptFonts), so it is compared with that slide.
505
506
  let themeResolved = resolved;
506
- if (resolver && 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; } }
507
508
  if (theme && themeResolved) {
508
509
  for (const {tag, slot, face} of themeScriptMismatches(theme, themeResolved)) {
509
510
  report({code: "script-font-not-imported", path: "design.fontScheme",
@@ -511,11 +512,11 @@ export function languageDiagnostics(imported, observed, report) {
511
512
  }
512
513
  }
513
514
  // opf-pptx#168: a slide on another slide master carries its own script fonts in that master's theme.
514
- if (resolver && Array.isArray(observed.slideThemes)) {
515
+ if (Array.isArray(observed.slideThemes)) {
515
516
  observed.slideThemes.forEach((slideTheme, index) => {
516
517
  if (!slideTheme || slideTheme.path === observed.themePath) return;
517
518
  let slideResolved;
518
- try { slideResolved = resolver(imported, {slideIndex: index}); } catch { return; }
519
+ try { slideResolved = resolver(imported, {slideIndex: index}, catalogs); } catch { return; }
519
520
  for (const {tag, slot, face} of themeScriptMismatches(slideTheme.xml, slideResolved)) {
520
521
  report({code: "script-font-not-imported", path: `slides.${index}.design.fontScheme`,
521
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.`});
@@ -553,8 +554,8 @@ export function stripRunScriptFonts(xml) {
553
554
  }
554
555
 
555
556
  /**
556
- * Without core's resolver the theme's East Asian slot still repeats the latin family of its font group, which keeps
557
- * the paragraph end marks from reading an empty font (see themeScriptFonts).
557
+ * When the resolver throws for a deck (see planScriptFonts) the theme's East Asian slot still repeats the latin family of
558
+ * its font group, which keeps the paragraph end marks from reading an empty font (see themeScriptFonts).
558
559
  */
559
560
  export function themeEastAsianFromLatin(xml) {
560
561
  return xml.replace(/<a:(majorFont|minorFont)>[\s\S]*?<\/a:\1>/g, block => {
@@ -36,7 +36,7 @@ const VENDOR_VALUES = new Set(['tx1', 'tx2', 'bg1', 'bg2', 'accent1', 'accent2',
36
36
  const ROLE_SLOTS = Object.freeze({
37
37
  primary: ['accent1'],
38
38
  secondary: ['accent2'],
39
- accent: ['accent1', 'accent3'],
39
+ accent: ['accent3'],
40
40
  text: ['dark1', 'light1'],
41
41
  textSecondary: ['dark2', 'light2'],
42
42
  surface: ['light2', 'dark2'],
@@ -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
  }
@@ -1,8 +1,9 @@
1
1
  import {XMLParser} from 'fast-xml-parser';
2
- import {validatePresentation} from '@openpresentation/opf';
2
+ import {isValidFormat} from './format-check.js';
3
3
  import {attachTextTags,decodeTextTag} from './code-provenance.js';
4
4
  import {sourceLineParagraphs} from './text-provenance.js';
5
5
 
6
+ const STATUSES=['done','current','planned'];
6
7
  const TAG='OPF_TIMELINE_V1',REL='http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
7
8
  const parser=new XMLParser({ignoreAttributes:false,attributeNamePrefix:'',parseTagValue:false,trimValues:false});
8
9
  const decoder=new TextDecoder('utf-8',{fatal:true}),array=value=>value===undefined?[]:Array.isArray(value)?value:[value];
@@ -49,16 +50,22 @@ export function importTimelineGroups(shapes,paragraphs,relationships,entries,rep
49
50
  for(const group of groups.values())try{
50
51
  const anchors=group.filter(item=>item.data.anchor!==undefined);if(anchors.length!==1)throw Error('Missing or duplicated timeline anchor.');
51
52
  const anchor=anchors[0],manifest=anchor.data.anchor,count=validateManifest(manifest);
52
- const total=count+manifest.eventCount+1;
53
+ // A current event's ring is a second native ellipse with its own tag, so the group has one more shape per ring.
54
+ const total=count+manifest.eventCount+1+group.filter(item=>item.data.role==='ring').length;
53
55
  if(anchor.data.role!=='text'||anchor.data.part!==0||anchor.data.line!==0||group.length!==total||new Set(group.map(item=>item.shape)).size!==total)throw Error('Incomplete or duplicated timeline elements.');
54
- const ordered=manifest.parts.map(part=>Array(part.lines)),markers=new Set();let connectors=0;
56
+ const ordered=manifest.parts.map(part=>Array(part.lines)),markers=new Set(),rings=new Set(),statuses=new Map();let connectors=0;
55
57
  for(const item of group){
56
58
  if(item.ambiguous)throw Error('Multiple source identities on one timeline shape.');
57
- if(item.data.role==='connector'||item.data.role==='marker'){
59
+ if(item.data.role==='connector'||item.data.role==='marker'||item.data.role==='ring'){
58
60
  const expected=item.data.role==='connector'?'line':'ellipse';
59
61
  if(item.shape['p:spPr']?.['a:prstGeom']?.prst!==expected||(paragraphs[item.index]??[]).some(paragraph=>paragraph.text||paragraph.bullet))throw Error('Timeline graphic now contains different geometry or native text.');
60
62
  if(item.data.role==='connector'){if(++connectors>1)throw Error('Duplicated timeline connector.');}
61
- else{const index=item.data.eventIndex;if(!Number.isSafeInteger(index)||index<0||index>=manifest.eventCount||markers.has(index))throw Error('Ambiguous timeline marker.');markers.add(index);}
63
+ else{
64
+ const index=item.data.eventIndex,known=item.data.role==='marker'?markers:rings;
65
+ if(!Number.isSafeInteger(index)||index<0||index>=manifest.eventCount||known.has(index))throw Error('Ambiguous timeline marker.');
66
+ known.add(index);
67
+ if(item.data.role==='marker'&&item.data.status!==undefined){if(!STATUSES.includes(item.data.status))throw Error('Invalid timeline event status.');statuses.set(index,item.data.status);}
68
+ }
62
69
  continue;
63
70
  }
64
71
  const {part,line}=item.data;
@@ -66,13 +73,17 @@ export function importTimelineGroups(shapes,paragraphs,relationships,entries,rep
66
73
  ordered[part][line]=item;
67
74
  }
68
75
  if(connectors!==1||markers.size!==manifest.eventCount)throw Error('Incomplete timeline graphics.');
76
+ // Only a current event has a ring, and every current event has one.
77
+ for(const index of rings)if(statuses.get(index)!=='current')throw Error('Timeline ring without a current event.');
78
+ for(const [index,status] of statuses)if(status==='current'&&!rings.has(index))throw Error('Current timeline event without its ring.');
69
79
  const result={events:Array.from({length:manifest.eventCount},()=>({}))};
70
80
  for(const [index,part]of manifest.parts.entries()){
71
81
  const text=sourceLineParagraphs(ordered[index],paragraphs)[0].text;
72
82
  if(part.eventIndex===undefined)result[part.field]=text;else result.events[part.eventIndex][part.field]=text;
73
83
  }
84
+ for(const [index,status] of statuses)result.events[index].status=status;
74
85
  const timeline=manifest.shorthand?result.events:result;
75
- if(!validatePresentation({slides:[{timeline}]}).valid)throw Error('Current native text does not form a valid timeline.');
86
+ if(!isValidFormat({slides:[{timeline}]}))throw Error('Current native text does not form a valid timeline.');
76
87
  for(const item of group)consumed.add(item.shape);
77
88
  items.push({shapes:group.map(item=>item.shape),payload:{type:'timeline',timeline}});
78
89
  report({code:'timeline-import-reflow',message:'Timeline field order, source boundaries and current native text were recovered. Native formatting, positions, marker styling and font theme are not reconstructed; review the reflowed timeline.'});
@@ -7,7 +7,7 @@ import {unzipSync} from 'fflate';
7
7
  // DrawingML `typeface` attributes (latin, ea, cs, sym, buFont, theme script
8
8
  // supplements, embedded-font lists), SpreadsheetML font names (styles `name`,
9
9
  // rich-text `rFont`) and the docProps/app.xml "Fonts Used" list.
10
- // checkPptxTypefaces() then applies the owner font policy: a package names only
10
+ // checkTypefaces() then applies the owner font policy: a package names only
11
11
  // the fonts its author chose, plus theme references that resolve to them, empty
12
12
  // theme script slots when no script font was selected (as in Office's themes; FF-49 checks a selected slot through options.themeScripts) and the documented theme script supplements.
13
13
 
@@ -33,7 +33,7 @@ export const THEME_SCRIPT_SUPPLEMENTS = Object.freeze({
33
33
  minor: supplement(`Jpan=游ゴシック|Hans=等线|Arab=Arial|Hebr=Arial|Thai=Cordia New|Khmr=DaunPenh|Viet=Arial|${SHARED_SCRIPTS}`)
34
34
  });
35
35
 
36
- export function inventoryPptxTypefaces(input, options = {}) {
36
+ export function inventoryTypefaces(input, options = {}) {
37
37
  const typefaces = [];
38
38
  const themes = {};
39
39
  // Every theme part's fonts, and the theme each part of the presentation package resolves its references against.
@@ -90,15 +90,15 @@ export function packageFontsUsed(inventory) {
90
90
  return [...used].sort(compareFontNames);
91
91
  }
92
92
 
93
- export function checkPptxTypefaces(input, options = {}) {
94
- if (!Array.isArray(options.fonts) || options.fonts.some(font => typeof font !== 'string' || !font)) {
95
- throw new TypeError('checkPptxTypefaces requires options.fonts, the non-empty family names the author chose.');
93
+ export function checkTypefaces(input, options = {}) {
94
+ if (!Array.isArray(options.families) || options.families.some(family => typeof family !== 'string' || !family)) {
95
+ throw new TypeError('checkTypefaces requires options.families, the non-empty family names the author chose.');
96
96
  }
97
- const chosen = new Set(options.fonts);
97
+ const chosen = new Set(options.families);
98
98
  const monospace = new Set(options.monospace ?? []);
99
99
  const allowEmpty = options.allowEmptyThemeScripts ?? true;
100
100
  const supplements = options.scriptSupplements ?? THEME_SCRIPT_SUPPLEMENTS;
101
- const inventory = inventoryPptxTypefaces(input);
101
+ const inventory = inventoryTypefaces(input);
102
102
  const violations = [];
103
103
  const fail = (entry, reason, details = {}) => violations.push({reason, part: entry.part, element: entry.element, typeface: entry.typeface, ...details});
104
104
  const emptyAllowed = entry => allowEmpty && entry.theme && (entry.element === 'ea' || entry.element === 'cs');
@@ -117,7 +117,7 @@ export function checkPptxTypefaces(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;
@@ -19,6 +19,8 @@ const canonical = value => JSON.stringify(value, (_key, item) => item && typeof
19
19
  ? Object.fromEntries(Object.keys(item).sort().filter(key => !(key === '#text' && typeof item[key] === 'string' && !item[key].trim())).map(key => [key, item[key]])) : item);
20
20
 
21
21
  export const watermarkName = () => 'OPF watermark';
22
+ // FA-13: a text watermark is one native text box (a p:sp), not a picture.
23
+ export const watermarkTextName = () => 'OPF watermark text';
22
24
 
23
25
  // The preview's frame: x/y at 30% and a 40% by 40% box, in the slide's units.
24
26
  export const watermarkBox = (width, height) => ({x: width * .3, y: height * .3, w: width * .4, h: height * .4});
@@ -80,6 +82,7 @@ function attachTags(entries, manifests) {
80
82
  const NS = 'http://schemas.openxmlformats.org/presentationml/2006/main', types = [];
81
83
  let count = 0;
82
84
  for (const [part, manifest] of manifests) {
85
+ const element = manifest.kind === 'text' ? 'sp' : 'pic', name = manifest.kind === 'text' ? watermarkTextName() : watermarkName();
83
86
  const relPath = part.replace('/slides/', '/slides/_rels/') + '.rels';
84
87
  const rels = decoder.decode(entries[relPath]);
85
88
  const ids = new Set([...rels.matchAll(/\bId="([^"]+)"/g)].map(match => match[1]));
@@ -90,11 +93,11 @@ function attachTags(entries, manifests) {
90
93
  types.push(`<Override PartName="/${tagPart}" ContentType="application/vnd.openxmlformats-officedocument.presentationml.tags+xml"/>`);
91
94
  entries[relPath] = encoder.encode(rels.replace('</Relationships>', `<Relationship Id="${id}" Type="${REL}" Target="../tags/opfWatermark${count}.xml"/></Relationships>`));
92
95
  let attached = false;
93
- const xml = decoder.decode(entries[part]).replace(/<p:pic>[\s\S]*?<\/p:pic>/g, picture => {
94
- if (attached || picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] !== watermarkName()) return picture;
96
+ const xml = decoder.decode(entries[part]).replace(new RegExp(`<p:${element}>[\\s\\S]*?</p:${element}>`, 'g'), picture => {
97
+ if (attached || picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] !== name) return picture;
95
98
  attached = true;
96
99
  picture = picture.replace(/<p:nvPr\s*\/>/, '<p:nvPr></p:nvPr>');
97
- if (!picture.includes('</p:nvPr>')) throw new Error('Generated watermark picture has no native application properties.');
100
+ if (!picture.includes('</p:nvPr>')) throw new Error('Generated watermark shape has no native application properties.');
98
101
  return picture.replace('</p:nvPr>', `<p:custDataLst><p:tags r:id="${id}"/></p:custDataLst></p:nvPr>`);
99
102
  });
100
103
  entries[part] = encoder.encode(xml);
@@ -102,6 +105,28 @@ function attachTags(entries, manifests) {
102
105
  entries['[Content_Types].xml'] = encoder.encode(decoder.decode(entries['[Content_Types].xml']).replace('</Types>', types.join('') + '</Types>'));
103
106
  }
104
107
 
108
+ /**
109
+ * Bind each generated text watermark box to its manifest with a native tag.
110
+ * marks: Map<slidePart, {slide, text, opacity}>. The manifest records the text and the exact native geometry
111
+ * (position, size and rotation), so an unchanged box imports back as design.watermark.
112
+ */
113
+ export function tagTextWatermarks(entries, marks) {
114
+ if (!marks.size) return;
115
+ const manifests = new Map();
116
+ for (const part of Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path))) {
117
+ const mark = marks.get(part);
118
+ if (!mark) continue;
119
+ let found = 0;
120
+ for (const shape of decoder.decode(entries[part]).match(/<p:sp>[\s\S]*?<\/p:sp>/g) ?? []) {
121
+ if (shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1] !== watermarkTextName()) continue;
122
+ found++;
123
+ manifests.set(part, {v: 1, kind: 'text', slide: mark.slide, text: mark.text, opacity: mark.opacity, properties: parser.parse(shape)['p:sp']['p:spPr']});
124
+ }
125
+ if (found !== 1) throw new Error('Missing generated watermark text box.');
126
+ }
127
+ attachTags(entries, manifests);
128
+ }
129
+
105
130
  function readTags(container, relationships, entries) {
106
131
  const tags = [];
107
132
  let unreadable = false;
@@ -113,6 +138,43 @@ function readTags(container, relationships, entries) {
113
138
  return {tags, unreadable};
114
139
  }
115
140
 
141
+ /**
142
+ * Recover a text design.watermark from an unchanged tagged text box. paragraphs[index] are the native paragraphs of
143
+ * shapes[index]. Returns the consumed shape indexes and the slide design fields to restore. A box whose text, position,
144
+ * size or rotation changed stays an ordinary text box and is reported.
145
+ */
146
+ export function importTextWatermark(shapes, paragraphs, relationships, entries, slideIndex, report) {
147
+ const consumed = new Set(), found = [];
148
+ for (const [index, shape] of shapes.entries()) {
149
+ const {tags, unreadable} = readTags(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries);
150
+ const own = tags.filter(tag => tag.name?.toUpperCase() === TAG);
151
+ if (!own.length) continue;
152
+ // The picture watermark has the same tag name; a picture's manifest has no kind and is read by importWatermark.
153
+ let kind;
154
+ try { kind = decodeTextTag(own[0].val)?.kind; } catch { kind = undefined; }
155
+ if (kind !== 'text') continue;
156
+ found.push({index, shape, own, unreadable, others: tags.some(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== TAG)});
157
+ }
158
+ if (!found.length) return {consumed};
159
+ const invalid = () => report({code: 'invalid-watermark-provenance', message: 'An edited, ambiguous or invalid tagged OPF watermark was imported as ordinary text. It is not reconstructed as design.watermark.'});
160
+ if (found.length > 1) { invalid(); return {consumed}; }
161
+ const [{index, shape, own, unreadable, others}] = found;
162
+ try {
163
+ if (unreadable || others || own.length !== 1) throw Error('Ambiguous watermark identity.');
164
+ const manifest = decodeTextTag(own[0].val);
165
+ if (manifest?.v !== 1 || manifest.kind !== 'text' || manifest.slide !== `slides.${slideIndex}` || typeof manifest.text !== 'string' || !manifest.text
166
+ || typeof manifest.opacity !== 'number' || !(manifest.opacity >= 0 && manifest.opacity <= 1)) throw Error('Invalid watermark manifest.');
167
+ if (shape['p:nvSpPr']?.['p:cNvPr']?.name !== watermarkTextName()) throw Error('Watermark identity changed.');
168
+ if (canonical(shape['p:spPr']) !== canonical(manifest.properties)) throw Error('Watermark geometry changed.');
169
+ if ((paragraphs[index] ?? []).map(paragraph => paragraph.text).join('\n') !== manifest.text) throw Error('Watermark text changed.');
170
+ consumed.add(index);
171
+ return {consumed, design: {watermark: {text: manifest.text, opacity: manifest.opacity}}};
172
+ } catch {
173
+ invalid();
174
+ return {consumed};
175
+ }
176
+ }
177
+
116
178
  /**
117
179
  * Recover design.watermark from an unchanged tagged picture. Returns the
118
180
  * consumed picture indexes and the slide design fields to restore.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf-pptx",
3
- "version": "0.13.2",
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.13.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.13.1"
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.13.1",
85
+ "@openpresentation/opf-render": "0.15.0",
86
86
  "esbuild": "0.28.2",
87
87
  "playwright": "1.63.0"
88
88
  },