@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.
- package/README.md +22 -3
- package/dist/annotation-export.js +83 -0
- package/dist/annotation-provenance.js +246 -0
- package/dist/body-text-import.js +4 -1
- package/dist/chart-options.js +276 -0
- package/dist/chartex.js +42 -11
- package/dist/code-provenance.js +34 -10
- package/dist/content-topology.js +259 -52
- package/dist/document-provenance.js +77 -5
- package/dist/index.d.ts +17 -1
- package/dist/index.js +304 -67
- package/dist/logo-provenance.js +4 -3
- package/dist/master-themes.js +49 -0
- package/dist/metric-provenance.js +6 -3
- package/dist/native-text-style.js +17 -1
- package/dist/numbered-list.js +114 -0
- package/dist/package-fonts.js +6 -4
- package/dist/run-colors.js +118 -0
- package/dist/script-fonts.js +150 -17
- package/dist/table-cell-import.js +3 -1
- package/dist/table-import.js +3 -2
- package/package.json +6 -6
package/dist/logo-provenance.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
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)
|
|
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
|
+
}
|
package/dist/package-fonts.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
156
|
-
|
|
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
|
+
}
|
package/dist/script-fonts.js
CHANGED
|
@@ -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 => ({"&": "&", "<": "<", ">": ">", "\"": """, "'": "'"}[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
|
-
|
|
83
|
-
|
|
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
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
|
|
140
|
-
const
|
|
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
|
-
/**
|
|
146
|
-
const
|
|
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
|
-
|
|
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 ${
|
|
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
|
+
}
|