@openpresentation/opf-pptx 0.9.1 → 0.10.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.
@@ -0,0 +1,290 @@
1
+ // Language tags, right-to-left paragraphs and East Asian/complex-script fonts
2
+ // (font-fidelity-everywhere FF-07). Core's resolveScriptFonts() owns the model;
3
+ // this module only writes its result into the package PptxGenJS generated, and
4
+ // reads it back on import. Design: core docs/programs/font-fidelity-everywhere/
5
+ // script-font-model.md ("OOXML mapping").
6
+ //
7
+ // Core releases before the resolver (0.11.0 and earlier) export no
8
+ // resolveScriptFonts. The exporter then writes exactly what it wrote before
9
+ // FF-07 (lang="en-US", empty theme ea/cs, no rtl) and reports
10
+ // `language-export-unavailable` for a document that names a language. A core
11
+ // with the resolver but without paragraphDirection() marks no paragraph
12
+ // direction and reports `paragraph-direction-unavailable` for an RTL deck.
13
+ import * as opfCore from "@openpresentation/opf";
14
+
15
+ const resolver = typeof opfCore.resolveScriptFonts === "function" ? opfCore.resolveScriptFonts : null;
16
+ // The one paragraph-direction rule shared with the renderer (core FF-07).
17
+ const paragraphDirection = typeof opfCore.paragraphDirection === "function" ? opfCore.paragraphDirection : null;
18
+
19
+ const SCRIPT_SLOTS = [["ea", "eastAsian"], ["cs", "complexScript"]];
20
+
21
+ /** Whether the installed core exports the FF-18 language/script resolver. */
22
+ export function scriptFontsAvailable() {
23
+ return resolver !== null;
24
+ }
25
+
26
+ /**
27
+ * Resolve the deck (slide 0, which also sets the theme) and every slide.
28
+ * Returns null when core has no resolver.
29
+ */
30
+ export function planScriptFonts(presentation, report) {
31
+ if (!resolver) {
32
+ if (presentation.language !== undefined) report?.({code: "language-export-unavailable", path: "language",
33
+ message: "The installed @openpresentation/opf has no resolveScriptFonts, so the PPTX keeps lang=\"en-US\", empty theme East Asian/complex-script fonts and left-to-right paragraphs. Use a core release with the FF-18 language model."});
34
+ return null;
35
+ }
36
+ const count = Array.isArray(presentation.slides) ? presentation.slides.length : 0;
37
+ let slides, deck;
38
+ try {
39
+ slides = Array.from({length: count}, (_, slideIndex) => resolver(presentation, {slideIndex}));
40
+ deck = slides[0] ?? resolver(presentation);
41
+ } catch (error) {
42
+ // Keep exporting as before FF-07 rather than failing on the language model.
43
+ report?.({code: "language-export-unavailable", path: "language",
44
+ message: `Script fonts could not be resolved (${error instanceof Error ? error.message : String(error)}), so the PPTX keeps lang="en-US", empty theme East Asian/complex-script fonts and left-to-right paragraphs.`});
45
+ return null;
46
+ }
47
+ if (presentation.language !== undefined && deck.languageSource === "default") {
48
+ report?.({code: "language-unresolved", path: "language",
49
+ message: `The presentation language could not be resolved locally (a URL, pkg: reference or unknown id), so the PPTX uses ${deck.lang}.`});
50
+ }
51
+ let rtl = deck.rtl;
52
+ if (rtl && !paragraphDirection) {
53
+ rtl = false;
54
+ report?.({code: "paragraph-direction-unavailable", path: "language",
55
+ 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
+ }
57
+ return {deck, slides, lang: deck.lang, rtl};
58
+ }
59
+
60
+ const escapeAttribute = value => String(value).replace(/[&<>"']/g, char => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&apos;"}[char]));
61
+
62
+ /**
63
+ * Theme major/minor ea/cs from the resolved heading/body slots, and the
64
+ * language's own per-script supplement. Only the supplement's script entry
65
+ * changes; the rest of the vendored per-script list is FF-08's call.
66
+ *
67
+ * For a language written in the latin slot (Latin, Cyrillic, Greek and others) the
68
+ * vendored empty ea/cs stay empty unless the design font scheme sets that
69
+ * slot explicitly. Filling them with the latin family is gated on FF-05 (core
70
+ * script-font-model.md): it did not remove PowerPoint's nameless/Aptos font
71
+ * entries, and empty slots keep PowerPoint's per-script theme fallback for
72
+ * East Asian or complex-script text typed later. Other languages fill both.
73
+ */
74
+ export function themeScriptFonts(xml, plan) {
75
+ const {heading, body, supplement} = plan.deck;
76
+ for (const [tag, slots, family] of [["majorFont", heading, supplement?.heading], ["minorFont", body, supplement?.body]]) {
77
+ xml = xml.replace(new RegExp(`<a:${tag}>[\\s\\S]*?</a:${tag}>`), block => {
78
+ // With no script-specific choice the slot repeats the theme's own latin
79
+ // face exactly as written, as the run slots do.
80
+ const latin = /<a:latin typeface="([^"]*)"/.exec(block)?.[1];
81
+ for (const [element, slot] of SCRIPT_SLOTS) {
82
+ if (plan.deck.scriptRole === "latin" && plan.deck.sources[slot] !== "fontScheme") continue;
83
+ const face = plan.deck.sources[slot] === "latin" && latin ? latin : escapeAttribute(slots[slot]);
84
+ block = block.replace(new RegExp(`<a:${element}\\b[^>]*/>`), `<a:${element} typeface="${face}"/>`);
85
+ }
86
+ if (supplement && family) {
87
+ const entry = `<a:font script="${supplement.script}" typeface="${escapeAttribute(family)}"/>`;
88
+ const existing = new RegExp(`<a:font script="${supplement.script}" typeface="[^"]*"/>`);
89
+ block = existing.test(block) ? block.replace(existing, entry) : block.replace(`</a:${tag}>`, `${entry}</a:${tag}>`);
90
+ }
91
+ return block;
92
+ });
93
+ }
94
+ return xml;
95
+ }
96
+
97
+ // PptxGenJS writes run fonts as latin/ea/cs triples naming one face (charts
98
+ // may omit ea). Theme references (+mj-lt, +mn-ea) are left alone.
99
+ const RUN_FONTS = /(<a:latin typeface="([^"]*)"[^>]*\/>)(\s*)(<a:ea typeface="([^"]*)"[^>]*\/>)?(\s*)(<a:cs\s+typeface="([^"]*)"[^>]*\/>)?/g;
100
+
101
+ /**
102
+ * Explicit run ea/cs from the resolved slots. A slot whose source is `latin`
103
+ * (no script-specific font was chosen) keeps repeating the run's own face, as
104
+ * before FF-07, so Latin decks keep their run bytes. Otherwise the run's role
105
+ * picks the heading or body slot: its face when that names exactly one role,
106
+ * else the shape (native OPF headings are heading, everything else body).
107
+ */
108
+ function runScriptFonts(xml, resolved, headingShape) {
109
+ return xml.replace(RUN_FONTS, (match, latinElement, latin, gap1, eaElement = "", ea, gap2, csElement = "", cs) => {
110
+ if (latin.startsWith("+")) return match;
111
+ const isHeading = escapeAttribute(resolved.heading.latin) === latin, isBody = escapeAttribute(resolved.body.latin) === latin;
112
+ const slots = isHeading !== isBody ? (isHeading ? resolved.heading : resolved.body) : headingShape ? resolved.heading : resolved.body;
113
+ // Only a slot that repeats the run's face and has a script-specific choice
114
+ // changes; it then drops PptxGenJS's pitch/charset hints for that face.
115
+ const slot = (element, key, face, original) => {
116
+ const target = escapeAttribute(slots[key]);
117
+ return face === latin && resolved.sources[key] !== "latin" && target !== face ? `<a:${element} typeface="${target}"/>` : original;
118
+ };
119
+ return `${latinElement}${gap1}${slot("ea", "eastAsian", ea, eaElement)}${gap2}${slot("cs", "complexScript", cs, csElement)}`;
120
+ });
121
+ }
122
+
123
+ const RUN_LANGUAGE = /(<a:(?:rPr|endParaRPr|defRPr)\b[^>]*?\slang=")en-US(")/g;
124
+
125
+ const decodeText = value => value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|(amp|lt|gt|quot|apos));/gi, (entity, decimal, hex, name) =>
126
+ decimal ? String.fromCodePoint(Number(decimal)) : hex ? String.fromCodePoint(parseInt(hex, 16)) : {amp: "&", lt: "<", gt: ">", quot: "\"", apos: "'"}[name.toLowerCase()]);
127
+
128
+ /**
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.
133
+ */
134
+ function paragraphRtl(xml, deckDirection) {
135
+ return xml.replace(/<a:p>([\s\S]*?)<\/a:p>/g, (paragraph, body) => {
136
+ 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
+ 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}"`;
141
+ return `<a:p>${properties[1]}<a:pPr${attributes}${properties[3]}>${body.slice(properties[0].length)}</a:p>`;
142
+ });
143
+ }
144
+
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");
147
+
148
+ /**
149
+ * Apply the plan to one generated XML part. `slideIndex` names the slide a
150
+ * slide, chart or notes part belongs to.
151
+ */
152
+ export function partScriptFonts(path, xml, plan, slideIndex) {
153
+ if (/^ppt\/theme\/theme\d+\.xml$/.test(path)) return themeScriptFonts(xml, plan);
154
+ if (!path.startsWith("ppt/") || !path.endsWith(".xml")) return xml;
155
+ const resolved = plan.slides[slideIndex] ?? plan.deck;
156
+ if (plan.lang !== "en-US") xml = xml.replace(RUN_LANGUAGE, `$1${escapeAttribute(plan.lang)}$2`);
157
+ if (/^ppt\/slides\/slide\d+\.xml$/.test(path)) {
158
+ xml = xml.replace(/<p:(sp|graphicFrame)>[\s\S]*?<\/p:\1>/g, shape =>
159
+ runScriptFonts(shape, resolved, /<p:cNvPr\b[^>]*\bname="OPF heading /.test(shape)));
160
+ if (plan.rtl) xml = paragraphRtl(xml, plan.deck.direction);
161
+ } else if (/^ppt\/(?:charts\/chart|notesSlides\/notesSlide)\d+\.xml$/.test(path)) {
162
+ xml = runScriptFonts(xml, resolved, false);
163
+ if (plan.rtl && path.startsWith("ppt/notesSlides/")) xml = paragraphRtl(xml, plan.deck.direction);
164
+ } else if (plan.rtl && /^ppt\/(?:slideMasters\/slideMaster\d+|slideLayouts\/slideLayout\d+|notesMasters\/notesMaster\d+|presentation)\.xml$/.test(path)) {
165
+ xml = levelRtl(xml);
166
+ }
167
+ return xml;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Import
172
+
173
+ const LANGUAGE_TAG = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/;
174
+ const attribute = (xml, name) => new RegExp(`\\s${name}="([^"]*)"`).exec(xml)?.[1];
175
+
176
+ /**
177
+ * Match an OOXML run language to the OPF language to import. A catalog id is
178
+ * used when that record exports exactly this tag again: an exact BCP-47 tag,
179
+ * then a curated `ooxmlLang` (preferring the same primary language). A tag
180
+ * that core still resolves to a catalog record (for example en-NZ to English)
181
+ * is imported as the tag itself, so it round-trips. Without core's resolver,
182
+ * a record whose tag is the primary language alone is accepted too.
183
+ * Returns {language, record, shared} or null when nothing matches.
184
+ */
185
+ export function matchCatalogLanguage(lang, catalogs) {
186
+ const records = Array.isArray(catalogs?.languages) ? catalogs.languages : [];
187
+ const key = lang.toLowerCase(), primary = key.split("-")[0];
188
+ const lower = value => typeof value === "string" ? value.toLowerCase() : undefined;
189
+ const shared = records.filter(record => lower(record.bcp47) === key || lower(record.ooxmlLang) === key);
190
+ const exports = record => !resolver || resolver({language: record.id}).lang.toLowerCase() === key;
191
+ const record = shared.find(record => lower(record.bcp47) === key && exports(record))
192
+ ?? shared.find(record => lower(record.bcp47)?.split("-")[0] === primary && exports(record))
193
+ ?? shared.find(exports);
194
+ if (record) return {language: record.id, record, shared};
195
+ if (resolver) {
196
+ const resolved = resolver({language: lang});
197
+ // A tag core cannot resolve falls back to its default language; that is no match.
198
+ const matched = resolved.languageSource !== "default" && resolved.languageId && records.find(candidate => candidate.id === resolved.languageId);
199
+ return matched ? {language: lang, record: matched, shared: []} : null;
200
+ }
201
+ const byPrimary = records.find(candidate => lower(candidate.bcp47) === primary);
202
+ return byPrimary ? {language: byPrimary.id, record: byPrimary, shared: []} : null;
203
+ }
204
+
205
+ /**
206
+ * Observe the presentation language in run `lang`. `slides` and `theme` are
207
+ * XML strings. `language` is a catalog id, the run's tag when no catalog
208
+ * record matches, or undefined when the runs carry no language; `lang` is the
209
+ * dominant run tag itself. Nothing is reported here: languageDiagnostics()
210
+ * reports against the final imported document, after FF-32 provenance.
211
+ */
212
+ export function observeLanguage({slides, theme, catalogs}) {
213
+ const counts = new Map();
214
+ let rtlParagraphs = 0;
215
+ for (const xml of slides) {
216
+ for (const [, lang] of xml.matchAll(/<a:rPr\b[^>]*?\slang="([^"]+)"/g)) {
217
+ // Values that name no language are not counted: malformed tags, x-none, und and zxx.
218
+ if (!LANGUAGE_TAG.test(lang) || /^(?:und|zxx)(?:-|$)/i.test(lang)) continue;
219
+ counts.set(lang, (counts.get(lang) ?? 0) + 1);
220
+ }
221
+ rtlParagraphs += xml.match(/<a:pPr\b[^>]*?\srtl="1"/g)?.length ?? 0;
222
+ }
223
+ const ranked = [...counts].sort((left, right) => right[1] - left[1] || (left[0] < right[0] ? -1 : left[0] > right[0] ? 1 : 0));
224
+ const lang = ranked[0]?.[0];
225
+ const match = lang === undefined ? null : matchCatalogLanguage(lang, catalogs);
226
+ return {lang, language: lang === undefined ? undefined : match?.language ?? lang, match, ranked, rtlParagraphs, theme};
227
+ }
228
+
229
+ /**
230
+ * Reconcile the observed language with a stored FF-32 `language` reference.
231
+ * The stored reference wins while the runs still carry its OOXML tag (or no
232
+ * tag, or it cannot be resolved locally); otherwise the observed language
233
+ * stays and `metadata-reference-changed` names the reference. Returns the
234
+ * provenance groups to apply.
235
+ */
236
+ export function reconcileLanguage(groups, observed, report) {
237
+ const index = groups.findIndex(item => item.field === "language");
238
+ if (index < 0 || observed.lang === undefined || !resolver) return groups;
239
+ const stored = groups[index].ops.find(op => op.path?.length === 1 && op.path[0] === "language")?.value;
240
+ if (stored === undefined) return groups;
241
+ const resolved = resolver({language: stored});
242
+ if (resolved.languageSource === "default" || resolved.lang.toLowerCase() === observed.lang.toLowerCase()) return groups;
243
+ report?.({code: "metadata-reference-changed", path: "language",
244
+ message: `Runs now use ${observed.lang}, not ${resolved.lang} of the stored language, so the stored language was not restored; the imported language follows the runs.`});
245
+ return groups.filter((_, position) => position !== index);
246
+ }
247
+
248
+ /**
249
+ * Diagnostics for the final imported document: several run languages, a run
250
+ * tag that maps ambiguously or to no catalog record (only when that observed
251
+ * language was kept), right-to-left paragraphs under a left-to-right
252
+ * language, and theme East Asian/complex-script fonts the document does not
253
+ * reproduce.
254
+ */
255
+ export function languageDiagnostics(imported, observed, report) {
256
+ if (!report) return;
257
+ const {lang, match, ranked, rtlParagraphs, theme} = observed;
258
+ if (lang === undefined) {
259
+ 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
+ return;
261
+ }
262
+ if (ranked.length > 1) {
263
+ 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.`});
265
+ }
266
+ const kept = imported.language === observed.language;
267
+ 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.`});
268
+ else if (kept && match.shared.length > 1 && !match.shared.some(record => record.bcp47?.toLowerCase() === lang.toLowerCase())) {
269
+ report({code: "language-ambiguous", path: "language",
270
+ message: `Run language ${lang} is the OOXML tag of ${match.shared.map(record => record.id).join(", ")}; ${match.language} was imported.`});
271
+ }
272
+ const resolved = resolver ? resolver(imported) : null;
273
+ const rtl = resolved ? resolved.rtl : match?.record.direction === "rtl";
274
+ if (rtlParagraphs && !rtl) {
275
+ report({code: "rtl-language-mismatch", path: "language", message: `${rtlParagraphs} right-to-left paragraph(s) do not match the left-to-right language ${typeof imported.language === "string" ? imported.language : lang}; paragraph direction is not imported separately.`});
276
+ }
277
+ if (theme && resolved) {
278
+ for (const [tag, role] of [["majorFont", "heading"], ["minorFont", "body"]]) {
279
+ const block = new RegExp(`<a:${tag}>[\\s\\S]*?</a:${tag}>`).exec(theme)?.[0];
280
+ if (!block) continue;
281
+ const latin = attribute(/<a:latin\b[^>]*\/>/.exec(block)?.[0] ?? "", "typeface");
282
+ for (const [element, slot] of SCRIPT_SLOTS) {
283
+ const face = attribute(new RegExp(`<a:${element}\\b[^>]*/>`).exec(block)?.[0] ?? "", "typeface");
284
+ if (!face || face.startsWith("+") || face === latin || face === escapeAttribute(resolved[role][slot])) continue;
285
+ report({code: "script-font-not-imported", path: "design.fontScheme",
286
+ message: `Theme ${tag} ${slot} font "${face}" differs from both its latin font and what the imported document resolves, and is not represented in the imported OPF.`});
287
+ }
288
+ }
289
+ }
290
+ }
@@ -0,0 +1,169 @@
1
+ import {XMLParser} from 'fast-xml-parser';
2
+ import {attachTextTags, decodeTextTag} from './code-provenance.js';
3
+ import {framedPictureTransform} from './image-geometry.js';
4
+
5
+ // A slide-level image (design.slideImage) exports as one native picture. Its
6
+ // tag records the OPF placement and the exact native geometry, so an unchanged
7
+ // picture imports back as design.slideImage while an edited one stays ordinary.
8
+ const TAG = 'OPF_SLIDE_IMAGE_V1', OVERLAY_TAG = 'OPF_SLIDE_IMAGE_OVERLAY_V1', REL = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags';
9
+ const EMUS_PER_INCH = 914400;
10
+ const parser = new XMLParser({ignoreAttributes:false, attributeNamePrefix:'', parseTagValue:false, trimValues:false});
11
+ const decoder = new TextDecoder('utf-8', {fatal:true}), encoder = new TextEncoder();
12
+ const array = value => value === undefined ? [] : Array.isArray(value) ? value : [value];
13
+ const canonical = value => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
14
+ ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
15
+ const POSITIONS = ['background', 'top', 'bottom', 'left', 'right'];
16
+
17
+ export const slideImageName = slidePath => `OPF slide image ${slidePath}`;
18
+ export const slideImageOverlayName = slidePath => `OPF slide image overlay ${slidePath}`;
19
+
20
+ // DrawingML for the shared treatment: preset geometry (core guide values), a
21
+ // centered solid line, and blip recolor/alpha effects applied in that order.
22
+ const presetXml = shape => `<a:prstGeom prst="${shape?.preset ?? 'rect'}"><a:avLst>${Object.entries(shape?.adjust ?? {}).map(([name, value]) => `<a:gd name="${name}" fmla="val ${value}"/>`).join('')}</a:avLst></a:prstGeom>`;
23
+ const colorXml = ({hex, alpha}, extraAlpha = 1) => {
24
+ const value = Math.round(alpha * extraAlpha * 100000);
25
+ return `<a:srgbClr val="${hex}">${value < 100000 ? `<a:alpha val="${value}"/>` : ''}</a:srgbClr>`;
26
+ };
27
+ const lineXml = border => border ? `<a:ln w="${Math.round(border.width / 96 * EMUS_PER_INCH)}" algn="ctr"><a:solidFill>${colorXml(border)}</a:solidFill><a:miter lim="800000"/></a:ln>` : '';
28
+ function blipEffects(effects) {
29
+ let xml = '';
30
+ if (effects?.recolor?.type === 'grayscale') xml += '<a:grayscl/>';
31
+ if (effects?.recolor?.type === 'duotone') xml += `<a:duotone>${colorXml({...effects.recolor.dark, alpha: 1})}${colorXml({...effects.recolor.light, alpha: 1})}</a:duotone>`;
32
+ if (typeof effects?.opacity === 'number') xml += `<a:alphaModFix amt="${Math.round(effects.opacity * 100000)}"/>`;
33
+ return xml;
34
+ }
35
+
36
+ // Native blip fill without its package-local relationship id.
37
+ function blipFillIdentity(blipFill) {
38
+ const {['a:blip']: blip, ...rest} = blipFill ?? {};
39
+ const {['r:embed']: _embed, ...blipRest} = blip ?? {};
40
+ return {...rest, 'a:blip': blipRest};
41
+ }
42
+
43
+ /**
44
+ * Place each generated slide-image picture at its frame (crop/fit through
45
+ * a:srcRect), then bind the picture to its manifest with a native tag.
46
+ * images: Map<objectName, {slide, box (inches), fill, treatment, path}>.
47
+ */
48
+ export function placeSlideImages(entries, images, metadataFor, fail) {
49
+ if (!images.size) return;
50
+ const manifests = new Map(), overlays = new Map();
51
+ for (const part of Object.keys(entries).filter(path => /^ppt\/slides\/slide\d+\.xml$/.test(path))) {
52
+ const xml = decoder.decode(entries[part]);
53
+ const next = xml.replace(/<p:pic>[\s\S]*?<\/p:pic>/g, picture => {
54
+ const name = picture.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
55
+ const image = images.get(name);
56
+ if (!image) return picture;
57
+ const embed = picture.match(/<a:blip\b[^>]*r:embed="([^"]+)"/)?.[1];
58
+ const metadata = metadataFor(part, embed);
59
+ if (!metadata) fail(image.path);
60
+ const placed = framedPictureTransform(metadata, image.box, image.fill);
61
+ const emu = value => Math.round(value * EMUS_PER_INCH);
62
+ const attributes = `${placed.rotation ? ` rot="${placed.rotation * 60000}"` : ''}${placed.flipH ? ' flipH="1"' : ''}${placed.flipV ? ' flipV="1"' : ''}`;
63
+ 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>`);
64
+ picture = picture.replace(/<a:srcRect\b[^>]*\/>/, '');
65
+ if (placed.crop) picture = picture.replace('<a:stretch>', `<a:srcRect l="${placed.crop.l}" t="${placed.crop.t}" r="${placed.crop.r}" b="${placed.crop.b}"/><a:stretch>`);
66
+ const effects = image.effects;
67
+ if (effects) {
68
+ picture = picture.replace(/<a:prstGeom\b[\s\S]*?<\/a:prstGeom>/, presetXml(effects.shape) + lineXml(effects.border));
69
+ const blip = blipEffects(effects);
70
+ if (blip) picture = picture.replace(/<a:blip\b([^>]*?)(?:\/>|>([\s\S]*?)<\/a:blip>)/, (_, attributes, inner = '') => `<a:blip${attributes}>${blip}${inner}</a:blip>`);
71
+ }
72
+ const node = parser.parse(picture)['p:pic'];
73
+ manifests.set(name, {v:1, slide:image.slide, position:image.treatment.position, fill:image.fill, treatment:image.treatment,
74
+ properties:node['p:spPr'], blipFill:blipFillIdentity(node['p:blipFill'])});
75
+ return picture;
76
+ });
77
+ const shaped = next.replace(/<p:sp>[\s\S]*?<\/p:sp>/g, shape => {
78
+ const name = shape.match(/<p:cNvPr\b[^>]*\bname="([^"]+)"/)?.[1];
79
+ const image = [...images.values()].find(candidate => slideImageOverlayName(candidate.slide) === name);
80
+ const overlay = image?.effects?.overlay;
81
+ if (!overlay) return shape;
82
+ const emu = value => Math.round(value / 96 * EMUS_PER_INCH);
83
+ shape = shape.replace(/<p:spPr>[\s\S]*?<\/p:spPr>/, `<p:spPr><a:xfrm><a:off x="${emu(overlay.box.x)}" y="${emu(overlay.box.y)}"/><a:ext cx="${emu(overlay.box.width)}" cy="${emu(overlay.box.height)}"/></a:xfrm>${presetXml(overlay.shape)}<a:solidFill>${colorXml(overlay, overlay.opacity)}</a:solidFill><a:ln><a:noFill/></a:ln></p:spPr>`);
84
+ const node = parser.parse(shape)['p:sp'];
85
+ overlays.set(name, {v:1, slide:image.slide, properties:node['p:spPr']});
86
+ return shape;
87
+ });
88
+ if (shaped !== xml) entries[part] = encoder.encode(shaped);
89
+ }
90
+ if (manifests.size !== images.size) throw new Error('Missing generated slide image picture.');
91
+ attachTextTags(entries, manifests, TAG, 'opfSlideImage', 'slide image', {pictures:true});
92
+ attachTextTags(entries, overlays, OVERLAY_TAG, 'opfSlideImageOverlay', 'slide image overlay');
93
+ }
94
+
95
+ function readTags(container, relationships, entries) {
96
+ const tags = [];
97
+ let unreadable = false;
98
+ for (const link of array(container?.['p:tags'])) {
99
+ const rel = relationships.get(link['r:id']);
100
+ if (rel?.type !== REL || rel.targetMode === 'External' || !entries[rel.path]) { unreadable = true; continue; }
101
+ try { tags.push(...array(parser.parse(decoder.decode(entries[rel.path]))['p:tagLst']?.['p:tag'])); } catch { unreadable = true; }
102
+ }
103
+ return {tags, unreadable};
104
+ }
105
+
106
+ function validTreatment(treatment, position) {
107
+ return treatment && typeof treatment === 'object' && !Array.isArray(treatment) && treatment.position === position
108
+ && POSITIONS.includes(position) && treatment.src === undefined;
109
+ }
110
+
111
+ /**
112
+ * Recover design.slideImage from an unchanged tagged picture. Returns the
113
+ * consumed picture indexes and the slide design fields to restore.
114
+ * readPicture(picture) returns the ordinary imported image payload.
115
+ */
116
+ export function importSlideImage(pictures, shapes, relationships, entries, slideIndex, readPicture, report) {
117
+ const consumed = new Set(), consumedShapes = new Set(), found = [];
118
+ for (const [index, picture] of pictures.entries()) {
119
+ const {tags, unreadable} = readTags(picture['p:nvPicPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries);
120
+ const own = tags.filter(tag => tag.name?.toUpperCase() === TAG);
121
+ if (!own.length) continue;
122
+ found.push({index, picture, own, unreadable, others: tags.some(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== TAG)});
123
+ }
124
+ if (!found.length) return {consumed, consumedShapes};
125
+ const invalid = () => report({code:'invalid-slide-image-provenance', message:'An edited, ambiguous or invalid tagged OPF slide image was imported as an ordinary picture. Its native placement is not reconstructed as design.slideImage.'});
126
+ if (found.length > 1) { invalid(); return {consumed, consumedShapes}; }
127
+ const [{index, picture, own, unreadable, others}] = found;
128
+ try {
129
+ if (unreadable || others || own.length !== 1) throw Error('Ambiguous slide image identity.');
130
+ const manifest = decodeTextTag(own[0].val);
131
+ if (manifest?.v !== 1 || manifest.slide !== `slides.${slideIndex}` || !['crop', 'fit'].includes(manifest.fill) || !validTreatment(manifest.treatment, manifest.position)) throw Error('Invalid slide image manifest.');
132
+ if (picture['p:nvPicPr']?.['p:cNvPr']?.name !== slideImageName(manifest.slide)) throw Error('Slide image identity changed.');
133
+ if (canonical(picture['p:spPr']) !== canonical(manifest.properties) || canonical(blipFillIdentity(picture['p:blipFill'])) !== canonical(manifest.blipFill)) throw Error('Slide image geometry or effects changed.');
134
+ const item = readPicture(picture);
135
+ const src = item?.payload?.image?.src;
136
+ if (typeof src !== 'string') throw Error('Slide image bytes are unavailable.');
137
+ consumed.add(index);
138
+ const treatment = {...manifest.treatment};
139
+ if (treatment.overlay !== undefined) {
140
+ // The scrim is a separate native shape; recover it only while it is unchanged.
141
+ const overlay = findOverlay(shapes, relationships, entries, manifest.slide);
142
+ if (overlay === null) {
143
+ delete treatment.overlay;
144
+ report({code:'invalid-slide-image-provenance', message:'The tagged OPF slide image overlay is missing, edited or ambiguous. The slide image is recovered without its overlay; any remaining native shape is imported as ordinary content.'});
145
+ } else consumedShapes.add(overlay);
146
+ }
147
+ return {consumed, consumedShapes, design: {slideImage: {...treatment, src}, ...(manifest.fill === 'fit' ? {imageFill: 'fit'} : {})}};
148
+ } catch {
149
+ invalid();
150
+ return {consumed, consumedShapes};
151
+ }
152
+ }
153
+
154
+ function findOverlay(shapes, relationships, entries, slide) {
155
+ const matches = [];
156
+ for (const [index, shape] of shapes.entries()) {
157
+ const {tags, unreadable} = readTags(shape['p:nvSpPr']?.['p:nvPr']?.['p:custDataLst'], relationships, entries);
158
+ const own = tags.filter(tag => tag.name?.toUpperCase() === OVERLAY_TAG);
159
+ if (!own.length) continue;
160
+ try {
161
+ if (unreadable || own.length !== 1 || tags.some(tag => /^OPF_/i.test(tag.name) && tag.name.toUpperCase() !== OVERLAY_TAG)) throw Error('Ambiguous overlay identity.');
162
+ const manifest = decodeTextTag(own[0].val);
163
+ if (manifest?.v !== 1 || manifest.slide !== slide || shape['p:nvSpPr']?.['p:cNvPr']?.name !== slideImageOverlayName(slide)) throw Error('Invalid overlay identity.');
164
+ if (canonical(shape['p:spPr']) !== canonical(manifest.properties)) throw Error('Overlay changed.');
165
+ matches.push(index);
166
+ } catch { return null; }
167
+ }
168
+ return matches.length === 1 ? matches[0] : null;
169
+ }
@@ -1,3 +1,4 @@
1
+ import {nativeRunStyle as runStyle, mergeNativeRunProperties as mergeProperties} from './native-text-style.js';
1
2
  import {drawingObject, readSlideTheme} from './background-import.js';
2
3
  import {readBackgroundColor} from './background.js';
3
4
  import {nativeCellStyle, nativeTableGrid, nativeStyleFill} from './table-cell-import.js';
@@ -9,59 +10,6 @@ const attrs = (tree, tag) => nodes(tree, tag)[0]?.[':@'] ?? {};
9
10
  const text = tree => (tree ?? []).map(node => node['#text'] ?? '').join('');
10
11
  const boolean = value => ['1','true','on'].includes(value) ? true : ['0','false','off'].includes(value) ? false : undefined;
11
12
 
12
- function runStyle(properties, context, relationships, report) {
13
- const result = {};
14
- for (const [native, opf] of [['b','bold'], ['i','italic']]) {
15
- const value = boolean(properties[native]);
16
- if (value !== undefined) result[opf] = value;
17
- }
18
- for (const [native, opf] of [['u','underline'], ['strike','strikethrough']]) {
19
- if (properties[native] !== undefined) result[opf] = properties[native] !== 'none' && properties[native] !== 'noStrike';
20
- }
21
- const size = Number(properties.sz);
22
- if (Number.isFinite(size) && size > 0) result.fontSize = size / 100;
23
- const baseline = Number(properties.baseline);
24
- if (Number.isFinite(baseline)) {
25
- result.superscript = baseline > 0;
26
- result.subscript = baseline < 0;
27
- }
28
- const font = properties['a:latin']?.typeface;
29
- if (properties['a:latin'] && !font) report('unsupported-table-font', 'The native text font has no usable Latin face or theme reference.');
30
- if (font) {
31
- const match = font.match(/^\+(mj|mn)-(lt|ea|cs)$/);
32
- const family = match ? context.fonts?.[match[1] === 'mj' ? 'a:majorFont' : 'a:minorFont']?.[{lt:'a:latin',ea:'a:ea',cs:'a:cs'}[match[2]]]?.typeface : font;
33
- if (family) result.fontFamily = family;
34
- else report('unsupported-table-font', 'The theme font reference could not be resolved from this archive.');
35
- }
36
- if (properties['a:solidFill']) {
37
- const color = readBackgroundColor(properties['a:solidFill'], context);
38
- if (color) result.color = color.hex + (color.alpha < 1 ? Math.round(color.alpha * 255).toString(16).padStart(2, '0').toUpperCase() : '');
39
- else report('unsupported-table-text-color', 'The native text color or its transforms could not be resolved.');
40
- } else if (properties['a:noFill']) {
41
- result.color = '#00000000';
42
- }
43
- if (['a:gradFill','a:blipFill','a:pattFill','a:grpFill'].some(key => properties[key])) report('unsupported-table-text-fill', 'Only solid native text fills are represented by OPF runs.');
44
- const hyperlink = properties['a:hlinkClick'];
45
- if (hyperlink) {
46
- const relationship = relationships.get(hyperlink['r:id']);
47
- if (relationship?.type.endsWith('/hyperlink') && relationship.targetMode === 'External' && relationship.target && !hyperlink.action) result.link = relationship.target;
48
- else report('unsupported-table-link', 'The native hyperlink action or relationship cannot be represented as a URL.');
49
- }
50
- return result;
51
- }
52
-
53
- // Merge at the native property level before conversion: an explicit normal run
54
- // overrides a bold paragraph default, and a new fill replaces the inherited fill.
55
- function mergeProperties(...levels) {
56
- const result = {};
57
- const fills = ['a:solidFill','a:noFill','a:gradFill','a:blipFill','a:pattFill','a:grpFill','_opfUnresolvedTableFill'];
58
- for (const level of levels) {
59
- if (!level) continue;
60
- if (fills.some(key => Object.hasOwn(level, key))) for (const key of fills) delete result[key];
61
- Object.assign(result, level);
62
- }
63
- return result;
64
- }
65
13
 
66
14
  function cellText(body, context, relationships, report, header, tableDefaults) {
67
15
  const listStyle = drawingObject(child(body, 'a:lstStyle'));