@openpresentation/opf-pptx 0.11.9 → 0.12.1

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.
@@ -36,10 +36,11 @@ function blipFillIdentity(blipFill) {
36
36
  * left edge and vertically centered. A 90 degree EXIF orientation swaps the
37
37
  * frame's axes, so its visual width is the stored height.
38
38
  */
39
- export function logoFrame(metadata, box) {
39
+ export function logoFrame(metadata, box, anchor = 'left') {
40
40
  const placed = pictureTransform(metadata, box, 'fit');
41
41
  const visualWidth = (metadata.orientation ?? 1) >= 5 ? placed.h : placed.w;
42
- return {...placed, x: placed.x - (box.w - visualWidth) / 2};
42
+ // Right to left (RR-05): a cover logo sits at the top right, anchored to the box's right edge.
43
+ return {...placed, x: placed.x + (anchor === 'right' ? 1 : -1) * (box.w - visualWidth) / 2};
43
44
  }
44
45
 
45
46
  /**
@@ -61,7 +62,7 @@ export function placeLogos(entries, logos, metadataFor, fail) {
61
62
  const embed = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
62
63
  const metadata = metadataFor(part, embed);
63
64
  if (!metadata) fail(logo.path);
64
- const placed = logoFrame(metadata, logo.box);
65
+ const placed = logoFrame(metadata, logo.box, logo.anchor);
65
66
  const attributes = `${placed.rotation ? ` rot="${placed.rotation * 60000}"` : ''}${placed.flipH ? ' flipH="1"' : ''}${placed.flipV ? ' flipV="1"' : ''}`;
66
67
  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>`);
67
68
  picture = picture.replace(/<a:srcRect\b[^>]*\/>/, '');
@@ -0,0 +1,49 @@
1
+ const decoder = new TextDecoder(), encoder = new TextEncoder();
2
+ const text = bytes => decoder.decode(bytes);
3
+
4
+ const THEME_TYPE = 'application/vnd.openxmlformats-officedocument.theme+xml';
5
+ const THEME_TARGET = /Target="\.\.\/theme\/(theme\d+\.xml)"/;
6
+ const NOTES_MASTER = /^ppt\/notesMasters\/notesMaster\d+\.xml$/;
7
+ const SLIDE_MASTER = /^ppt\/slideMasters\/slideMaster\d+\.xml$/;
8
+
9
+ const relsOf = path => path.replace(/([^/]+)$/, '_rels/$1.rels');
10
+
11
+ /**
12
+ * FF-05 (font-fidelity-everywhere). PptxGenJS 4.0.1 points the notes master at the slide master's theme part
13
+ * (`ppt/theme/theme1.xml`). Every master owns its theme part in a PowerPoint package (theme1 for the slide master,
14
+ * theme2 for the notes master, theme3 for a handout master), and PowerPoint refuses to read a package whose notes
15
+ * master it recognises when that master shares the slide theme ("The file or directory is corrupted and unreadable",
16
+ * 0x80070570). The vendored `p:notesMasterIdLst` sits after `p:sldIdLst` (see presentationInSchemaOrder in index.js),
17
+ * which PowerPoint ignored; it then synthesised a default notes master with the default Office theme, and that
18
+ * made the default font (Aptos) appear in Presentation.Fonts although no part names it.
19
+ *
20
+ * Each notes master that shares a theme part with another master gets its own copy of that theme, with the same
21
+ * fonts, colours and format scheme, a relationship to it and a content-type override. The copy is taken after
22
+ * every theme rewrite, so it carries the deck's final fonts. Copies are numbered after the existing themes.
23
+ */
24
+ export function giveNotesMastersOwnThemes(output) {
25
+ const types = output['[Content_Types].xml'];
26
+ if (!types) return;
27
+ const used = new Set();
28
+ const themeOf = master => THEME_TARGET.exec(text(output[relsOf(master)]?.[0] ?? new Uint8Array()))?.[1];
29
+ for (const master of Object.keys(output).filter(path => SLIDE_MASTER.test(path))) {
30
+ const theme = themeOf(master);
31
+ if (theme) used.add(theme);
32
+ }
33
+ let next = Math.max(0, ...Object.keys(output).map(path => Number(/^ppt\/theme\/theme(\d+)\.xml$/.exec(path)?.[1] ?? 0))) + 1;
34
+ let overrides = '';
35
+ for (const master of Object.keys(output).filter(path => NOTES_MASTER.test(path)).sort()) {
36
+ const theme = themeOf(master);
37
+ if (!theme) continue;
38
+ if (!used.has(theme)) { used.add(theme); continue; }
39
+ const source = output[`ppt/theme/${theme}`];
40
+ if (!source) continue;
41
+ const name = `theme${next++}.xml`;
42
+ output[`ppt/theme/${name}`] = [source[0], source[1]];
43
+ const rels = relsOf(master);
44
+ output[rels] = [encoder.encode(text(output[rels][0]).replace(THEME_TARGET, `Target="../theme/${name}"`)), output[rels][1]];
45
+ overrides += `<Override PartName="/ppt/theme/${name}" ContentType="${THEME_TYPE}"/>`;
46
+ used.add(name);
47
+ }
48
+ if (overrides) output['[Content_Types].xml'] = [encoder.encode(text(types[0]).replace('</Types>', `${overrides}</Types>`)), types[1]];
49
+ }
@@ -14,7 +14,7 @@ export function metricManifest(value,layout,group) {
14
14
  return {v:1,group,role:'anchor',part:0,line:0,value,parts:layout.parts.map(part=>({role:part.role,visible:part.visible,
15
15
  lines:part.fit?.sourceLines.map(({start,end,nextStart,boundary})=>({start,end,nextStart,boundary}))??[]}))};
16
16
  }
17
- export function attachMetricTags(entries,records) {return attachTextTags(entries,records,TAG,'opfMetric','metric');}
17
+ export function attachMetricTags(entries,records,descriptions) {return attachTextTags(entries,records,TAG,'opfMetric','metric',{descriptions});}
18
18
 
19
19
  function validateManifest(manifest) {
20
20
  if (!validatePresentation({slides:[{metric:manifest.value}]}).valid) throw new Error('Invalid metric source.');
@@ -59,10 +59,13 @@ export function importMetricGroups(shapes,paragraphs,relationships,entries,repor
59
59
  const anchors=group.filter(item=>item.data.role==='anchor');
60
60
  if (anchors.length!==1) throw new Error('Missing or duplicated metric anchor.');
61
61
  const anchor=anchors[0],manifest=anchor.data;validateManifest(manifest);
62
+ // RR-07: the trend arrow (role 'mark') is generated decoration: at most one, consumed with the group, never source text.
63
+ const marks=group.filter(item=>item.data.role==='mark'),members=group.filter(item=>item.data.role!=='mark');
64
+ if (marks.length>1) throw new Error('Duplicated metric trend mark.');
62
65
  const expected=manifest.parts.reduce((sum,part)=>sum+part.lines.length,0);
63
- if (group.length!==expected||new Set(group.map(item=>item.shape)).size!==expected) throw new Error('Incomplete or duplicated metric shapes.');
66
+ if (members.length!==expected||new Set(members.map(item=>item.shape)).size!==expected) throw new Error('Incomplete or duplicated metric shapes.');
64
67
  const nativeLines=new Map();
65
- for (const item of group) {
68
+ for (const item of members) {
66
69
  const {role,part,line}=item.data,key=`${part}:${line}`;
67
70
  if ((item!==anchor&&role!=='line')||!Number.isSafeInteger(part)||!Number.isSafeInteger(line)||!manifest.parts[part]?.lines[line]||nativeLines.has(key)) throw new Error('Ambiguous metric source line.');
68
71
  nativeLines.set(key,item.text);
@@ -5,6 +5,18 @@ const boolean = value => ['1','true','on'].includes(value) ? true : ['0','false'
5
5
 
6
6
  const themeFamilies = context => ['a:majorFont', 'a:minorFont'].map(key => context.fonts?.[key]?.['a:latin']?.typeface?.trim().toLowerCase()).filter(Boolean);
7
7
 
8
+ // A run painted with a bare theme slot (a:schemeClr, no color transform) remembers the slot as the OPF color name: the
9
+ // transient `_opfScheme` marker is read, and always removed, by restoreRunColors (run-colors.js) once the document's own
10
+ // colour scheme can confirm the slot holds that colour.
11
+ const OPF_SLOTS = {dk1: 'dark1', lt1: 'light1', dk2: 'dark2', lt2: 'light2', hlink: 'hyperlink', folHlink: 'followedHyperlink', accent1: 'accent1', accent2: 'accent2', accent3: 'accent3', accent4: 'accent4', accent5: 'accent5', accent6: 'accent6'};
12
+ function nativeSchemeSlot(fill, context) {
13
+ const node = fill?.['a:schemeClr'];
14
+ if (!node || Array.isArray(node) || Object.keys(fill).length !== 1 || Object.keys(node).some(key => key !== 'val') || typeof node.val !== 'string') return undefined;
15
+ // Own keys only: a slot name from the package (`__proto__`, `constructor`) must never reach an inherited property.
16
+ const mapping = context?.mapping, slot = mapping && Object.hasOwn(mapping, node.val) ? mapping[node.val] : node.val;
17
+ return typeof slot === 'string' && Object.hasOwn(OPF_SLOTS, slot) ? OPF_SLOTS[slot] : undefined;
18
+ }
19
+
8
20
  export function nativeRunStyle(properties, context, relationships, report, kind = 'table') {
9
21
  const result = {};
10
22
  for (const [native, opf] of [['b','bold'], ['i','italic']]) {
@@ -42,7 +54,11 @@ export function nativeRunStyle(properties, context, relationships, report, kind
42
54
  }
43
55
  if (properties['a:solidFill']) {
44
56
  const color = readBackgroundColor(properties['a:solidFill'], context);
45
- if (color) result.color = color.hex + (color.alpha < 1 ? Math.round(color.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '');
57
+ if (color) {
58
+ result.color = color.hex + (color.alpha < 1 ? Math.round(color.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '');
59
+ const slot = color.alpha === 1 ? nativeSchemeSlot(properties['a:solidFill'], context) : undefined;
60
+ if (slot) result._opfScheme = slot;
61
+ }
46
62
  else report(`unsupported-${kind}-text-color`, 'The native text color or its transforms could not be resolved.');
47
63
  } else if (properties['a:noFill']) {
48
64
  result.color = '#00000000';
@@ -0,0 +1,114 @@
1
+ // RR-33: numbered lists. OPF `numbering` (a style, a start and a suffix per list level) is written as native
2
+ // `a:buAutoNum` and read back from it.
3
+ //
4
+ // Export: every marker line of a measured list is its own text box (see addMeasuredList), so each carries the number
5
+ // core counted as `startAt`; there is nothing for PowerPoint to count across boxes.
6
+ // Import: `a:buAutoNum` of a native paragraph is read with PowerPoint's own counting (consecutive paragraphs of one
7
+ // level and one scheme count up from `startAt`; a paragraph of another scheme or start begins a new sequence; a
8
+ // shallower or unnumbered paragraph ends the deeper sequences), then the numbers of the whole list are expressed as the
9
+ // shortest `numbering` plus per-entry `start` values that reproduce them.
10
+
11
+ const STYLES = ['arabic', 'roman-upper', 'roman-lower', 'alpha-upper', 'alpha-lower'];
12
+ const STYLE_STEMS = {arabic: 'arabic', 'roman-upper': 'romanUc', 'roman-lower': 'romanLc', 'alpha-upper': 'alphaUc', 'alpha-lower': 'alphaLc'};
13
+ const SUFFIX_TAILS = {period: 'Period', paren: 'ParenR', 'paren-both': 'ParenBoth'};
14
+
15
+ /** The `a:buAutoNum@type` scheme of an OPF style and suffix. */
16
+ export const autoNumScheme = (style, suffix = 'period') => `${STYLE_STEMS[style]}${SUFFIX_TAILS[suffix]}`;
17
+
18
+ const SCHEMES = new Map(STYLES.flatMap(style => Object.keys(SUFFIX_TAILS).map(suffix => [autoNumScheme(style, suffix), {style, suffix}])));
19
+ /** OPF style and suffix of a native scheme, or `undefined` for a scheme OPF has no equivalent for. */
20
+ export const schemeNumbering = type => SCHEMES.get(type);
21
+
22
+ /** `startAt` of a native auto-number: an integer from 1 to 32767, default 1. */
23
+ export function autoNumStart(value) {
24
+ if (value === undefined || value === '') return 1;
25
+ const number = Number(value);
26
+ return Number.isInteger(number) && number >= 1 && number <= 32767 ? number : undefined;
27
+ }
28
+
29
+ /**
30
+ * The number each paragraph of one native shape displays, by PowerPoint's counting. `paragraphs` are
31
+ * `{level, autoNum?: {type, startAt}}`; returns the displayed numbers (`undefined` for an unnumbered paragraph).
32
+ */
33
+ export function displayedNumbers(paragraphs) {
34
+ const sequences = [];
35
+ return paragraphs.map(paragraph => {
36
+ const level = Number.isInteger(paragraph.level) && paragraph.level >= 0 ? paragraph.level : 0;
37
+ if (!paragraph.autoNum) { sequences.length = Math.min(sequences.length, level); return undefined; }
38
+ const start = autoNumStart(paragraph.autoNum.startAt) ?? 1, type = paragraph.autoNum.type ?? 'arabicPeriod';
39
+ const current = sequences[level];
40
+ const value = current && current.type === type && current.start === start ? current.value + 1 : start;
41
+ sequences[level] = {type, start, value};
42
+ sequences.length = level + 1;
43
+ return value;
44
+ });
45
+ }
46
+
47
+ const entryOf = ({style, start, suffix}) => {
48
+ const fields = {...(style !== 'arabic' ? {style} : {}), ...(start !== 1 ? {start} : {}), ...(suffix !== 'period' ? {suffix} : {})};
49
+ const names = Object.keys(fields);
50
+ if (!names.length) return 'arabic';
51
+ return names.length === 1 && names[0] === 'style' ? style : fields;
52
+ };
53
+
54
+ /**
55
+ * The OPF numbering of a native list: `paragraphs` are `{level, number?: {type, value}}` in reading order (`number` is the
56
+ * displayed number and its scheme). Returns `{numbering, starts, diagnostics}`: `numbering` is the shortest authored form
57
+ * (`undefined` when the list is not numbered), `starts` maps a paragraph index to the entry `start` that restores its
58
+ * number, and `diagnostics` lists what could not be represented.
59
+ */
60
+ export function deriveListNumbering(paragraphs) {
61
+ const diagnostics = [];
62
+ const numbered = paragraphs.filter(paragraph => paragraph.number);
63
+ if (!numbered.length) return {numbering: undefined, starts: new Map(), diagnostics};
64
+ if (numbered.length !== paragraphs.length) {
65
+ diagnostics.push({code: 'numbering-mixed', message: 'A list that mixes numbered and bulleted or plain paragraphs has no OPF equivalent (numbering applies to the whole list); it imports as bullets.'});
66
+ return {numbering: undefined, starts: new Map(), diagnostics};
67
+ }
68
+ const levels = [];
69
+ let unknown = false, mixedStyles = false;
70
+ for (const paragraph of numbered) {
71
+ const level = paragraph.level ?? 0;
72
+ const known = schemeNumbering(paragraph.number.type);
73
+ if (!known) { unknown = true; }
74
+ const resolved = known ?? {style: 'arabic', suffix: 'period'};
75
+ if (!levels[level]) levels[level] = {...resolved, start: paragraph.number.value};
76
+ else if (levels[level].style !== resolved.style || levels[level].suffix !== resolved.suffix) mixedStyles = true;
77
+ }
78
+ if (unknown) diagnostics.push({code: 'numbering-scheme-adapted', message: 'A native numbering scheme OPF has no equivalent for (an East Asian, Hebrew or Arabic scheme, or a bullet-only scheme) imports as arabic with a period.'});
79
+ if (mixedStyles) diagnostics.push({code: 'numbering-style-adapted', message: 'Entries of one list level use different native numbering schemes; OPF numbers a level in one style, so the first entry\'s style is used for the level.'});
80
+ const filled = [];
81
+ for (let level = 0; level < levels.length; level++) filled[level] = levels[level] ?? {...(filled[level - 1] ?? {style: 'arabic', suffix: 'period'}), start: 1};
82
+ // Per-entry starts: restart wherever the displayed number is not what counting gives.
83
+ const counters = [], starts = new Map();
84
+ paragraphs.forEach((paragraph, index) => {
85
+ const level = paragraph.level ?? 0, value = paragraph.number.value;
86
+ const natural = counters[level] === undefined ? filled[level].start : counters[level] + 1;
87
+ if (natural !== value) starts.set(index, value);
88
+ counters[level] = value;
89
+ counters.length = level + 1;
90
+ });
91
+ // The last entry repeats for deeper levels, so trailing equal entries are redundant.
92
+ const same = (a, b) => a.style === b.style && a.suffix === b.suffix && a.start === b.start;
93
+ while (filled.length > 1 && same(filled.at(-1), filled.at(-2))) filled.pop();
94
+ const entries = filled.map(entryOf);
95
+ return {numbering: entries.length === 1 ? entries[0] : entries, starts, diagnostics};
96
+ }
97
+
98
+ /** A canonical form of an authored `numbering`, for comparing two spellings of the same numbering. */
99
+ export function canonicalNumbering(numbering) {
100
+ const list = Array.isArray(numbering) ? numbering : [numbering];
101
+ const resolved = list.map(entry => typeof entry === 'string' ? {style: entry, start: 1, suffix: 'period'} : {style: entry?.style ?? 'arabic', start: entry?.start ?? 1, suffix: entry?.suffix ?? 'period'});
102
+ while (resolved.length > 1 && JSON.stringify(resolved.at(-1)) === JSON.stringify(resolved.at(-2))) resolved.pop();
103
+ return JSON.stringify(resolved);
104
+ }
105
+
106
+ /**
107
+ * Add `autoNum` (the paragraph's `a:buAutoNum` attributes) and the displayed `number` ({type, value}) to the numbered
108
+ * paragraphs of one native shape. Paragraphs without a native auto-number are returned unchanged.
109
+ */
110
+ export function withDisplayedNumbers(paragraphs) {
111
+ if (!paragraphs.some(paragraph => paragraph.autoNum)) return paragraphs;
112
+ const numbers = displayedNumbers(paragraphs);
113
+ return paragraphs.map((paragraph, index) => numbers[index] === undefined ? paragraph : {...paragraph, number: {type: paragraph.autoNum.type ?? 'arabicPeriod', value: numbers[index]}});
114
+ }
@@ -122,7 +122,7 @@ export function applyPitchFamilies(entries, pitch) {
122
122
  // docProps/app.xml: regenerate the "Fonts Used" group of HeadingPairs and
123
123
  // TitlesOfParts from the fonts the finished package uses, and name the theme
124
124
  // the way the theme part does. Other groups (slide titles) are kept.
125
- export function writeFontsUsed(appXml, fonts, themeName) {
125
+ export function writeFontsUsed(appXml, fonts, themeNames) {
126
126
  const pairs = headingPairs(appXml), titles = titlesOfParts(appXml);
127
127
  if (!pairs || !titles) return appXml;
128
128
  const groups = [];
@@ -135,7 +135,8 @@ export function writeFontsUsed(appXml, fonts, themeName) {
135
135
  if (fontsGroup) fontsGroup[1] = fonts;
136
136
  else groups.unshift(['Fonts Used', fonts]);
137
137
  const themeGroup = groups.find(([name]) => name === 'Theme');
138
- if (themeGroup && themeName && themeGroup[1].length === 1) themeGroup[1] = [themeName];
138
+ const names = [themeNames ?? []].flat();
139
+ if (themeGroup && names.length) themeGroup[1] = names;
139
140
  const kept = groups.filter(([, items]) => items.length);
140
141
  const variants = kept.map(([name, items]) => `<vt:variant><vt:lpstr>${escapeXml(name)}</vt:lpstr></vt:variant><vt:variant><vt:i4>${items.length}</vt:i4></vt:variant>`).join('');
141
142
  const items = kept.flatMap(([, list]) => list).map(item => `<vt:lpstr>${escapeXml(item)}</vt:lpstr>`).join('');
@@ -152,8 +153,9 @@ export function finalizeFontsUsed(output) {
152
153
  const parts = {};
153
154
  for (const [path, [bytes]] of Object.entries(output)) if (/^ppt\/.*\.xml$/.test(path)) parts[path] = bytes;
154
155
  const fonts = packageFontsUsed(inventoryPptxTypefaces(parts, {nested: false}));
155
- const themeName = /<a:theme\b[^>]*\bname="([^"]*)"/.exec(parts['ppt/theme/theme1.xml'] ? text(parts['ppt/theme/theme1.xml']) : '')?.[1];
156
- output['docProps/app.xml'] = [encoder.encode(writeFontsUsed(text(app[0]), fonts, themeName === undefined ? undefined : unescapeFace(themeName))), app[1]];
156
+ const themeNames = Object.keys(parts).filter(path => /^ppt\/theme\/theme\d+\.xml$/.test(path)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]))
157
+ .map(path => /<a:theme\b[^>]*\bname="([^"]*)"/.exec(text(parts[path]))?.[1]).filter(name => name !== undefined).map(unescapeFace);
158
+ output['docProps/app.xml'] = [encoder.encode(writeFontsUsed(text(app[0]), fonts, themeNames)), app[1]];
157
159
  }
158
160
 
159
161
  function unescapeFace(value) {
@@ -0,0 +1,118 @@
1
+ // Run colours named by a reference (RR-08).
2
+ //
3
+ // A TextRun colour may be a ColorRef: a `var:` variable, a colour-scheme slot ('accent2') or a role ('primary'). The
4
+ // export resolves it (or writes the theme slot that holds the same colour) and PowerPoint has no field for the name, so
5
+ // import used to return only the resolved hex. Two sources can name the reference again, and both are gated by the
6
+ // imported document's own colour resolution: a name is restored only while it still resolves to the run's current colour,
7
+ // so a colour edited in PowerPoint keeps its edited value and nothing is invented.
8
+ //
9
+ // 1. Provenance (full mode): OPF_SLIDE_V1 `colors` = {n, at: [[slot, reference], ...]}. The slots are the text runs of the
10
+ // slide's content in a fixed order (slideRunSlots: text runs, list item and description runs; regions by name, then
11
+ // blocks and groups in order, or the root payload). `n` is how many slots the authored slide had; the record is used only
12
+ // when the imported slide has exactly that many, so a run can never be matched to another. It holds reference names
13
+ // only, never words.
14
+ // 2. A slide with no OPF record (plain PPTX): a run painted with a bare a:schemeClr (no colour transform) names that
15
+ // theme slot ('accent2', 'dark1', 'hyperlink'), restored when the document's colour scheme holds that exact colour for
16
+ // the slot. The exporter writes schemeClr only where the theme holds the colour, so this is the same convention.
17
+
18
+ import {ROOT_PAYLOAD_FIELDS, REGION_KEYS} from './content-topology.js';
19
+
20
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
21
+ const REGION_KEY_SET = new Set(REGION_KEYS);
22
+ const isGroup = block => object(block) && (block.type === 'group' || (block.type === undefined && Array.isArray(block.blocks)));
23
+ export const MAX_COLOR_SLOTS = 100000;
24
+
25
+ // A reference name: the schema's ColorRef forms other than a hex literal.
26
+ const REFERENCE = /^(?:var:[a-z][a-z0-9-]*|accent[1-6]|dark[12]|light[12]|hyperlink|followedHyperlink|primary|secondary|accent|background|surface|text|textSecondary)$/;
27
+ export const isColorReference = value => typeof value === 'string' && REFERENCE.test(value);
28
+
29
+ /** The payload objects of a slide in the fixed traversal order: regions (by name), else blocks (groups depth first), else the slide's root payload. */
30
+ function* leaves(slide) {
31
+ const walk = function* (block) {
32
+ if (!object(block)) return;
33
+ if (isGroup(block)) { for (const child of Array.isArray(block.blocks) ? block.blocks : []) yield* walk(child); return; }
34
+ yield block;
35
+ };
36
+ const regions = Object.keys(slide).filter(key => REGION_KEY_SET.has(key) && object(slide[key])).sort();
37
+ if (regions.length) { for (const key of regions) yield* walk(slide[key]); return; }
38
+ if (Array.isArray(slide.blocks)) { for (const block of slide.blocks) yield* walk(block); return; }
39
+ yield slide;
40
+ }
41
+
42
+ /** The text runs of the slide's text and list payloads, in order: an object (a run) or null (a plain string). */
43
+ export function slideRunSlots(slide) {
44
+ const slots = [];
45
+ const runs = value => {
46
+ if (Array.isArray(value)) for (const run of value) slots.push(object(run) ? run : null);
47
+ else if (typeof value === 'string') slots.push(null);
48
+ };
49
+ for (const leaf of leaves(slide)) {
50
+ for (const field of ROOT_PAYLOAD_FIELDS) {
51
+ const value = leaf[field];
52
+ if (field === 'text') { if (Array.isArray(value)) runs(value); }
53
+ else if ((field === 'items' || field === 'bullets') && Array.isArray(value)) {
54
+ for (const item of value) {
55
+ if (Array.isArray(item) || typeof item === 'string') runs(item);
56
+ else if (object(item)) { runs(item.text); runs(item.description); }
57
+ }
58
+ }
59
+ }
60
+ }
61
+ return slots;
62
+ }
63
+
64
+ /** The record stored for an authored slide, or undefined when no run names a reference. */
65
+ export function runColorRecord(slide) {
66
+ const slots = slideRunSlots(slide);
67
+ if (slots.length > MAX_COLOR_SLOTS) return undefined;
68
+ const at = [];
69
+ slots.forEach((run, index) => { if (run && isColorReference(run.color)) at.push([index, run.color]); });
70
+ return at.length ? {n: slots.length, at} : undefined;
71
+ }
72
+
73
+ /** Throws when `value` is not a well-formed colours record. */
74
+ export function validateRunColors(value) {
75
+ if (!object(value) || !Number.isSafeInteger(value.n) || value.n < 1 || value.n > MAX_COLOR_SLOTS || !Array.isArray(value.at) || !value.at.length || value.at.length > value.n) throw Error('Invalid run colours record.');
76
+ let previous = -1;
77
+ for (const entry of value.at) {
78
+ if (!Array.isArray(entry) || entry.length !== 2 || !Number.isSafeInteger(entry[0]) || entry[0] <= previous || entry[0] >= value.n || !isColorReference(entry[1])) throw Error('Invalid run colour entry.');
79
+ previous = entry[0];
80
+ }
81
+ return value;
82
+ }
83
+
84
+ function* markedRuns(value) {
85
+ if (Array.isArray(value)) { for (const item of value) yield* markedRuns(item); return; }
86
+ if (!object(value)) return;
87
+ if (Object.hasOwn(value, '_opfScheme')) yield value;
88
+ for (const item of Object.values(value)) if (item !== null && typeof item === 'object') yield* markedRuns(item);
89
+ }
90
+
91
+ /**
92
+ * Restore run colour references on `slides` (the imported document's slides). `info[i]` is
93
+ * {record, structure, content}: the stored OPF_SLIDE_V1 value, whether the slide's arrangement matches it and whether its
94
+ * content structure was restored from it, or undefined for an untagged slide. `resolve(slide, name)` returns the '#RRGGBB'
95
+ * the name resolves to in the document (or undefined). Every transient `_opfScheme` marker is removed.
96
+ */
97
+ export function restoreRunColors(slides, info, resolve, report) {
98
+ slides.forEach((slide, index) => {
99
+ const tag = info[index];
100
+ const marked = [...markedRuns(slide)];
101
+ const restoreName = (run, name) => {
102
+ const hex = typeof run.color === 'string' && /^#[0-9a-f]{6}$/i.test(run.color) ? run.color.toUpperCase() : undefined;
103
+ if (hex && resolve(slide, name)?.toUpperCase() === hex) run.color = name;
104
+ };
105
+ if (tag?.record?.colors) {
106
+ const {n, at} = tag.record.colors;
107
+ // When the content form was not restored its own diagnostic says why, and the slots cannot be aligned.
108
+ if (tag.structure && tag.content) {
109
+ const slots = slideRunSlots(slide);
110
+ if (slots.length !== n) report({code: 'color-reference-changed', path: `slides.${index}`, message: `The slide's text runs no longer match the stored colour references (${slots.length} runs now, ${n} at export), so the named run colours of slides.${index} (variables, scheme slots and roles) were not restored; the runs keep their resolved colours.`});
111
+ else for (const [slot, name] of at) if (slots[slot]) restoreName(slots[slot], name);
112
+ }
113
+ } else if (!tag?.record) {
114
+ for (const run of marked) if (typeof run._opfScheme === 'string') restoreName(run, run._opfScheme);
115
+ }
116
+ for (const run of marked) delete run._opfScheme;
117
+ });
118
+ }
@@ -13,6 +13,9 @@
13
13
  import * as opfCore from "@openpresentation/opf";
14
14
 
15
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;
16
19
  // The one paragraph-direction rule shared with the renderer (core FF-07).
17
20
  const paragraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
18
21
 
@@ -54,7 +57,37 @@ export function planScriptFonts(presentation, report) {
54
57
  report?.({code: "paragraph-direction-unavailable", path: "language",
55
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."});
56
59
  }
57
- return {deck, slides, lang: deck.lang, rtl};
60
+ return {deck, slides, lang: deck.lang, rtl, contentEastAsian: contentEastAsianFonts(presentation, deck, report)};
61
+ }
62
+
63
+ // FF-05: East Asian characters in a deck whose own language has no East Asian font (an English deck with Japanese
64
+ // text) still need a real East Asian theme font; an empty slot reads as an unresolved +mn-ea in PowerPoint. The
65
+ // script of the text picks the language core resolves it for: kana is Japanese, hangul Korean, Han alone Simplified
66
+ // Chinese. Returns {heading, body} families, or null when the deck language already selects one or the text has none.
67
+ const KANA = /[\p{Script=Hiragana}\p{Script=Katakana}]/u, HANGUL = /\p{Script=Hangul}/u, HAN = /\p{Script=Han}/u;
68
+ function contentEastAsianFonts(presentation, deck, report) {
69
+ if (deck.sources.eastAsian !== "latin") return null;
70
+ let kana = false, hangul = false, han = false;
71
+ const seen = new Set();
72
+ const walk = value => {
73
+ if (typeof value === "string") {
74
+ kana ||= KANA.test(value); hangul ||= HANGUL.test(value); han ||= HAN.test(value);
75
+ } else if (value && typeof value === "object" && !seen.has(value)) {
76
+ seen.add(value);
77
+ for (const item of Object.values(value)) walk(item);
78
+ }
79
+ };
80
+ walk(presentation.slides);
81
+ const language = kana ? "japanese" : hangul ? "korean" : han ? "chinese-simplified" : null;
82
+ if (!language) return null;
83
+ try {
84
+ const resolved = resolver({...presentation, language}, {slideIndex: 0});
85
+ return resolved.sources.eastAsian === "latin" ? null : {heading: resolved.heading.eastAsian, body: resolved.body.eastAsian};
86
+ } catch (error) {
87
+ report?.({code: "language-export-unavailable", path: "language",
88
+ message: `East Asian theme fonts for the East Asian text could not be resolved (${error instanceof Error ? error.message : String(error)}); the theme names the latin family for them.`});
89
+ return null;
90
+ }
58
91
  }
59
92
 
60
93
  const escapeAttribute = value => String(value).replace(/[&<>"']/g, char => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&apos;"}[char]));
@@ -79,8 +112,12 @@ export function themeScriptFonts(xml, plan) {
79
112
  for (const [tag, slots, family] of [["majorFont", heading, supplement?.heading], ["minorFont", body, supplement?.body]]) {
80
113
  xml = xml.replace(new RegExp(`<a:${tag}>[\\s\\S]*?</a:${tag}>`), block => {
81
114
  for (const [element, slot] of SCRIPT_SLOTS) {
82
- if (plan.deck.sources[slot] === "latin") continue;
83
- const face = escapeAttribute(slots[slot]);
115
+ // FF-05: the East Asian slot always names a font (a script font, a font for the East Asian text, else the latin
116
+ // family): PowerPoint lists an empty slot as an empty-name font through every paragraph end mark. Complex script
117
+ // stays empty unless a script font is selected.
118
+ const content = element === "ea" && plan.deck.sources[slot] === "latin" ? plan.contentEastAsian?.[tag === "majorFont" ? "heading" : "body"] : undefined;
119
+ if (plan.deck.sources[slot] === "latin" && element !== "ea") continue;
120
+ const face = escapeAttribute(content ?? slots[slot]);
84
121
  block = block.replace(new RegExp(`<a:${element}\\b[^>]*/>`), `<a:${element} typeface="${face}"/>`);
85
122
  }
86
123
  if (supplement && family) {
@@ -126,24 +163,89 @@ const decodeText = value => value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|(amp|lt|gt|q
126
163
  decimal ? String.fromCodePoint(Number(decimal)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : {amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'"}[name.toLowerCase()]);
127
164
 
128
165
  /**
129
- * In a right-to-left deck every paragraph states its direction from core's
130
- * paragraphDirection() over its own text: rtl="1" when it is right-to-left,
131
- * else an explicit rtl="0", because the master default levels start
132
- * right-to-left. Alignment is left as composed.
166
+ * RR-05 bidi runs. PowerPoint treats the digits of a run tagged with a right-to-left language as numbers of that language and
167
+ * orders them as their own item, so "v2.0" or "PowerPoint 365" inside an Arabic or Hebrew run reads "2.0v" or "365PowerPoint".
168
+ * Splitting each left-to-right Latin phrase into its own run tagged en-US keeps it one item that PowerPoint orders left to right.
133
169
  */
134
- function paragraphRtl(xml, deckDirection) {
170
+ const LATIN_WORD = /[0-9\p{Script=Latin}]/u, LATIN_LETTER = /\p{Script=Latin}/u, PHRASE_JOINER = /[ \u00a0.,:;&'\u2019/+*_@#=-]/;
171
+
172
+ /** Ranges [start, end) of the Latin phrases of a decoded string: a Latin letter through the last Latin letter or digit joined by spaces and word punctuation. */
173
+ export function latinPhrases(text) {
174
+ const ranges = [];
175
+ for (let index = 0; index < text.length;) {
176
+ const char = String.fromCodePoint(text.codePointAt(index));
177
+ if (!LATIN_LETTER.test(char)) { index += char.length; continue; }
178
+ let end = index + char.length;
179
+ for (let cursor = end; cursor < text.length;) {
180
+ const next = String.fromCodePoint(text.codePointAt(cursor));
181
+ if (LATIN_WORD.test(next)) { cursor += next.length; end = cursor; continue; }
182
+ if (!PHRASE_JOINER.test(next)) break;
183
+ // Joiners count only when a Latin letter or digit follows them directly.
184
+ let after = cursor + next.length;
185
+ while (after < text.length && PHRASE_JOINER.test(text[after])) after += 1;
186
+ if (after < text.length && LATIN_WORD.test(String.fromCodePoint(text.codePointAt(after)))) cursor = after; else break;
187
+ }
188
+ ranges.push([index, end]);
189
+ index = end;
190
+ }
191
+ return ranges;
192
+ }
193
+
194
+ const encodeText = value => value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
195
+ const RUN = /<a:r>(<a:rPr\b[^>]*\/>|<a:rPr\b[^>]*[^/]>[\s\S]*?<\/a:rPr>)<a:t>([^<]*)<\/a:t><\/a:r>/g;
196
+
197
+ /** Split the runs of a right-to-left paragraph so each Latin phrase is its own en-US run. */
198
+ function splitLatinRuns(body) {
199
+ return body.replace(RUN, (run, properties, escaped) => {
200
+ const text = decodeText(escaped), phrases = latinPhrases(text);
201
+ if (!phrases.length) return run;
202
+ const make = (value, lang) => `<a:r>${lang ? properties.replace(/\slang="[^"]*"/, ` lang="${lang}"`) : properties}<a:t>${encodeText(value)}</a:t></a:r>`;
203
+ const pieces = [];
204
+ let cursor = 0;
205
+ for (const [from, to] of phrases) {
206
+ if (from > cursor) pieces.push(make(text.slice(cursor, from)));
207
+ pieces.push(make(text.slice(from, to), "en-US"));
208
+ cursor = to;
209
+ }
210
+ if (cursor < text.length) pieces.push(make(text.slice(cursor)));
211
+ return pieces.join("");
212
+ });
213
+ }
214
+
215
+ /**
216
+ * In a right-to-left deck every paragraph states its direction. The writers mark the lines of a paragraph they laid out
217
+ * (rtl="1", the direction core computed once for the whole paragraph, so wrapped lines never differ); any other paragraph takes
218
+ * core's paragraphDirection() over its own text: rtl="1" when it is right-to-left, else an explicit rtl="0", because the master
219
+ * default levels start right-to-left. A right-to-left paragraph with no explicit alignment starts at the right edge (logical
220
+ * alignment: the master default is left), and its Latin phrases become en-US runs (see splitLatinRuns). With `notes` the
221
+ * alignment is also set on an explicit left paragraph, because the notes writers pass no alignment of their own.
222
+ */
223
+ function paragraphRtl(xml, deckDirection, {notes = false} = {}) {
135
224
  return xml.replace(/<a:p>([\s\S]*?)<\/a:p>/g, (paragraph, body) => {
136
225
  const text = [...body.matchAll(/<a:t>([^<]*)<\/a:t>|<a:br\b/g)].map(match => match[1] === undefined ? "\n" : decodeText(match[1])).join("");
137
- const value = paragraphDirection(text, deckDirection) === "rtl" ? "1" : "0";
138
226
  const properties = /^(\s*)<a:pPr\b([^>]*?)(\/?)>/.exec(body);
139
- if (!properties) return `<a:p><a:pPr rtl="${value}"/>${body}</a:p>`;
140
- const attributes = / rtl="[^"]*"/.test(properties[2]) ? properties[2].replace(/ rtl="[^"]*"/, ` rtl="${value}"`) : `${properties[2]} rtl="${value}"`;
227
+ const marked = properties && / rtl="([01])"/.exec(properties[2]);
228
+ const rtl = marked ? marked[1] === "1" : paragraphDirection(text, deckDirection) === "rtl";
229
+ const value = rtl ? "1" : "0";
230
+ if (rtl) body = splitLatinRuns(body);
231
+ if (!properties) return `<a:p><a:pPr rtl="${value}"${rtl ? " algn=\"r\"" : ""}/>${body}</a:p>`;
232
+ let attributes = / rtl="[^"]*"/.test(properties[2]) ? properties[2].replace(/ rtl="[^"]*"/, ` rtl="${value}"`) : `${properties[2]} rtl="${value}"`;
233
+ if (rtl) {
234
+ if (!/\salgn="/.test(attributes)) attributes += " algn=\"r\"";
235
+ else if (notes) attributes = attributes.replace(/ algn="l"/, " algn=\"r\"");
236
+ }
141
237
  return `<a:p>${properties[1]}<a:pPr${attributes}${properties[3]}>${body.slice(properties[0].length)}</a:p>`;
142
238
  });
143
239
  }
144
240
 
145
- /** Master, layout and presentation default paragraph levels start right-to-left. */
146
- const levelRtl = xml => xml.replace(/(<a:(?:lvl\dpPr|defPPr)\b[^>]*?\srtl=")0(")/g, "$11$2");
241
+ /** Tables of a right-to-left deck run right to left: the first column is the rightmost, as core's layout draws it. */
242
+ const tableRtl = xml => xml.replace(/<a:tblPr\b([^>]*?)(\/?)>/g, (match, attributes, close) => / rtl="/.test(attributes) ? match : `<a:tblPr rtl="1"${attributes}${close}>`);
243
+
244
+ /**
245
+ * Master, layout and presentation default paragraph levels start right-to-left, and a left-aligned level starts at the right edge
246
+ * (logical alignment): text typed later in an Arabic or Hebrew deck then reads and aligns as the exported slides do.
247
+ */
248
+ const levelRtl = xml => xml.replace(/<a:(?:lvl\dpPr|defPPr)\b[^>]*?\srtl="0"[^>]*>/g, level => level.replace(/\srtl="0"/, " rtl=\"1\"").replace(/\salgn="l"/, " algn=\"r\""));
147
249
 
148
250
  /**
149
251
  * Apply the plan to one generated XML part. `slideIndex` names the slide a
@@ -157,10 +259,10 @@ export function partScriptFonts(path, xml, plan, slideIndex) {
157
259
  if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
158
260
  xml = xml.replace(/<p:(sp|graphicFrame)>[\s\S]*?<\/p:\1>/g, shape =>
159
261
  runScriptFonts(shape, resolved, /<p:cNvPr\b[^>]*\bname="OPF heading /.test(shape)));
160
- if (plan.rtl) xml = paragraphRtl(xml, plan.deck.direction);
262
+ if (plan.rtl) xml = tableRtl(paragraphRtl(xml, plan.deck.direction));
161
263
  } else if (/^ppt\/(?:charts\/chart(?:Ex)?|notesSlides\/notesSlide)\d+\.xml$/.test(path)) {
162
264
  xml = runScriptFonts(xml, resolved, false);
163
- if (plan.rtl && path.startsWith("ppt/notesSlides/")) xml = paragraphRtl(xml, plan.deck.direction);
265
+ if (plan.rtl && path.startsWith("ppt/notesSlides/")) xml = paragraphRtl(xml, plan.deck.direction, {notes: true});
164
266
  } else if (plan.rtl && /^ppt\/(?:slideMasters\/slideMaster\d+|slideLayouts\/slideLayout\d+|notesMasters\/notesMaster\d+|presentation)\.xml$/.test(path)) {
165
267
  xml = levelRtl(xml);
166
268
  }
@@ -252,6 +354,12 @@ export function reconcileLanguage(groups, observed, report) {
252
354
  * language, and theme East Asian/complex-script fonts the document does not
253
355
  * reproduce.
254
356
  */
357
+ /** Whether the language observed in the runs is written right to left. */
358
+ export function observedRtl(observed) {
359
+ if (observed.lang === undefined) return false;
360
+ try { return resolver ? resolver({language: observed.language}).rtl === true : observed.match?.record.direction === "rtl"; } catch { return false; }
361
+ }
362
+
255
363
  export function languageDiagnostics(imported, observed, report) {
256
364
  if (!report) return;
257
365
  const {lang, match, ranked, rtlParagraphs, theme} = observed;
@@ -259,9 +367,11 @@ export function languageDiagnostics(imported, observed, report) {
259
367
  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.`});
260
368
  return;
261
369
  }
262
- if (ranked.length > 1) {
370
+ // RR-05: a right-to-left deck's Latin phrases are their own en-US runs; they are not a second presentation language.
371
+ const languages = observedRtl(observed) ? ranked.filter(([tag]) => tag === lang || tag !== "en-US") : ranked;
372
+ if (languages.length > 1) {
263
373
  report({code: "mixed-run-languages", path: "language",
264
- message: `Runs use ${ranked.length} languages (${ranked.map(([tag, count]) => `${tag} x${count}`).join(", ")}). OPF has one presentation language, so ${lang} was imported.`});
374
+ message: `Runs use ${languages.length} languages (${languages.map(([tag, count]) => `${tag} x${count}`).join(", ")}). OPF has one presentation language, so ${lang} was imported.`});
265
375
  }
266
376
  const kept = imported.language === observed.language;
267
377
  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.`});
@@ -288,3 +398,26 @@ export function languageDiagnostics(imported, observed, report) {
288
398
  }
289
399
  }
290
400
  }
401
+
402
+ /**
403
+ * FF-05: PptxGenJS writes every run's East Asian and complex-script font as a copy of the Latin face, with the
404
+ * charsets of other scripts (-122, -120). PowerPoint lists such a run font as an empty-name font in
405
+ * Presentation.Fonts and hides the real one (the charset is not the cause: a bare typeface does the same).
406
+ * PowerPoint's own runs name only the Latin face; East Asian and complex-script faces come from the paragraph or
407
+ * master style and the theme slots (+mn-ea, +mn-cs), which the exporter writes. A run keeps no explicit ea/cs
408
+ * typeface; theme references stay.
409
+ */
410
+ export function stripRunScriptFonts(xml) {
411
+ return xml.replace(/<a:(?:ea|cs)\s+typeface="(?!\+)[^"]*"[^>]*\/>/g, "");
412
+ }
413
+
414
+ /**
415
+ * Without core's resolver the theme's East Asian slot still repeats the latin family of its font group, which keeps
416
+ * the paragraph end marks from reading an empty font (see themeScriptFonts).
417
+ */
418
+ export function themeEastAsianFromLatin(xml) {
419
+ return xml.replace(/<a:(majorFont|minorFont)>[\s\S]*?<\/a:\1>/g, block => {
420
+ const latin = /<a:latin\b[^>]*\btypeface="([^"]+)"/.exec(block)?.[1];
421
+ return latin ? block.replace(/<a:ea typeface=""\/>/, `<a:ea typeface="${latin}"/>`) : block;
422
+ });
423
+ }