@openpresentation/opf-pptx 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPENDENCY-NOTES.md +1 -1
- package/README.md +108 -17
- package/dist/background-import.js +59 -3
- package/dist/background.js +86 -1
- package/dist/body-text-import.js +86 -0
- package/dist/code-provenance.js +9 -3
- package/dist/document-provenance.js +998 -0
- package/dist/font-weights.js +63 -0
- package/dist/furniture-fields.js +176 -0
- package/dist/furniture-provenance.js +138 -18
- package/dist/image-geometry.js +77 -5
- package/dist/index.d.ts +112 -2
- package/dist/index.js +873 -110
- package/dist/media-provenance.js +237 -0
- package/dist/native-text-style.js +71 -0
- package/dist/package-fonts.js +161 -0
- package/dist/script-fonts.js +290 -0
- package/dist/slide-image-provenance.js +169 -0
- package/dist/table-import.js +1 -53
- package/dist/theme-colors.js +204 -0
- package/dist/typeface-inventory.js +227 -0
- package/package.json +15 -7
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// FF-24: write the deck's OPF color scheme into the exported theme and refer to
|
|
2
|
+
// it with a:schemeClr where the document names a scheme slot or role.
|
|
3
|
+
//
|
|
4
|
+
// OPF color schemes use the twelve OOXML theme slots (opf color-scheme.schema.json,
|
|
5
|
+
// docs/design-resolution.md). The theme clrScheme is deck-wide; PowerPoint has
|
|
6
|
+
// no per-slide theme. A content color becomes a scheme reference only when the
|
|
7
|
+
// document names a slot or role AND the deck theme slot holds exactly the color
|
|
8
|
+
// the slide resolved, so slide-level scheme overrides, variables, literals and
|
|
9
|
+
// engine-derived chrome colors stay literal sRGB.
|
|
10
|
+
|
|
11
|
+
import {readBackgroundColor} from './background.js';
|
|
12
|
+
|
|
13
|
+
/** OPF slot -> clrScheme child element, in CT_ColorScheme order. */
|
|
14
|
+
export const THEME_SLOTS = Object.freeze([
|
|
15
|
+
['dark1', 'dk1'], ['light1', 'lt1'], ['dark2', 'dk2'], ['light2', 'lt2'],
|
|
16
|
+
['accent1', 'accent1'], ['accent2', 'accent2'], ['accent3', 'accent3'],
|
|
17
|
+
['accent4', 'accent4'], ['accent5', 'accent5'], ['accent6', 'accent6'],
|
|
18
|
+
['hyperlink', 'hlink'], ['followedHyperlink', 'folHlink'],
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// Slide content reaches the theme through the master clrMap. The vendored
|
|
22
|
+
// master maps bg1=lt1, tx1=dk1, bg2=lt2, tx2=dk2 and every other slot to itself.
|
|
23
|
+
const CONTENT_VALUES = Object.freeze({
|
|
24
|
+
dark1: 'tx1', light1: 'bg1', dark2: 'tx2', light2: 'bg2',
|
|
25
|
+
accent1: 'accent1', accent2: 'accent2', accent3: 'accent3',
|
|
26
|
+
accent4: 'accent4', accent5: 'accent5', accent6: 'accent6',
|
|
27
|
+
hyperlink: 'hlink', followedHyperlink: 'folHlink',
|
|
28
|
+
});
|
|
29
|
+
// PptxGenJS 4.0.1 createColorElement accepts only these scheme values.
|
|
30
|
+
const VENDOR_VALUES = new Set(['tx1', 'tx2', 'bg1', 'bg2', 'accent1', 'accent2', 'accent3', 'accent4', 'accent5', 'accent6']);
|
|
31
|
+
|
|
32
|
+
// Role names resolve through core resolveColorRef with the exporter's resolved
|
|
33
|
+
// chrome colors (color-ref.js coreRoles). These are the slots each role can land
|
|
34
|
+
// on; a slot is used only when its deck theme value equals the resolved color.
|
|
35
|
+
const ROLE_SLOTS = Object.freeze({
|
|
36
|
+
primary: ['accent1'],
|
|
37
|
+
secondary: ['accent2'],
|
|
38
|
+
accent: ['accent1', 'accent3'],
|
|
39
|
+
text: ['dark1', 'light1'],
|
|
40
|
+
textSecondary: ['dark2', 'light2'],
|
|
41
|
+
surface: ['light2', 'dark2'],
|
|
42
|
+
});
|
|
43
|
+
// Abstract roles supply a slot only when the scheme leaves that slot unset.
|
|
44
|
+
const ROLE_FOR_SLOT = Object.freeze({accent1: 'primary', accent2: 'secondary', accent3: 'accent', light1: 'background', light2: 'surface', dark1: 'text', dark2: 'textSecondary'});
|
|
45
|
+
|
|
46
|
+
const SLOT_NAMES = new Set(THEME_SLOTS.map(([slot]) => slot));
|
|
47
|
+
|
|
48
|
+
export function sixDigitHex(value) {
|
|
49
|
+
if (typeof value !== 'string') return undefined;
|
|
50
|
+
let raw = value.trim().replace(/^#/, '');
|
|
51
|
+
if (/^[\da-f]{3}$/i.test(raw)) raw = [...raw].map(char => char + char).join('');
|
|
52
|
+
if (/^[\da-f]{8}$/i.test(raw) && /ff$/i.test(raw)) raw = raw.slice(0, 6);
|
|
53
|
+
return /^[\da-f]{6}$/i.test(raw) ? raw.toUpperCase() : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Twelve-slot map (OPF slot -> RRGGBB) for a resolved color scheme. Unset slots are omitted. */
|
|
57
|
+
export function themeSlotColors(colorScheme = {}) {
|
|
58
|
+
const colors = {};
|
|
59
|
+
for (const [slot] of THEME_SLOTS) {
|
|
60
|
+
const value = sixDigitHex(colorScheme[slot]) ?? sixDigitHex(colorScheme[ROLE_FOR_SLOT[slot]]);
|
|
61
|
+
if (value) colors[slot] = value;
|
|
62
|
+
}
|
|
63
|
+
return colors;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const escapeXml = value => String(value).replace(/[&<>"']/g, char => ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[char]));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Replace the vendored Office clrScheme with the deck scheme. Slots the scheme
|
|
70
|
+
* leaves unset keep the vendored value. With a theme name, rename the theme and
|
|
71
|
+
* its thm15 theme family so an OPF catalog theme can be recognized on import.
|
|
72
|
+
*/
|
|
73
|
+
export function writeThemeColors(xml, {colors, schemeName, themeName}) {
|
|
74
|
+
let result = xml.replace(/<a:clrScheme\b[^>]*>([\s\S]*?)<\/a:clrScheme>/, (scheme, body) => {
|
|
75
|
+
for (const [slot, element] of THEME_SLOTS) {
|
|
76
|
+
if (!colors[slot]) continue;
|
|
77
|
+
body = body.replace(new RegExp(`<a:${element}>[\\s\\S]*?</a:${element}>`), `<a:${element}><a:srgbClr val="${colors[slot]}"/></a:${element}>`);
|
|
78
|
+
}
|
|
79
|
+
return `<a:clrScheme name="${escapeXml(schemeName)}">${body}</a:clrScheme>`;
|
|
80
|
+
});
|
|
81
|
+
if (themeName) {
|
|
82
|
+
const name = escapeXml(themeName);
|
|
83
|
+
result = result.replace(/(<a:theme\b[^>]*\bname=")[^"]*(")/, `$1${name}$2`)
|
|
84
|
+
.replace(/(<thm15:themeFamily\b[^>]*\bname=")[^"]*(")/, `$1${name}$2`);
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function backgroundSlot(definition) {
|
|
90
|
+
if (typeof definition === 'string' && !definition.startsWith('#')) return definition;
|
|
91
|
+
if (definition && typeof definition === 'object' && definition.type === 'theme') return definition.slot;
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The slide-content scheme value (tx1, bg2, accent3, hlink, ...) for a document
|
|
97
|
+
* color reference, or undefined when it must stay a literal color.
|
|
98
|
+
*
|
|
99
|
+
* `hex` is the color the exporter already resolved for this slide. The reference
|
|
100
|
+
* qualifies only when it names a slot or role and the deck theme slot, which the
|
|
101
|
+
* schemeClr will actually resolve to, holds that exact opaque color.
|
|
102
|
+
* `vendor` limits the result to scheme values PptxGenJS 4.0.1 can emit.
|
|
103
|
+
*/
|
|
104
|
+
export function schemeColorValue(reference, hex, context, {vendor = false} = {}) {
|
|
105
|
+
if (typeof reference !== 'string') return undefined;
|
|
106
|
+
const name = reference.trim();
|
|
107
|
+
const resolved = sixDigitHex(hex);
|
|
108
|
+
const deck = context.themeColors;
|
|
109
|
+
if (!resolved || !deck) return undefined;
|
|
110
|
+
let slots;
|
|
111
|
+
if (SLOT_NAMES.has(name)) slots = [name];
|
|
112
|
+
else if (name === 'background') slots = [backgroundSlot(context.backgroundDefinition)].filter(slot => SLOT_NAMES.has(slot));
|
|
113
|
+
else slots = ROLE_SLOTS[name] ?? [];
|
|
114
|
+
const effective = themeSlotColors(context.colorScheme);
|
|
115
|
+
for (const slot of slots) {
|
|
116
|
+
if (deck[slot] !== resolved || effective[slot] !== resolved) continue;
|
|
117
|
+
const value = CONTENT_VALUES[slot];
|
|
118
|
+
if (!vendor || VENDOR_VALUES.has(value)) return value;
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Native slide background fill for a theme-slot background, or null when it must stay literal. */
|
|
124
|
+
export function schemeBackgroundFill(definition, context) {
|
|
125
|
+
const slot = backgroundSlot(definition);
|
|
126
|
+
if (!slot) return null;
|
|
127
|
+
const value = schemeColorValue(slot, context.colors?.background, context);
|
|
128
|
+
if (!value) return null;
|
|
129
|
+
const opacity = typeof definition === 'object' && Number.isFinite(definition.opacity) ? definition.opacity : 1;
|
|
130
|
+
const alpha = Math.round(Math.max(0, Math.min(1, opacity)) * 100000);
|
|
131
|
+
return `<a:solidFill>${alpha === 100000 ? `<a:schemeClr val="${value}"/>` : `<a:schemeClr val="${value}"><a:alpha val="${alpha}"/></a:schemeClr>`}</a:solidFill>`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Import
|
|
136
|
+
|
|
137
|
+
const asArray = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
|
|
138
|
+
|
|
139
|
+
/** Read the twelve clrScheme slots as RRGGBB; unreadable slots are listed separately. */
|
|
140
|
+
export function readThemeSlotColors(clrScheme) {
|
|
141
|
+
const colors = {}, unreadable = [];
|
|
142
|
+
for (const [slot, element] of THEME_SLOTS) {
|
|
143
|
+
const node = clrScheme?.[`a:${element}`];
|
|
144
|
+
const color = node && !Array.isArray(node) ? readBackgroundColor(node) : null;
|
|
145
|
+
if (color && color.alpha === 1) colors[slot] = color.hex.slice(1);
|
|
146
|
+
else unreadable.push(slot);
|
|
147
|
+
}
|
|
148
|
+
return {colors, unreadable};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function recordColors(record) {
|
|
152
|
+
return Object.fromEntries(THEME_SLOTS.map(([slot]) => [slot, sixDigitHex(record[slot])]));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Recover design.colorScheme from theme clrScheme colors.
|
|
157
|
+
* An exact twelve-slot match with a catalog record yields its id (the clrScheme
|
|
158
|
+
* name breaks ties). Otherwise the colors are inline: relative to the record
|
|
159
|
+
* whose name the clrScheme carries, or as all readable slots.
|
|
160
|
+
*/
|
|
161
|
+
export function recoverColorScheme({colors, unreadable, name}, records) {
|
|
162
|
+
const complete = unreadable.length === 0;
|
|
163
|
+
const exact = complete ? records.filter(record => THEME_SLOTS.every(([slot]) => recordColors(record)[slot] === colors[slot])) : [];
|
|
164
|
+
const named = record => record.name === name || record.id === name;
|
|
165
|
+
if (exact.length) return {value: (exact.find(named) ?? exact[0]).id, exact: true};
|
|
166
|
+
const base = records.find(named);
|
|
167
|
+
const inline = {};
|
|
168
|
+
if (base) {
|
|
169
|
+
inline.id = base.id;
|
|
170
|
+
const baseColors = recordColors(base);
|
|
171
|
+
for (const [slot] of THEME_SLOTS) if (colors[slot] && colors[slot] !== baseColors[slot]) inline[slot] = `#${colors[slot]}`;
|
|
172
|
+
} else {
|
|
173
|
+
for (const [slot] of THEME_SLOTS) if (colors[slot]) inline[slot] = `#${colors[slot]}`;
|
|
174
|
+
}
|
|
175
|
+
return {value: inline, exact: false};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** First slide master's theme part, else the presentation's theme relationship. */
|
|
179
|
+
export function presentationThemePath(presentationRoot, presentationRels, relationshipsFor, entries) {
|
|
180
|
+
const byId = presentationRels;
|
|
181
|
+
const masterIds = asArray(presentationRoot?.['p:sldMasterIdLst']?.['p:sldMasterId']).map(node => node?.['r:id']);
|
|
182
|
+
const masterPath = masterIds.map(id => byId.get(id)).find(rel => rel?.type?.endsWith('/slideMaster') && entries[rel.path])?.path
|
|
183
|
+
?? [...byId.values()].find(rel => rel.type?.endsWith('/slideMaster') && entries[rel.path])?.path;
|
|
184
|
+
const fromMaster = masterPath && [...relationshipsFor(masterPath).values()].find(rel => rel.type?.endsWith('/theme') && entries[rel.path])?.path;
|
|
185
|
+
return fromMaster ?? [...byId.values()].find(rel => rel.type?.endsWith('/theme') && entries[rel.path])?.path;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Identify a catalog theme from the native theme name. The name must equal a
|
|
190
|
+
* catalog theme name, and the package must corroborate it with that theme's
|
|
191
|
+
* color scheme or its heading/body font pair, so a foreign deck that happens to
|
|
192
|
+
* use a theme called "Bold" is not bound to the OPF theme.
|
|
193
|
+
*/
|
|
194
|
+
export function recoverTheme({themeName, colors, majorFont, minorFont}, {themes, colorSchemes, fontFamilies}) {
|
|
195
|
+
if (!themeName) return {};
|
|
196
|
+
const theme = themes.find(record => record.name === themeName);
|
|
197
|
+
if (!theme) return {};
|
|
198
|
+
const scheme = colorSchemes.find(record => record.id === theme.colorScheme);
|
|
199
|
+
const schemeColors = scheme ? recordColors(scheme) : null;
|
|
200
|
+
const colorMatch = !!schemeColors && THEME_SLOTS.every(([slot]) => schemeColors[slot] === colors[slot]);
|
|
201
|
+
const fonts = fontFamilies(theme.fontScheme);
|
|
202
|
+
const fontMatch = !!fonts && fonts.heading === majorFont && fonts.body === minorFont;
|
|
203
|
+
return colorMatch || fontMatch ? {id: theme.id} : {unverified: theme.id};
|
|
204
|
+
}
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import {unzipSync} from 'fflate';
|
|
2
|
+
|
|
3
|
+
// Typeface inventory for a PPTX package (FF-08, font-fidelity-everywhere).
|
|
4
|
+
//
|
|
5
|
+
// Walks every XML part, including parts of nested OOXML packages such as the
|
|
6
|
+
// embedded chart workbooks, and records each font name the package carries:
|
|
7
|
+
// DrawingML `typeface` attributes (latin, ea, cs, sym, buFont, theme script
|
|
8
|
+
// supplements, embedded-font lists), SpreadsheetML font names (styles `name`,
|
|
9
|
+
// rich-text `rFont`) and the docProps/app.xml "Fonts Used" list.
|
|
10
|
+
// checkPptxTypefaces() then applies the owner font policy: a package names only
|
|
11
|
+
// the fonts its author chose, plus theme references that resolve to them, empty
|
|
12
|
+
// theme script slots (FF-05) and the documented theme script supplements.
|
|
13
|
+
|
|
14
|
+
const decoder = new TextDecoder();
|
|
15
|
+
const NESTED_PACKAGE = /\.(?:xlsx|xlsm|docx|pptx)$/i;
|
|
16
|
+
const XML_PART = /\.(?:xml|rels)$/i;
|
|
17
|
+
const THEME_PART = /(?:^|\/)theme\/theme\d+\.xml$/;
|
|
18
|
+
const THEME_REFERENCE = /^\+(mj|mn)-(lt|ea|cs)$/;
|
|
19
|
+
const THEME_SLOT = {lt: 'latin', ea: 'ea', cs: 'cs'};
|
|
20
|
+
const FIXED_PITCH = 1;
|
|
21
|
+
|
|
22
|
+
// Per-script theme fonts (`<a:font script="…">`) written by the vendored
|
|
23
|
+
// PptxGenJS 4.0.1 theme. They are Office's defaults for scripts the deck
|
|
24
|
+
// declares no font for. PowerPoint uses one only for text in that script, and
|
|
25
|
+
// it does not list them in "Fonts Used". The check allows exactly these
|
|
26
|
+
// script/typeface pairs in the presentation theme; any other supplement fails
|
|
27
|
+
// unless it names a chosen font. test/typeface-inventory.mjs pins this list to
|
|
28
|
+
// the vendored theme.
|
|
29
|
+
const supplement = entries => Object.freeze(Object.fromEntries(entries.split('|').map(pair => pair.split('='))));
|
|
30
|
+
const SHARED_SCRIPTS = 'Hang=맑은 고딕|Hant=新細明體|Ethi=Nyala|Beng=Vrinda|Gujr=Shruti|Knda=Tunga|Guru=Raavi|Cans=Euphemia|Cher=Plantagenet Cherokee|Yiii=Microsoft Yi Baiti|Tibt=Microsoft Himalaya|Thaa=MV Boli|Deva=Mangal|Telu=Gautami|Taml=Latha|Syrc=Estrangelo Edessa|Orya=Kalinga|Mlym=Kartika|Laoo=DokChampa|Sinh=Iskoola Pota|Mong=Mongolian Baiti|Uigh=Microsoft Uighur|Geor=Sylfaen|Armn=Arial|Bugi=Leelawadee UI|Bopo=Microsoft JhengHei|Java=Javanese Text|Lisu=Segoe UI|Mymr=Myanmar Text|Nkoo=Ebrima|Olck=Nirmala UI|Osma=Ebrima|Phag=Phagspa|Syrn=Estrangelo Edessa|Syrj=Estrangelo Edessa|Syre=Estrangelo Edessa|Sora=Nirmala UI|Tale=Microsoft Tai Le|Talu=Microsoft New Tai Lue|Tfng=Ebrima';
|
|
31
|
+
export const THEME_SCRIPT_SUPPLEMENTS = Object.freeze({
|
|
32
|
+
major: supplement(`Jpan=游ゴシック Light|Hans=等线 Light|Arab=Times New Roman|Hebr=Times New Roman|Thai=Angsana New|Khmr=MoolBoran|Viet=Times New Roman|${SHARED_SCRIPTS}`),
|
|
33
|
+
minor: supplement(`Jpan=游ゴシック|Hans=等线|Arab=Arial|Hebr=Arial|Thai=Cordia New|Khmr=DaunPenh|Viet=Arial|${SHARED_SCRIPTS}`)
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export function inventoryPptxTypefaces(input, options = {}) {
|
|
37
|
+
const typefaces = [];
|
|
38
|
+
const themes = {};
|
|
39
|
+
let fontsUsed = null;
|
|
40
|
+
const visit = (entries, prefix) => {
|
|
41
|
+
const packageThemes = [];
|
|
42
|
+
for (const path of Object.keys(entries).sort()) {
|
|
43
|
+
const bytes = entries[path];
|
|
44
|
+
if (NESTED_PACKAGE.test(path)) {
|
|
45
|
+
if (options.nested !== false) visit(unzipSync(bytes), `${prefix}${path}!/`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!XML_PART.test(path)) continue;
|
|
49
|
+
const xml = decoder.decode(bytes);
|
|
50
|
+
const part = prefix + path;
|
|
51
|
+
if (THEME_PART.test(path)) packageThemes.push(part);
|
|
52
|
+
readTypefaces(xml, part, THEME_PART.test(path), typefaces);
|
|
53
|
+
if (/^xl\/.*\.xml$/.test(path)) readSpreadsheetFonts(xml, part, typefaces);
|
|
54
|
+
if (!prefix && path === 'docProps/app.xml') fontsUsed = readFontsUsed(xml);
|
|
55
|
+
}
|
|
56
|
+
// Theme references resolve against the package theme. The exporter writes
|
|
57
|
+
// one theme per package; with several, the first (theme1) is used.
|
|
58
|
+
const theme = packageThemes[0];
|
|
59
|
+
if (theme) themes[prefix] = themeFonts(typefaces, theme);
|
|
60
|
+
};
|
|
61
|
+
visit(toEntries(input), '');
|
|
62
|
+
for (const entry of typefaces) {
|
|
63
|
+
const reference = THEME_REFERENCE.exec(entry.typeface);
|
|
64
|
+
if (!reference) continue;
|
|
65
|
+
const prefix = entry.part.includes('!/') ? entry.part.slice(0, entry.part.lastIndexOf('!/') + 2) : '';
|
|
66
|
+
const resolved = themes[prefix]?.[reference[1] === 'mj' ? 'major' : 'minor']?.[THEME_SLOT[reference[2]]];
|
|
67
|
+
entry.resolved = resolved ?? null;
|
|
68
|
+
}
|
|
69
|
+
return {typefaces, themes, fontsUsed};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Fonts a package uses, as PowerPoint lists them under "Fonts Used": the
|
|
73
|
+
// presentation theme's major/minor fonts and every explicit font in the
|
|
74
|
+
// presentation's own parts (slides, layouts, masters, notes, charts), with
|
|
75
|
+
// theme references resolved. Script supplements, empty slots and nested
|
|
76
|
+
// packages are not fonts in use. Sorted by a locale-independent order.
|
|
77
|
+
export function packageFontsUsed(inventory) {
|
|
78
|
+
const used = new Set();
|
|
79
|
+
for (const entry of inventory.typefaces) {
|
|
80
|
+
if (entry.part.includes('!/') || !entry.part.startsWith('ppt/') || entry.script !== undefined || entry.kind !== 'drawingml') continue;
|
|
81
|
+
const value = THEME_REFERENCE.test(entry.typeface) ? entry.resolved : entry.typeface;
|
|
82
|
+
if (value) used.add(value);
|
|
83
|
+
}
|
|
84
|
+
return [...used].sort(compareFontNames);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function checkPptxTypefaces(input, options = {}) {
|
|
88
|
+
if (!Array.isArray(options.fonts) || options.fonts.some(font => typeof font !== 'string' || !font)) {
|
|
89
|
+
throw new TypeError('checkPptxTypefaces requires options.fonts, the non-empty family names the author chose.');
|
|
90
|
+
}
|
|
91
|
+
const chosen = new Set(options.fonts);
|
|
92
|
+
const monospace = new Set(options.monospace ?? []);
|
|
93
|
+
const allowEmpty = options.allowEmptyThemeScripts ?? true;
|
|
94
|
+
const supplements = options.scriptSupplements ?? THEME_SCRIPT_SUPPLEMENTS;
|
|
95
|
+
const inventory = inventoryPptxTypefaces(input);
|
|
96
|
+
const violations = [];
|
|
97
|
+
const fail = (entry, reason, details = {}) => violations.push({reason, part: entry.part, element: entry.element, typeface: entry.typeface, ...details});
|
|
98
|
+
const emptyAllowed = entry => allowEmpty && entry.theme && (entry.element === 'ea' || entry.element === 'cs');
|
|
99
|
+
const pitches = new Map();
|
|
100
|
+
for (const entry of inventory.typefaces) {
|
|
101
|
+
const reference = THEME_REFERENCE.exec(entry.typeface);
|
|
102
|
+
if (reference) {
|
|
103
|
+
if (entry.resolved === null) fail(entry, 'unresolved-theme-reference');
|
|
104
|
+
else if (entry.resolved === '') { if (!allowEmpty || reference[2] === 'lt') fail(entry, 'empty-theme-reference'); }
|
|
105
|
+
else if (!chosen.has(entry.resolved)) fail(entry, 'foreign-theme-reference', {resolved: entry.resolved});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (entry.typeface === '') {
|
|
109
|
+
if (!emptyAllowed(entry)) fail(entry, 'empty-typeface');
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (entry.script !== undefined) {
|
|
113
|
+
const documented = !entry.part.includes('!/') && Boolean(entry.theme) && supplements[entry.theme]?.[entry.script] === entry.typeface;
|
|
114
|
+
if (!documented && !chosen.has(entry.typeface)) fail(entry, 'foreign-script-supplement', {script: entry.script, theme: entry.theme});
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!chosen.has(entry.typeface)) fail(entry, 'foreign-typeface');
|
|
118
|
+
if (entry.pitchFamily !== undefined) {
|
|
119
|
+
const fixed = (entry.pitchFamily & 0x03) === FIXED_PITCH;
|
|
120
|
+
if (monospace.has(entry.typeface) && !fixed) fail(entry, 'monospace-not-fixed-pitch', {pitchFamily: entry.pitchFamily});
|
|
121
|
+
if (!monospace.has(entry.typeface) && fixed) fail(entry, 'fixed-pitch-not-monospace', {pitchFamily: entry.pitchFamily});
|
|
122
|
+
const seen = pitches.get(entry.typeface);
|
|
123
|
+
if (seen === undefined) pitches.set(entry.typeface, entry.pitchFamily);
|
|
124
|
+
else if (seen !== entry.pitchFamily) fail(entry, 'inconsistent-pitch-family', {pitchFamily: entry.pitchFamily, first: seen});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const expected = packageFontsUsed(inventory);
|
|
128
|
+
if (inventory.fontsUsed === null) {
|
|
129
|
+
violations.push({reason: 'missing-fonts-used', part: 'docProps/app.xml'});
|
|
130
|
+
} else {
|
|
131
|
+
for (const font of inventory.fontsUsed) {
|
|
132
|
+
if (!chosen.has(font)) violations.push({reason: 'foreign-fonts-used', part: 'docProps/app.xml', typeface: font});
|
|
133
|
+
}
|
|
134
|
+
if (inventory.fontsUsed.join('\n') !== expected.join('\n')) {
|
|
135
|
+
violations.push({reason: 'fonts-used-mismatch', part: 'docProps/app.xml', listed: inventory.fontsUsed, expected});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return {ok: violations.length === 0, violations, inventory, fontsUsed: expected};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function compareFontNames(left, right) {
|
|
142
|
+
const a = left.toLowerCase(), b = right.toLowerCase();
|
|
143
|
+
if (a !== b) return a < b ? -1 : 1;
|
|
144
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function toEntries(input) {
|
|
148
|
+
if (input instanceof Uint8Array) return unzipSync(input);
|
|
149
|
+
if (input instanceof ArrayBuffer) return unzipSync(new Uint8Array(input));
|
|
150
|
+
if (input && typeof input === 'object') return input;
|
|
151
|
+
throw new TypeError('Expected PPTX bytes or a map of package parts.');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readTypefaces(xml, part, isTheme, output) {
|
|
155
|
+
const major = sectionRange(xml, 'majorFont'), minor = sectionRange(xml, 'minorFont');
|
|
156
|
+
for (const match of xml.matchAll(/<(?:[\w.-]+:)?([\w.-]+)\b([^<>]*?\btypeface="([^"]*)"[^<>]*)>/g)) {
|
|
157
|
+
const [, element, attributes, raw] = match;
|
|
158
|
+
const entry = {part, kind: 'drawingml', element, typeface: unescapeXml(raw)};
|
|
159
|
+
const pitch = /\bpitchFamily="([^"]*)"/.exec(attributes)?.[1];
|
|
160
|
+
if (pitch !== undefined) entry.pitchFamily = Number(pitch);
|
|
161
|
+
const script = /\bscript="([^"]*)"/.exec(attributes)?.[1];
|
|
162
|
+
if (script !== undefined) entry.script = script;
|
|
163
|
+
if (isTheme) {
|
|
164
|
+
const at = match.index;
|
|
165
|
+
if (at > major[0] && at < major[1]) entry.theme = 'major';
|
|
166
|
+
else if (at > minor[0] && at < minor[1]) entry.theme = 'minor';
|
|
167
|
+
}
|
|
168
|
+
output.push(entry);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function readSpreadsheetFonts(xml, part, output) {
|
|
173
|
+
for (const match of xml.matchAll(/<(?:[\w.-]+:)?(name|rFont)\b[^<>]*?\bval="([^"]*)"[^<>]*>/g)) {
|
|
174
|
+
output.push({part, kind: 'spreadsheetml', element: match[1], typeface: unescapeXml(match[2])});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function themeFonts(typefaces, part) {
|
|
179
|
+
const fonts = {major: {}, minor: {}};
|
|
180
|
+
for (const entry of typefaces) {
|
|
181
|
+
if (entry.part !== part || !entry.theme || !['latin', 'ea', 'cs'].includes(entry.element)) continue;
|
|
182
|
+
fonts[entry.theme][entry.element] ??= entry.typeface;
|
|
183
|
+
}
|
|
184
|
+
return fonts;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function readFontsUsed(xml) {
|
|
188
|
+
const pairs = headingPairs(xml);
|
|
189
|
+
const titles = titlesOfParts(xml);
|
|
190
|
+
if (!pairs || !titles) return null;
|
|
191
|
+
let offset = 0;
|
|
192
|
+
for (const [name, count] of pairs) {
|
|
193
|
+
if (name === 'Fonts Used') return titles.slice(offset, offset + count);
|
|
194
|
+
offset += count;
|
|
195
|
+
}
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function headingPairs(xml) {
|
|
200
|
+
const block = /<HeadingPairs>([\s\S]*?)<\/HeadingPairs>/.exec(xml)?.[1];
|
|
201
|
+
if (block === undefined) return null;
|
|
202
|
+
const values = [...block.matchAll(/<vt:variant>\s*<vt:(lpstr|i4)>([^<]*)<\/vt:\1>\s*<\/vt:variant>/g)].map(match => match[1] === 'i4' ? Number(match[2]) : unescapeXml(match[2]));
|
|
203
|
+
const pairs = [];
|
|
204
|
+
for (let index = 0; index + 1 < values.length; index += 2) pairs.push([values[index], values[index + 1]]);
|
|
205
|
+
return pairs;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function titlesOfParts(xml) {
|
|
209
|
+
const block = /<TitlesOfParts>([\s\S]*?)<\/TitlesOfParts>/.exec(xml)?.[1];
|
|
210
|
+
if (block === undefined) return null;
|
|
211
|
+
return [...block.matchAll(/<vt:lpstr>([^<]*)<\/vt:lpstr>/g)].map(match => unescapeXml(match[1]));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function sectionRange(xml, name) {
|
|
215
|
+
const start = xml.indexOf(`<a:${name}>`);
|
|
216
|
+
if (start < 0) return [-1, -1];
|
|
217
|
+
const end = xml.indexOf(`</a:${name}>`, start);
|
|
218
|
+
return [start, end < 0 ? xml.length : end];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function unescapeXml(value) {
|
|
222
|
+
return value.replace(/&(?:amp|lt|gt|quot|apos|#(\d+)|#x([0-9a-fA-F]+));/g, (entity, decimal, hex) => {
|
|
223
|
+
if (decimal) return String.fromCodePoint(Number(decimal));
|
|
224
|
+
if (hex) return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
225
|
+
return {'&': '&', '<': '<', '>': '>', '"': '"', ''': "'"}[entity];
|
|
226
|
+
});
|
|
227
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openpresentation/opf-pptx",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Pure local OPF to PPTX export and PPTX to OPF import tooling.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -37,8 +37,8 @@
|
|
|
37
37
|
"sideEffects": false,
|
|
38
38
|
"scripts": {
|
|
39
39
|
"build": "node scripts/build.mjs",
|
|
40
|
-
"typecheck": "node --check src/index.js && node --check src/color-ref.js && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check test/furniture-provenance.mjs",
|
|
41
|
-
"test": "npm run build && npm run test:furniture && node test/color-ref-export.mjs && node test/plain-whitespace.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/shared-quote.mjs && npm run test:timeline",
|
|
40
|
+
"typecheck": "node --check src/index.js && node --check test/design-alignment.mjs && node --check src/color-ref.js && node --check src/theme-colors.js && node --check test/theme-colors.mjs && node --check scripts/build.mjs && node --check scripts/validate-package.mjs && node --check test/smoke.mjs && node --check src/image-geometry.js && node --check src/slide-image-provenance.js && node --check test/slide-image.mjs && node --check test/slide-image-treatments.mjs && node --check test/image-fit.mjs && node --check test/image-orientation.mjs && node --check test/table-layout.mjs && node --check test/table-import.mjs && node --check test/object-ids.mjs && node --check test/export-corpus.mjs && node --check test/image-media-type.mjs && node --check src/image-fallback-node.js && node --check src/image-fallback-browser.js && node --check test/webp-fallback.mjs && node --check scripts/build-browser-check.mjs && node --check test/webp-fallback-browser.js && node --check test/image-fallback-boundary.mjs && node --check src/background.js && node --check test/background.mjs && node --check src/image-import.js && node --check test/image-import.mjs && node --check test/native-background.mjs && node --check src/background-import.js && node --check test/background-inheritance.mjs && node --check test/rich-table.mjs && node --check src/table-import.js && node --check test/rich-table-import.mjs && node --check test/rich-table-import-browser.js && node --check test/table-row-sizing.mjs && node --check test/table-styles.mjs && node --check test/table-styles-browser.js && node --check test/styled-table.mjs && node --check src/table-cell-import.js && node --check test/styled-table-import.mjs && node --check test/styled-table-import-browser.js && node --check src/table-border-import.js && node --check test/table-border-styles.mjs && node --check src/card-provenance.js && node --check test/content-cards.mjs && node --check src/chart-workbook.js && node --check test/chart-colors.mjs && node --check test/chart-workbook.mjs && node --check test/single-column-chart.mjs && node --check test/native-chart-colors.mjs && node --check src/heading-provenance.js && node --check test/accepted-text.mjs && node --check test/heading-provenance.mjs && node --check src/text-provenance.js && node --check test/plain-whitespace.mjs && node --check src/timeline-provenance.js && node --check test/shared-timeline.mjs && node --check test/shared-furniture.mjs && node --check src/furniture-provenance.js && node --check src/document-provenance.js && node --check test/document-provenance.mjs && node --check test/furniture-provenance.mjs && node --check src/furniture-fields.js && node --check test/furniture-fields.mjs && node --check test/furniture-wrapped-date.mjs && node --check test/native-picture.mjs && node --check test/compare-native-picture.mjs && node --check test/compare-native-picture-edit.mjs && node --check test/compare-native-picture-edit-controls.mjs && node --check test/native-font-embed-audit.mjs && node --check test/native-font-embed-controls.mjs && node --check test/native-font-embed-fixture-source.mjs && node --check test/native-font-edit-fixture.mjs && node --check test/native-mixed-edit-audit.mjs && node --check test/native-mixed-edit-controls.mjs && node --check test/master-bullet-font.mjs && node --check test/native-font-inventory-audit.mjs && node --check test/native-font-inventory-controls.mjs && node --check test/default-font-scheme.mjs && node --check test/export-chosen-fonts.mjs && node --check test/background-fills.mjs && node --check src/typeface-inventory.js && node --check src/package-fonts.js && node --check test/typeface-inventory.mjs && node --check src/script-fonts.js && node --check test/script-fonts.mjs && node --check test/powershell-scan.mjs && node --check test/powershell-scan-probes.mjs && node --check test/socials-furniture.mjs && node --check src/media-provenance.js && node --check test/layout-parity.mjs && node --check test/cover-centering.mjs && node --check test/media-placeholder.mjs && node --check test/image-placeholder.mjs && node --check test/list-shape-names.mjs && node --check test/layout-intent.mjs && node --check test/chart-cache-import.mjs && node --check test/zip-date.mjs && node --check test/native-text-whitespace.mjs && node --check test/native-underline.mjs && node --check src/native-text-style.js && node --check src/font-weights.js && node --check test/font-weight-import.mjs && node --check src/body-text-import.js && node --check test/native-body-rich-text.mjs && node --check test/native-body-rich-text-browser.js && node --check test/native-quote-import-contract.mjs && node --check test/native-quote-import-controls.mjs && node --check test/native-quote.mjs && node --check test/determinism.mjs && node --check test/determinism-fixtures.mjs && node --check test/table-frame-furniture-order.mjs && node --check test/shape-border-widths.mjs",
|
|
41
|
+
"test": "npm run build && node test/zip-date.mjs && node test/background-fills.mjs && npm run test:furniture && node test/slide-image.mjs && node test/slide-image-treatments.mjs && node test/document-provenance.mjs && node test/layout-intent.mjs && node test/master-bullet-font.mjs && node test/default-font-scheme.mjs && node test/script-fonts.mjs && node test/export-chosen-fonts.mjs && node test/typeface-inventory.mjs && node test/color-ref-export.mjs && node test/theme-colors.mjs && node test/plain-whitespace.mjs && node test/native-text-whitespace.mjs && node test/native-body-rich-text.mjs && node test/native-underline.mjs && node test/font-weight-import.mjs && node test/design-alignment.mjs && node test/accepted-text.mjs && node test/heading-provenance.mjs && node test/native-quote-import-controls.mjs && node test/chart-colors.mjs && node test/content-cards.mjs && node test/chart-workbook.mjs && node test/single-column-chart.mjs && node --test test/chart-cache-import.mjs && node test/dependency-boundary.mjs && npm run build:browser-check && npm run test:styled-table && node test/content-layout.mjs && node test/layout-parity.mjs && node test/cover-centering.mjs && node test/table-frame-furniture-order.mjs && node test/media-placeholder.mjs && node test/shape-border-widths.mjs && node test/image-placeholder.mjs && node test/list-shape-names.mjs && node test/shared-quote.mjs && npm run test:timeline && npm run test:determinism",
|
|
42
42
|
"validate": "node scripts/validate-package.mjs",
|
|
43
43
|
"test:packed": "node test/packed-install.mjs",
|
|
44
44
|
"test:compare-published": "node test/compare-published.mjs",
|
|
@@ -50,17 +50,24 @@
|
|
|
50
50
|
"test:metric": "node test/shared-metric.mjs && node test/metric-provenance.mjs",
|
|
51
51
|
"test:font-variants": "node test/font-variants.mjs",
|
|
52
52
|
"test:timeline": "node test/shared-timeline.mjs",
|
|
53
|
-
"test:furniture": "node test/shared-furniture.mjs && node test/furniture-provenance.mjs"
|
|
53
|
+
"test:furniture": "node test/shared-furniture.mjs && node test/furniture-provenance.mjs && node test/furniture-fields.mjs && node test/furniture-wrapped-date.mjs && node test/socials-furniture.mjs",
|
|
54
|
+
"test:native-picture-edit-controls": "node test/compare-native-picture-edit-controls.mjs",
|
|
55
|
+
"test:native-font-embed-controls": "node test/native-font-embed-controls.mjs",
|
|
56
|
+
"test:native-mixed-edit-controls": "node test/native-mixed-edit-controls.mjs",
|
|
57
|
+
"test:native-font-inventory-controls": "node test/native-font-inventory-controls.mjs",
|
|
58
|
+
"test:zip-date": "node test/zip-date.mjs",
|
|
59
|
+
"test:native-text": "node test/native-text-whitespace.mjs",
|
|
60
|
+
"test:determinism": "node test/determinism.mjs"
|
|
54
61
|
},
|
|
55
62
|
"dependencies": {
|
|
56
|
-
"@openpresentation/opf": "^0.11.
|
|
63
|
+
"@openpresentation/opf": "^0.11.2",
|
|
57
64
|
"fast-xml-parser": "^5.8.0",
|
|
58
65
|
"fflate": "^0.8.3",
|
|
59
66
|
"jszip": "3.10.2",
|
|
60
67
|
"sharp": "0.35.4"
|
|
61
68
|
},
|
|
62
69
|
"peerDependencies": {
|
|
63
|
-
"@openpresentation/opf-render": "^0.
|
|
70
|
+
"@openpresentation/opf-render": "^0.11.0"
|
|
64
71
|
},
|
|
65
72
|
"peerDependenciesMeta": {
|
|
66
73
|
"@openpresentation/opf-render": {
|
|
@@ -68,7 +75,8 @@
|
|
|
68
75
|
}
|
|
69
76
|
},
|
|
70
77
|
"devDependencies": {
|
|
71
|
-
"@
|
|
78
|
+
"@expo-google-fonts/carlito": "0.4.1",
|
|
79
|
+
"@openpresentation/opf-render": "0.11.0",
|
|
72
80
|
"esbuild": "0.28.2",
|
|
73
81
|
"playwright": "1.63.0"
|
|
74
82
|
},
|