@textcortex/slidewise 1.8.0 → 1.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.
- package/dist/index.mjs +6287 -5264
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/editor/ElementView.tsx +97 -9
- package/src/lib/pptx/__tests__/roundtrip.test.ts +83 -0
- package/src/lib/pptx/deckToPptx.ts +280 -11
- package/src/lib/pptx/pptxToDeck.ts +1668 -129
- package/src/lib/types.ts +52 -0
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
TextRun,
|
|
10
10
|
ShapeElement,
|
|
11
11
|
ShapeKind,
|
|
12
|
+
ShapePath,
|
|
12
13
|
ImageElement,
|
|
13
14
|
LineElement,
|
|
14
15
|
TableElement,
|
|
@@ -49,6 +50,13 @@ interface ThemeColors {
|
|
|
49
50
|
accent6: string;
|
|
50
51
|
hlink: string;
|
|
51
52
|
folHlink: string;
|
|
53
|
+
// bg1/bg2/tx1/tx2 are *token* names rather than colour scheme entries —
|
|
54
|
+
// their resolved hexes come from the master's <p:clrMap>. Slidewise bakes
|
|
55
|
+
// those into the theme so resolveSchemeToken stays a flat lookup.
|
|
56
|
+
bg1: string;
|
|
57
|
+
bg2: string;
|
|
58
|
+
tx1: string;
|
|
59
|
+
tx2: string;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
interface PlaceholderInfo {
|
|
@@ -62,14 +70,18 @@ interface PlaceholderInfo {
|
|
|
62
70
|
rPr?: any;
|
|
63
71
|
pPr?: any;
|
|
64
72
|
bodyPr?: any;
|
|
73
|
+
/** Per-level paragraph defaults from <a:lstStyle><a:lvlNpPr>. */
|
|
74
|
+
lvlPPr?: (any | undefined)[];
|
|
65
75
|
/** Fallback paragraphs (used when the slide placeholder has no text). */
|
|
66
76
|
paragraphs?: any[];
|
|
77
|
+
/** Raw spPr (used to resolve the placeholder's own fill / stroke). */
|
|
78
|
+
spPr?: any;
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
interface MasterTextDefaults {
|
|
70
|
-
title?: any
|
|
71
|
-
body?: any;
|
|
72
|
-
other?: any;
|
|
82
|
+
title?: (any | undefined)[]; // titleStyle lvl1..lvl9 pPr
|
|
83
|
+
body?: (any | undefined)[]; // bodyStyle lvl1..lvl9 pPr
|
|
84
|
+
other?: (any | undefined)[];
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
interface ParseContext {
|
|
@@ -79,11 +91,50 @@ interface ParseContext {
|
|
|
79
91
|
slideRels: Rels;
|
|
80
92
|
fit: Fit;
|
|
81
93
|
theme: ThemeColors;
|
|
94
|
+
themeFills: ThemeFills;
|
|
95
|
+
themeFonts: ThemeFonts;
|
|
82
96
|
layoutPh: Map<string, PlaceholderInfo>;
|
|
83
97
|
masterPh: Map<string, PlaceholderInfo>;
|
|
84
98
|
masterTextDefaults: MasterTextDefaults;
|
|
85
99
|
}
|
|
86
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Ordered theme fill lists from a:fmtScheme. PPTX <p:bgRef idx="1001+"> looks
|
|
103
|
+
* up the (idx - 1000)th entry of bgFillStyleLst (and analogously fillStyleLst
|
|
104
|
+
* for 1+). Order across mixed tag types matters — fast-xml-parser flattens by
|
|
105
|
+
* tag, so we reconstruct order from the raw XML.
|
|
106
|
+
*/
|
|
107
|
+
interface ThemeFill {
|
|
108
|
+
kind: "solidFill" | "gradFill" | "blipFill" | "pattFill" | "noFill";
|
|
109
|
+
node: any;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface ThemeFills {
|
|
113
|
+
bg: ThemeFill[];
|
|
114
|
+
fg: ThemeFill[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface ThemeFonts {
|
|
118
|
+
/** Major (heading) Latin typeface — referenced via `+mj-lt`. */
|
|
119
|
+
majorLatin?: string;
|
|
120
|
+
/** Minor (body) Latin typeface — referenced via `+mn-lt`. */
|
|
121
|
+
minorLatin?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Maps generic colour tokens used by slides (`bg1`, `tx1`, `bg2`, `tx2`) onto
|
|
126
|
+
* actual theme entries (`lt1`, `dk1`, …). Defined on the slide master via
|
|
127
|
+
* `<p:clrMap>`; individual slides may override via `<p:clrMapOvr>`.
|
|
128
|
+
*/
|
|
129
|
+
interface ClrMap {
|
|
130
|
+
bg1: keyof ThemeColors;
|
|
131
|
+
bg2: keyof ThemeColors;
|
|
132
|
+
tx1: keyof ThemeColors;
|
|
133
|
+
tx2: keyof ThemeColors;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const DEFAULT_CLR_MAP: ClrMap = { bg1: "lt1", bg2: "lt2", tx1: "dk1", tx2: "dk2" };
|
|
137
|
+
|
|
87
138
|
const xmlParser = new XMLParser({
|
|
88
139
|
ignoreAttributes: false,
|
|
89
140
|
attributeNamePrefix: "@_",
|
|
@@ -110,6 +161,8 @@ const ARRAY_TAGS = new Set([
|
|
|
110
161
|
"p:grpSp",
|
|
111
162
|
"a:p",
|
|
112
163
|
"a:r",
|
|
164
|
+
"a:br",
|
|
165
|
+
"a:fld",
|
|
113
166
|
"a:tr",
|
|
114
167
|
"a:tc",
|
|
115
168
|
"Relationship",
|
|
@@ -128,6 +181,11 @@ const DEFAULT_THEME: ThemeColors = {
|
|
|
128
181
|
accent6: "#F79646",
|
|
129
182
|
hlink: "#0000FF",
|
|
130
183
|
folHlink: "#800080",
|
|
184
|
+
// Default clrMap (bg1→lt1, tx1→dk1, …).
|
|
185
|
+
bg1: "#FFFFFF",
|
|
186
|
+
bg2: "#EEECE1",
|
|
187
|
+
tx1: "#000000",
|
|
188
|
+
tx2: "#1F497D",
|
|
131
189
|
};
|
|
132
190
|
|
|
133
191
|
/**
|
|
@@ -146,8 +204,14 @@ const DEFAULT_THEME: ThemeColors = {
|
|
|
146
204
|
* UnknownElement carrying its raw OOXML so a save round-trip can re-emit
|
|
147
205
|
* it without data loss.
|
|
148
206
|
*/
|
|
149
|
-
export async function parsePptx(
|
|
150
|
-
|
|
207
|
+
export async function parsePptx(
|
|
208
|
+
blob: Blob | ArrayBuffer | Uint8Array
|
|
209
|
+
): Promise<Deck> {
|
|
210
|
+
// Keep the original archive bytes so serializeDeck can re-inject any
|
|
211
|
+
// OOXML we couldn't model (UnknownElement) back into the saved file
|
|
212
|
+
// along with the media it referenced. See SOURCE_PPTX / SOURCE_SLIDE_PATH.
|
|
213
|
+
const sourceBuffer = await toArrayBuffer(blob);
|
|
214
|
+
const zip = await JSZip.loadAsync(sourceBuffer);
|
|
151
215
|
const diagnostics: ParseDiagnostics = {
|
|
152
216
|
unknownElementCount: 0,
|
|
153
217
|
droppedAnimations: 0,
|
|
@@ -172,7 +236,17 @@ export async function parsePptx(blob: Blob | ArrayBuffer): Promise<Deck> {
|
|
|
172
236
|
const slides: Slide[] = [];
|
|
173
237
|
for (const slidePath of slidePaths) {
|
|
174
238
|
const slide = await parseSlide(zip, slidePath, diagnostics, fit);
|
|
175
|
-
if (slide)
|
|
239
|
+
if (slide) {
|
|
240
|
+
// Tag the slide with the source xml path so the serializer can pick
|
|
241
|
+
// the right `ppt/slides/slideN.xml.rels` to copy media refs from
|
|
242
|
+
// when the user adds / reorders / deletes slides in the editor.
|
|
243
|
+
Object.defineProperty(slide, SOURCE_SLIDE_PATH, {
|
|
244
|
+
value: slidePath,
|
|
245
|
+
enumerable: false,
|
|
246
|
+
configurable: true,
|
|
247
|
+
});
|
|
248
|
+
slides.push(slide);
|
|
249
|
+
}
|
|
176
250
|
}
|
|
177
251
|
|
|
178
252
|
if (!slides.length) {
|
|
@@ -181,12 +255,39 @@ export async function parsePptx(blob: Blob | ArrayBuffer): Promise<Deck> {
|
|
|
181
255
|
}
|
|
182
256
|
|
|
183
257
|
const deck: Deck = { version: CURRENT_DECK_VERSION, title, slides };
|
|
258
|
+
Object.defineProperty(deck, SOURCE_PPTX, {
|
|
259
|
+
value: sourceBuffer,
|
|
260
|
+
enumerable: false,
|
|
261
|
+
configurable: true,
|
|
262
|
+
});
|
|
184
263
|
if (diagnostics.warnings.length) {
|
|
185
264
|
console.info("[slidewise/pptx] parse diagnostics:", diagnostics);
|
|
186
265
|
}
|
|
187
266
|
return deck;
|
|
188
267
|
}
|
|
189
268
|
|
|
269
|
+
/** Non-enumerable property keys used to ferry the original archive
|
|
270
|
+
* bytes from parse to serialize so we can round-trip the OOXML we
|
|
271
|
+
* couldn't model. Internal — do not depend on these from outside the
|
|
272
|
+
* package; the contract is enforced at the parse/serialize boundary. */
|
|
273
|
+
export const SOURCE_PPTX = "__slidewiseSourcePptx";
|
|
274
|
+
export const SOURCE_SLIDE_PATH = "__slidewiseSourceSlidePath";
|
|
275
|
+
|
|
276
|
+
async function toArrayBuffer(
|
|
277
|
+
input: Blob | ArrayBuffer | Uint8Array
|
|
278
|
+
): Promise<ArrayBuffer> {
|
|
279
|
+
if (input instanceof ArrayBuffer) return input;
|
|
280
|
+
// Node Buffer is a Uint8Array subclass; honour it explicitly so the
|
|
281
|
+
// server-side `serializeDeck → arrayBuffer → parsePptx` round-trip
|
|
282
|
+
// works without the caller having to allocate a Blob.
|
|
283
|
+
if (input instanceof Uint8Array) {
|
|
284
|
+
const copy = new ArrayBuffer(input.byteLength);
|
|
285
|
+
new Uint8Array(copy).set(input);
|
|
286
|
+
return copy;
|
|
287
|
+
}
|
|
288
|
+
return input.arrayBuffer();
|
|
289
|
+
}
|
|
290
|
+
|
|
190
291
|
async function parseSlide(
|
|
191
292
|
zip: JSZip,
|
|
192
293
|
slidePath: string,
|
|
@@ -224,7 +325,29 @@ async function parseSlide(
|
|
|
224
325
|
? normalisePath(themeTarget, dirOf(masterPath))
|
|
225
326
|
: null;
|
|
226
327
|
const themeXml = themePath ? await readXml(zip, themePath) : null;
|
|
227
|
-
const
|
|
328
|
+
const themeRaw = themePath ? await readXmlRaw(zip, themePath) : null;
|
|
329
|
+
const baseTheme = themeXml ? extractTheme(themeXml) : DEFAULT_THEME;
|
|
330
|
+
const themeFills =
|
|
331
|
+
themeXml && themeRaw
|
|
332
|
+
? extractThemeFills(themeXml, themeRaw)
|
|
333
|
+
: { bg: [], fg: [] };
|
|
334
|
+
const themeFonts = themeXml ? extractThemeFonts(themeXml) : {};
|
|
335
|
+
// <p:clrMap> lives on the master; <p:clrMapOvr><a:overrideClrMapping/> on
|
|
336
|
+
// the slide can override individual mappings. Slides commonly only declare
|
|
337
|
+
// <a:masterClrMapping/> which means "inherit the master's map verbatim".
|
|
338
|
+
const masterClrMap = masterXml
|
|
339
|
+
? extractClrMap(masterXml?.["p:sldMaster"]?.["p:clrMap"])
|
|
340
|
+
: DEFAULT_CLR_MAP;
|
|
341
|
+
const clrMapOvr = xml["p:sld"]?.["p:clrMapOvr"]?.["a:overrideClrMapping"];
|
|
342
|
+
const clrMap = clrMapOvr ? extractClrMap(clrMapOvr) : masterClrMap;
|
|
343
|
+
// Bake the clrMap into the theme so bg1/bg2/tx1/tx2 stay flat lookups.
|
|
344
|
+
const theme: ThemeColors = {
|
|
345
|
+
...baseTheme,
|
|
346
|
+
bg1: baseTheme[clrMap.bg1],
|
|
347
|
+
bg2: baseTheme[clrMap.bg2],
|
|
348
|
+
tx1: baseTheme[clrMap.tx1],
|
|
349
|
+
tx2: baseTheme[clrMap.tx2],
|
|
350
|
+
};
|
|
228
351
|
|
|
229
352
|
const layoutPh = layoutXml ? extractPlaceholders(layoutXml) : new Map();
|
|
230
353
|
const masterPh = masterXml ? extractPlaceholders(masterXml) : new Map();
|
|
@@ -239,6 +362,8 @@ async function parseSlide(
|
|
|
239
362
|
slideRels,
|
|
240
363
|
fit,
|
|
241
364
|
theme,
|
|
365
|
+
themeFills,
|
|
366
|
+
themeFonts,
|
|
242
367
|
layoutPh,
|
|
243
368
|
masterPh,
|
|
244
369
|
masterTextDefaults,
|
|
@@ -246,27 +371,64 @@ async function parseSlide(
|
|
|
246
371
|
|
|
247
372
|
const sld = xml["p:sld"];
|
|
248
373
|
const cSld = sld?.["p:cSld"];
|
|
249
|
-
const slideBg =
|
|
374
|
+
const slideBg = await extractBackground(
|
|
375
|
+
cSld?.["p:bg"],
|
|
376
|
+
ctx,
|
|
377
|
+
slideRels,
|
|
378
|
+
slidePath
|
|
379
|
+
);
|
|
250
380
|
const layoutBg = layoutXml
|
|
251
|
-
?
|
|
381
|
+
? await extractBackground(
|
|
252
382
|
layoutXml?.["p:sldLayout"]?.["p:cSld"]?.["p:bg"],
|
|
253
|
-
|
|
383
|
+
ctx,
|
|
384
|
+
layoutRels,
|
|
385
|
+
layoutPath!
|
|
254
386
|
)
|
|
255
387
|
: undefined;
|
|
256
388
|
const masterBg = masterXml
|
|
257
|
-
?
|
|
389
|
+
? await extractBackground(
|
|
258
390
|
masterXml?.["p:sldMaster"]?.["p:cSld"]?.["p:bg"],
|
|
259
|
-
|
|
391
|
+
ctx,
|
|
392
|
+
masterRels,
|
|
393
|
+
masterPath!
|
|
260
394
|
)
|
|
261
395
|
: undefined;
|
|
262
396
|
const background = slideBg ?? layoutBg ?? masterBg ?? "#FFFFFF";
|
|
263
397
|
|
|
264
398
|
const spTree = cSld?.["p:spTree"];
|
|
399
|
+
|
|
400
|
+
// Layout & master visuals (non-placeholder shapes/pics, plus the fill of
|
|
401
|
+
// placeholder-bearing shapes) form an underlay so brand bars, side gradients,
|
|
402
|
+
// logo pics, and tinted placeholder boxes appear behind slide content.
|
|
403
|
+
// Placeholders the slide already overrides (e.g. picture placeholder filled
|
|
404
|
+
// by an in-slide <p:pic>) are skipped so their "Insert Picture" prompt
|
|
405
|
+
// background doesn't leak through.
|
|
406
|
+
const slidePhKeys = collectSlidePlaceholderKeys(spTree);
|
|
407
|
+
const masterUnderlay = masterXml
|
|
408
|
+
? await parseUnderlay(
|
|
409
|
+
masterXml["p:sldMaster"]?.["p:cSld"]?.["p:spTree"],
|
|
410
|
+
ctx,
|
|
411
|
+
masterPath!,
|
|
412
|
+
masterRels,
|
|
413
|
+
slidePhKeys
|
|
414
|
+
)
|
|
415
|
+
: [];
|
|
416
|
+
const layoutUnderlay = layoutXml
|
|
417
|
+
? await parseUnderlay(
|
|
418
|
+
layoutXml["p:sldLayout"]?.["p:cSld"]?.["p:spTree"],
|
|
419
|
+
ctx,
|
|
420
|
+
layoutPath!,
|
|
421
|
+
layoutRels,
|
|
422
|
+
slidePhKeys
|
|
423
|
+
)
|
|
424
|
+
: [];
|
|
265
425
|
const elements: SlideElement[] = [];
|
|
266
426
|
|
|
427
|
+
let z = 1;
|
|
428
|
+
for (const el of masterUnderlay) elements.push({ ...el, z: z++ });
|
|
429
|
+
for (const el of layoutUnderlay) elements.push({ ...el, z: z++ });
|
|
267
430
|
if (spTree) {
|
|
268
431
|
const collected = await parseSpTree(spTree, ctx, identityTransform());
|
|
269
|
-
let z = 1;
|
|
270
432
|
for (const el of collected) {
|
|
271
433
|
elements.push({ ...el, z: z++ });
|
|
272
434
|
}
|
|
@@ -279,29 +441,64 @@ async function parseSlide(
|
|
|
279
441
|
};
|
|
280
442
|
}
|
|
281
443
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
function
|
|
291
|
-
|
|
444
|
+
/**
|
|
445
|
+
* Walk a layout or master spTree and return elements to render behind the
|
|
446
|
+
* slide: non-placeholder shapes/pics (the brand bars, gradient bands, corner
|
|
447
|
+
* logos) and any explicit fill on placeholder-bearing shapes (the tinted
|
|
448
|
+
* boxes some templates host on layout placeholders). Hidden shapes are
|
|
449
|
+
* skipped. Placeholder text/positions remain handled by the existing
|
|
450
|
+
* inheritance path so we don't duplicate them.
|
|
451
|
+
*/
|
|
452
|
+
async function parseUnderlay(
|
|
453
|
+
spTree: any,
|
|
454
|
+
ctx: ParseContext,
|
|
455
|
+
ownerPath: string,
|
|
456
|
+
ownerRels: Rels,
|
|
457
|
+
slidePhKeys: Set<string>
|
|
458
|
+
): Promise<SlideElement[]> {
|
|
459
|
+
if (!spTree) return [];
|
|
460
|
+
const underlayCtx: ParseContext = {
|
|
461
|
+
...ctx,
|
|
462
|
+
slidePath: ownerPath,
|
|
463
|
+
slideRels: ownerRels,
|
|
464
|
+
};
|
|
465
|
+
return walkUnderlay(spTree, underlayCtx, identityTransform(), slidePhKeys);
|
|
292
466
|
}
|
|
293
467
|
|
|
294
|
-
async function
|
|
468
|
+
async function walkUnderlay(
|
|
295
469
|
spTree: any,
|
|
296
470
|
ctx: ParseContext,
|
|
297
|
-
outer: GroupTransform
|
|
471
|
+
outer: GroupTransform,
|
|
472
|
+
slidePhKeys: Set<string>
|
|
298
473
|
): Promise<SlideElement[]> {
|
|
299
474
|
const out: SlideElement[] = [];
|
|
300
475
|
for (const sp of asArray(spTree["p:sp"])) {
|
|
301
|
-
|
|
476
|
+
if (isHiddenNode(sp, "p:nvSpPr")) continue;
|
|
477
|
+
const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
|
|
478
|
+
if (ph) {
|
|
479
|
+
const isPicPrompt = ph["@_type"] === "pic";
|
|
480
|
+
const isOverridden = slidePhKeys.has(placeholderKey(ph));
|
|
481
|
+
// Picture placeholders are "Insert Picture" prompts; when the slide
|
|
482
|
+
// supplies an actual image, the prompt panel must hide.
|
|
483
|
+
if (isPicPrompt && isOverridden) continue;
|
|
484
|
+
// When the slide hosts this placeholder, its fill rides on the
|
|
485
|
+
// slide's text element (TextElement.background) so it stays at the
|
|
486
|
+
// text's z-index — important when the slide also has a full-bleed
|
|
487
|
+
// image that would otherwise cover an underlay-emitted backing.
|
|
488
|
+
if (isOverridden) continue;
|
|
489
|
+
// Unreferenced placeholders: emit a fill-only backing so coloured
|
|
490
|
+
// boxes (numbered chips, decorative panels) appear.
|
|
491
|
+
const filler = await placeholderFillUnderlay(sp, ctx, outer);
|
|
492
|
+
if (filler) out.push(filler);
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
const el = await parseSpOrText(sp, ctx, outer, { underlay: true });
|
|
302
496
|
if (el) out.push(el);
|
|
303
497
|
}
|
|
304
498
|
for (const pic of asArray(spTree["p:pic"])) {
|
|
499
|
+
if (isHiddenNode(pic, "p:nvPicPr")) continue;
|
|
500
|
+
const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
|
|
501
|
+
if (ph && slidePhKeys.has(placeholderKey(ph))) continue;
|
|
305
502
|
const el = await parsePic(pic, ctx, outer);
|
|
306
503
|
if (el) out.push(el);
|
|
307
504
|
}
|
|
@@ -309,14 +506,147 @@ async function parseSpTree(
|
|
|
309
506
|
const el = parseCxn(cxn, ctx, outer);
|
|
310
507
|
if (el) out.push(el);
|
|
311
508
|
}
|
|
312
|
-
for (const gf of asArray(spTree["p:graphicFrame"])) {
|
|
313
|
-
const el = parseGraphicFrame(gf, ctx, outer);
|
|
314
|
-
if (el) out.push(el);
|
|
315
|
-
}
|
|
316
509
|
for (const grp of asArray(spTree["p:grpSp"])) {
|
|
317
510
|
const inner = composeGroupTransform(grp, outer);
|
|
318
|
-
|
|
319
|
-
|
|
511
|
+
out.push(...(await walkUnderlay(grp, ctx, inner, slidePhKeys)));
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function collectSlidePlaceholderKeys(spTree: any): Set<string> {
|
|
517
|
+
const keys = new Set<string>();
|
|
518
|
+
if (!spTree) return keys;
|
|
519
|
+
const visit = (tree: any) => {
|
|
520
|
+
if (!tree) return;
|
|
521
|
+
for (const sp of asArray(tree["p:sp"])) {
|
|
522
|
+
const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
|
|
523
|
+
if (ph) keys.add(placeholderKey(ph));
|
|
524
|
+
}
|
|
525
|
+
for (const pic of asArray(tree["p:pic"])) {
|
|
526
|
+
const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
|
|
527
|
+
if (ph) keys.add(placeholderKey(ph));
|
|
528
|
+
}
|
|
529
|
+
for (const grp of asArray(tree["p:grpSp"])) visit(grp);
|
|
530
|
+
};
|
|
531
|
+
visit(spTree);
|
|
532
|
+
return keys;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function isHiddenNode(node: any, nvKey: "p:nvSpPr" | "p:nvPicPr"): boolean {
|
|
536
|
+
const cNvPr = node?.[nvKey]?.["p:cNvPr"];
|
|
537
|
+
return cNvPr?.["@_hidden"] === "1" || cNvPr?.["@_hidden"] === 1;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Resolve a shape's <p:style><a:fillRef idx="N">...<a:schemeClr/></a:fillRef>
|
|
542
|
+
* against the theme's fillStyleLst. idx=0 = noFill; 1+ indexes the fillStyleLst
|
|
543
|
+
* children; 1001+ indexes bgFillStyleLst (rarely used here). The colour child
|
|
544
|
+
* inside fillRef plays the role of phClr in the theme fill template.
|
|
545
|
+
*/
|
|
546
|
+
function resolveStyleFillRef(sp: any, ctx: ParseContext): string | undefined {
|
|
547
|
+
const fillRef = sp?.["p:style"]?.["a:fillRef"];
|
|
548
|
+
if (!fillRef) return undefined;
|
|
549
|
+
const idx = Number(fillRef["@_idx"]);
|
|
550
|
+
if (!Number.isFinite(idx) || idx === 0) return undefined;
|
|
551
|
+
const list = idx >= 1000 ? ctx.themeFills.bg : ctx.themeFills.fg;
|
|
552
|
+
const entry = list[(idx >= 1000 ? idx - 1001 : idx - 1)];
|
|
553
|
+
if (!entry) return undefined;
|
|
554
|
+
const phColor = readBaseHex(fillRef, ctx.theme);
|
|
555
|
+
if (entry.kind === "solidFill") {
|
|
556
|
+
return resolveColor(substitutePhClr(entry.node, phColor), ctx.theme);
|
|
557
|
+
}
|
|
558
|
+
if (entry.kind === "gradFill") {
|
|
559
|
+
return extractShapeFill(
|
|
560
|
+
{ "a:gradFill": substitutePhClr(entry.node, phColor) },
|
|
561
|
+
ctx.theme
|
|
562
|
+
);
|
|
563
|
+
}
|
|
564
|
+
if (entry.kind === "noFill") return "transparent";
|
|
565
|
+
return undefined;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function placeholderFillUnderlay(
|
|
569
|
+
sp: any,
|
|
570
|
+
ctx: ParseContext,
|
|
571
|
+
outer: GroupTransform = identityTransform()
|
|
572
|
+
): Promise<ShapeElement | null> {
|
|
573
|
+
const spPr = sp?.["p:spPr"];
|
|
574
|
+
if (!spPr) return null;
|
|
575
|
+
const fill = extractShapeFill(spPr, ctx.theme);
|
|
576
|
+
if (!fill || fill === "transparent") return null;
|
|
577
|
+
const geom = readGeometry(spPr["a:xfrm"], ctx.fit, outer);
|
|
578
|
+
if (!geom) return null;
|
|
579
|
+
return {
|
|
580
|
+
id: nanoid(8),
|
|
581
|
+
type: "shape",
|
|
582
|
+
...geom,
|
|
583
|
+
z: 0,
|
|
584
|
+
shape: "rect",
|
|
585
|
+
fill,
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
interface GroupTransform {
|
|
590
|
+
/** Linear transform for child raw-px coordinates: x' = a*x + c, y' = b*y + d. */
|
|
591
|
+
a: number;
|
|
592
|
+
b: number;
|
|
593
|
+
c: number;
|
|
594
|
+
d: number;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function identityTransform(): GroupTransform {
|
|
598
|
+
return { a: 1, b: 1, c: 0, d: 0 };
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function parseSpTree(
|
|
602
|
+
spTree: any,
|
|
603
|
+
ctx: ParseContext,
|
|
604
|
+
outer: GroupTransform
|
|
605
|
+
): Promise<SlideElement[]> {
|
|
606
|
+
const out: SlideElement[] = [];
|
|
607
|
+
// Cursor per tag — we pop from each parsed array as we encounter its tag
|
|
608
|
+
// in the document-order list, so the same elements get visited in the
|
|
609
|
+
// order they appeared in the source XML (which determines z-index).
|
|
610
|
+
const cursors: Record<string, number> = {
|
|
611
|
+
"p:sp": 0,
|
|
612
|
+
"p:pic": 0,
|
|
613
|
+
"p:cxnSp": 0,
|
|
614
|
+
"p:graphicFrame": 0,
|
|
615
|
+
"p:grpSp": 0,
|
|
616
|
+
};
|
|
617
|
+
const order: string[] = (spTree as any)?._childOrder ?? [
|
|
618
|
+
// Fall back to legacy tag-grouped order when raw isn't attached
|
|
619
|
+
// (e.g. tests that build a parsed structure by hand).
|
|
620
|
+
...asArray(spTree["p:sp"]).map(() => "p:sp"),
|
|
621
|
+
...asArray(spTree["p:pic"]).map(() => "p:pic"),
|
|
622
|
+
...asArray(spTree["p:cxnSp"]).map(() => "p:cxnSp"),
|
|
623
|
+
...asArray(spTree["p:graphicFrame"]).map(() => "p:graphicFrame"),
|
|
624
|
+
...asArray(spTree["p:grpSp"]).map(() => "p:grpSp"),
|
|
625
|
+
];
|
|
626
|
+
|
|
627
|
+
for (const tag of order) {
|
|
628
|
+
if (!(tag in cursors)) continue;
|
|
629
|
+
const arr = asArray((spTree as any)[tag]);
|
|
630
|
+
const idx = cursors[tag]++;
|
|
631
|
+
const node = arr[idx];
|
|
632
|
+
if (!node) continue;
|
|
633
|
+
if (tag === "p:sp") {
|
|
634
|
+
const el = await parseSpOrText(node, ctx, outer);
|
|
635
|
+
if (el) out.push(el);
|
|
636
|
+
} else if (tag === "p:pic") {
|
|
637
|
+
const el = await parsePic(node, ctx, outer);
|
|
638
|
+
if (el) out.push(el);
|
|
639
|
+
} else if (tag === "p:cxnSp") {
|
|
640
|
+
const el = parseCxn(node, ctx, outer);
|
|
641
|
+
if (el) out.push(el);
|
|
642
|
+
} else if (tag === "p:graphicFrame") {
|
|
643
|
+
const el = parseGraphicFrame(node, ctx, outer);
|
|
644
|
+
if (el) out.push(el);
|
|
645
|
+
} else if (tag === "p:grpSp") {
|
|
646
|
+
const inner = composeGroupTransform(node, outer);
|
|
647
|
+
const children = await parseSpTree(node, ctx, inner);
|
|
648
|
+
out.push(...children);
|
|
649
|
+
}
|
|
320
650
|
}
|
|
321
651
|
return out;
|
|
322
652
|
}
|
|
@@ -367,7 +697,8 @@ function composeGroupTransform(grp: any, outer: GroupTransform): GroupTransform
|
|
|
367
697
|
async function parseSpOrText(
|
|
368
698
|
sp: any,
|
|
369
699
|
ctx: ParseContext,
|
|
370
|
-
outer: GroupTransform
|
|
700
|
+
outer: GroupTransform,
|
|
701
|
+
opts: { underlay?: boolean } = {}
|
|
371
702
|
): Promise<SlideElement | null> {
|
|
372
703
|
const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
|
|
373
704
|
const phKey = ph ? placeholderKey(ph) : null;
|
|
@@ -386,6 +717,8 @@ async function parseSpOrText(
|
|
|
386
717
|
const txBody = sp["p:txBody"];
|
|
387
718
|
const prstGeom = sp?.["p:spPr"]?.["a:prstGeom"];
|
|
388
719
|
const presetName = prstGeom?.["@_prst"];
|
|
720
|
+
const custGeom = sp?.["p:spPr"]?.["a:custGeom"];
|
|
721
|
+
const customPath = custGeom ? parseCustGeomPath(custGeom) : undefined;
|
|
389
722
|
|
|
390
723
|
// Lines are sometimes authored as <p:sp prst="line">.
|
|
391
724
|
if (presetName === "line" || presetName === "straightConnector1") {
|
|
@@ -401,10 +734,13 @@ async function parseSpOrText(
|
|
|
401
734
|
const phType = ph?.["@_type"];
|
|
402
735
|
const isPlaceholderTextHost = !!ph && phType !== "pic";
|
|
403
736
|
const hasText = !!txBody && hasAnyText(txBody);
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
737
|
+
// Treat as text when the element actually carries text OR when it's a
|
|
738
|
+
// placeholder text host with no preset geometry override. A
|
|
739
|
+
// non-placeholder shape with an empty <p:txBody> (commonly authored
|
|
740
|
+
// around <a:custGeom> graphics like brand icons) is a SHAPE — promoting
|
|
741
|
+
// it to a text element would drop the silhouette and fill.
|
|
742
|
+
const isText = hasText || (isPlaceholderTextHost && !presetName);
|
|
743
|
+
void opts;
|
|
408
744
|
|
|
409
745
|
if (isText) {
|
|
410
746
|
return makeTextElement(sp, txBody, geom, ctx, ph, layoutPh, masterPh);
|
|
@@ -412,7 +748,10 @@ async function parseSpOrText(
|
|
|
412
748
|
|
|
413
749
|
// Fill / stroke. Use placeholder-inherited spPr if slide spPr is empty.
|
|
414
750
|
const spPr = sp?.["p:spPr"];
|
|
415
|
-
const fillColor =
|
|
751
|
+
const fillColor =
|
|
752
|
+
extractShapeFill(spPr, ctx.theme)
|
|
753
|
+
?? resolveStyleFillRef(sp, ctx)
|
|
754
|
+
?? "transparent";
|
|
416
755
|
const lineProps = spPr?.["a:ln"];
|
|
417
756
|
const lineHasNoFill = lineProps?.["a:noFill"] !== undefined;
|
|
418
757
|
const stroke = lineHasNoFill
|
|
@@ -427,7 +766,9 @@ async function parseSpOrText(
|
|
|
427
766
|
if (!kind) {
|
|
428
767
|
// Fall back to a rect with the shape's fill so it remains visible at the
|
|
429
768
|
// correct position rather than dropping to an opaque "Imported content"
|
|
430
|
-
// tile.
|
|
769
|
+
// tile. When the source carried a <a:custGeom> path we attach it so the
|
|
770
|
+
// renderer draws the actual silhouette (logos, brand marks) instead of
|
|
771
|
+
// the rectangle stand-in.
|
|
431
772
|
const fallback: ShapeElement = {
|
|
432
773
|
id: nanoid(8),
|
|
433
774
|
type: "shape",
|
|
@@ -439,6 +780,7 @@ async function parseSpOrText(
|
|
|
439
780
|
strokeWidth: strokeWidthEmu
|
|
440
781
|
? Math.max(1, Math.round(emuToPx(strokeWidthEmu) * ctx.fit.scale))
|
|
441
782
|
: undefined,
|
|
783
|
+
...(customPath ? { path: customPath } : {}),
|
|
442
784
|
};
|
|
443
785
|
return fallback;
|
|
444
786
|
}
|
|
@@ -473,7 +815,7 @@ async function parseSpOrText(
|
|
|
473
815
|
}
|
|
474
816
|
|
|
475
817
|
function makeTextElement(
|
|
476
|
-
|
|
818
|
+
sp: any,
|
|
477
819
|
txBody: any,
|
|
478
820
|
geom: { x: number; y: number; w: number; h: number; rotation: number },
|
|
479
821
|
ctx: ParseContext,
|
|
@@ -493,31 +835,74 @@ function makeTextElement(
|
|
|
493
835
|
? { "a:bodyPr": masterPh.bodyPr, "a:p": masterPh.paragraphs }
|
|
494
836
|
: txBody;
|
|
495
837
|
|
|
496
|
-
// Master defaults for the placeholder type (title vs body vs other)
|
|
838
|
+
// Master defaults for the placeholder type (title vs body vs other), as
|
|
839
|
+
// an array of lvl1..lvl9 paragraph properties.
|
|
497
840
|
const phType = ph?.["@_type"];
|
|
498
|
-
const
|
|
841
|
+
const masterLevels: (any | undefined)[] =
|
|
499
842
|
phType === "title" || phType === "ctrTitle"
|
|
500
|
-
? ctx.masterTextDefaults.title
|
|
843
|
+
? (ctx.masterTextDefaults.title ?? [])
|
|
501
844
|
: phType === "body" || phType === "subTitle"
|
|
502
|
-
? ctx.masterTextDefaults.body
|
|
503
|
-
: ctx.masterTextDefaults.other;
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
845
|
+
? (ctx.masterTextDefaults.body ?? [])
|
|
846
|
+
: (ctx.masterTextDefaults.other ?? []);
|
|
847
|
+
const masterLvl1 = masterLevels[0];
|
|
848
|
+
|
|
849
|
+
// Accumulate inheritance: slide < layout < master < masterDefaults. Each
|
|
850
|
+
// level can specify just a subset of fields (the layout might set the
|
|
851
|
+
// typeface while only the master defines the colour), so merge field by
|
|
852
|
+
// field with earlier candidates winning.
|
|
853
|
+
const fallbackRPr = mergeRPrChain(
|
|
507
854
|
layoutPh?.rPr,
|
|
508
855
|
masterPh?.rPr,
|
|
509
|
-
|
|
856
|
+
masterLvl1?.["a:defRPr"]
|
|
510
857
|
);
|
|
511
858
|
const fallbackPPr = mergeFirst(
|
|
512
859
|
layoutPh?.pPr,
|
|
513
860
|
masterPh?.pPr,
|
|
514
|
-
|
|
861
|
+
masterLvl1
|
|
515
862
|
);
|
|
516
863
|
const fallbackBodyPr = mergeFirst(layoutPh?.bodyPr, masterPh?.bodyPr);
|
|
517
864
|
|
|
518
|
-
|
|
865
|
+
// Resolve a per-level [layoutLvl, masterPhLvl, masterTxStyleLvl] chain so
|
|
866
|
+
// bullet/alignment/lineSpacing each fall through independently when an
|
|
867
|
+
// earlier layer is silent on that particular field.
|
|
868
|
+
const listStyle: (any | undefined)[][] = [];
|
|
869
|
+
for (let i = 0; i < 9; i++) {
|
|
870
|
+
const chain = [
|
|
871
|
+
layoutPh?.lvlPPr?.[i],
|
|
872
|
+
masterPh?.lvlPPr?.[i],
|
|
873
|
+
masterLevels[i],
|
|
874
|
+
].filter(Boolean);
|
|
875
|
+
listStyle.push(chain);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// <a:bodyPr><a:normAutofit fontScale="..." lnSpcReduction="..."/> shrinks
|
|
879
|
+
// text that overflowed when authored — apply so wraps don't push runs off
|
|
880
|
+
// the slide.
|
|
881
|
+
const autoFit = readNormAutofit(
|
|
882
|
+
effectiveTxBody?.["a:bodyPr"] ?? fallbackBodyPr
|
|
883
|
+
);
|
|
884
|
+
|
|
885
|
+
const text = extractRuns(
|
|
886
|
+
effectiveTxBody,
|
|
887
|
+
ctx.theme,
|
|
888
|
+
fallbackRPr,
|
|
889
|
+
fallbackPPr,
|
|
890
|
+
ctx.themeFonts,
|
|
891
|
+
listStyle,
|
|
892
|
+
autoFit
|
|
893
|
+
);
|
|
519
894
|
const first = text.runs[0];
|
|
520
|
-
|
|
895
|
+
// Each layer of the inheritance chain may set @algn independently — a
|
|
896
|
+
// layout placeholder can override geometry without touching alignment,
|
|
897
|
+
// expecting the master's algn="r" (slide-number, page-footer right-edge
|
|
898
|
+
// style) to still apply. mergeFirst would lock onto the layout's whole
|
|
899
|
+
// pPr and hide the master's algn, so check each layer in turn.
|
|
900
|
+
const align =
|
|
901
|
+
text.align ??
|
|
902
|
+
readAlign(layoutPh?.pPr) ??
|
|
903
|
+
readAlign(masterPh?.pPr) ??
|
|
904
|
+
readAlign(masterLvl1) ??
|
|
905
|
+
"left";
|
|
521
906
|
const valign =
|
|
522
907
|
readBodyVAlign(effectiveTxBody?.["a:bodyPr"]) ??
|
|
523
908
|
readBodyVAlign(fallbackBodyPr) ??
|
|
@@ -529,7 +914,10 @@ function makeTextElement(
|
|
|
529
914
|
: Math.round(defaultFontSizePx(phType, ctx) * scale);
|
|
530
915
|
const fontFamily =
|
|
531
916
|
first?.fontFamily ??
|
|
532
|
-
|
|
917
|
+
resolveFontFamily(
|
|
918
|
+
fallbackRPr?.["a:latin"]?.["@_typeface"],
|
|
919
|
+
ctx.themeFonts
|
|
920
|
+
) ??
|
|
533
921
|
"Inter";
|
|
534
922
|
const fontWeight = first?.bold ? 700 : 400;
|
|
535
923
|
const color =
|
|
@@ -585,6 +973,65 @@ function makeTextElement(
|
|
|
585
973
|
: 0,
|
|
586
974
|
...(hasMixedFormatting ? { runs } : {}),
|
|
587
975
|
};
|
|
976
|
+
// Inner padding from <a:bodyPr lIns/tIns/rIns/bIns>. PowerPoint applies
|
|
977
|
+
// these as text-box insets (the typographic equivalent of CSS padding).
|
|
978
|
+
// Default values in OOXML are 91440 / 45720 / 91440 / 45720 EMU. The
|
|
979
|
+
// slide often carries an empty <a:bodyPr/> that should silently inherit
|
|
980
|
+
// each attribute from the layout/master, so we read per-field rather
|
|
981
|
+
// than swap whole bodyPr objects.
|
|
982
|
+
const slideBp = effectiveTxBody?.["a:bodyPr"];
|
|
983
|
+
const layoutBp = layoutPh?.bodyPr;
|
|
984
|
+
const masterBp = masterPh?.bodyPr;
|
|
985
|
+
const readIns = (key: string, fallback: number): number => {
|
|
986
|
+
const v =
|
|
987
|
+
slideBp?.[`@_${key}`] ??
|
|
988
|
+
layoutBp?.[`@_${key}`] ??
|
|
989
|
+
masterBp?.[`@_${key}`];
|
|
990
|
+
return v !== undefined ? Number(v) : fallback;
|
|
991
|
+
};
|
|
992
|
+
const lIns = readIns("lIns", 91440);
|
|
993
|
+
const tIns = readIns("tIns", 45720);
|
|
994
|
+
const rIns = readIns("rIns", 91440);
|
|
995
|
+
const bIns = readIns("bIns", 45720);
|
|
996
|
+
const padding = {
|
|
997
|
+
l: Math.round(emuToPx(lIns) * ctx.fit.scale),
|
|
998
|
+
t: Math.round(emuToPx(tIns) * ctx.fit.scale),
|
|
999
|
+
r: Math.round(emuToPx(rIns) * ctx.fit.scale),
|
|
1000
|
+
b: Math.round(emuToPx(bIns) * ctx.fit.scale),
|
|
1001
|
+
};
|
|
1002
|
+
if (padding.l || padding.t || padding.r || padding.b) {
|
|
1003
|
+
el.padding = padding;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// Layout placeholders often supply a fill (e.g. a tinted body box) or a
|
|
1007
|
+
// <a:custGeom> path (a white brand-logo plate) that should sit
|
|
1008
|
+
// *immediately* behind the slide's hosted text — at the same z, not in
|
|
1009
|
+
// the underlay. Otherwise a full-bleed image on the slide will cover the
|
|
1010
|
+
// backing. The slide's own <p:spPr> can also override the fill on a
|
|
1011
|
+
// per-element basis (e.g. one chip in a roadmap is the "active" red
|
|
1012
|
+
// tile) — read the slide's spPr first and fall back to the layout's.
|
|
1013
|
+
const slideSpPr = sp?.["p:spPr"];
|
|
1014
|
+
const layoutSpPr = layoutPh?.spPr;
|
|
1015
|
+
const slideFill = slideSpPr ? extractShapeFill(slideSpPr, ctx.theme) : undefined;
|
|
1016
|
+
const layoutFill = layoutSpPr ? extractShapeFill(layoutSpPr, ctx.theme) : undefined;
|
|
1017
|
+
const phFill = slideFill ?? layoutFill;
|
|
1018
|
+
const phSpPr = layoutSpPr;
|
|
1019
|
+
const phPath = phSpPr?.["a:custGeom"]
|
|
1020
|
+
? parseCustGeomPath(phSpPr["a:custGeom"])
|
|
1021
|
+
: undefined;
|
|
1022
|
+
if (phPath && phFill && phFill !== "transparent") {
|
|
1023
|
+
// custGeom defines the actual rendered silhouette; the fill applies to
|
|
1024
|
+
// it. Skip the flat background and render the glyph as a backing path.
|
|
1025
|
+
el.backingPath = {
|
|
1026
|
+
d: phPath.d,
|
|
1027
|
+
viewW: phPath.viewW,
|
|
1028
|
+
viewH: phPath.viewH,
|
|
1029
|
+
fill: phFill,
|
|
1030
|
+
fillRule: phPath.fillRule,
|
|
1031
|
+
};
|
|
1032
|
+
} else if (phFill && phFill !== "transparent") {
|
|
1033
|
+
el.background = phFill;
|
|
1034
|
+
}
|
|
588
1035
|
return el;
|
|
589
1036
|
}
|
|
590
1037
|
|
|
@@ -601,10 +1048,25 @@ async function parsePic(
|
|
|
601
1048
|
outer: GroupTransform
|
|
602
1049
|
): Promise<SlideElement | null> {
|
|
603
1050
|
const xfrm = pic?.["p:spPr"]?.["a:xfrm"];
|
|
604
|
-
|
|
1051
|
+
// Picture placeholders (<p:ph type="pic">) often omit xfrm — inherit
|
|
1052
|
+
// geometry from the layout/master placeholder of the same key.
|
|
1053
|
+
const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
|
|
1054
|
+
const layoutPh = ph ? lookupPlaceholder(ctx.layoutPh, ph) : undefined;
|
|
1055
|
+
const masterPh = ph ? lookupPlaceholder(ctx.masterPh, ph) : undefined;
|
|
1056
|
+
const geom =
|
|
1057
|
+
readGeometry(xfrm, ctx.fit, outer)
|
|
1058
|
+
?? placeholderGeometry(layoutPh, ctx.fit, outer)
|
|
1059
|
+
?? placeholderGeometry(masterPh, ctx.fit, outer);
|
|
605
1060
|
if (!geom) return toUnknown(pic, "p:pic", ctx, outer);
|
|
606
1061
|
|
|
607
|
-
|
|
1062
|
+
// Modern PPTX embeds SVGs via a dual-blip: <a:blip r:embed="rId_png">…
|
|
1063
|
+
// <a:extLst><a:ext uri="…"><asvg:svgBlip r:embed="rId_svg"/></a:ext></a:extLst>
|
|
1064
|
+
// </a:blip>. The outer embed is the raster fallback; prefer the SVG when
|
|
1065
|
+
// present so vector logos stay sharp.
|
|
1066
|
+
const blip = pic?.["p:blipFill"]?.["a:blip"];
|
|
1067
|
+
const svgRef = findSvgBlipRef(blip);
|
|
1068
|
+
const rasterRef = blip?.["@_r:embed"];
|
|
1069
|
+
const blipRef = svgRef ?? rasterRef;
|
|
608
1070
|
if (!blipRef) return toUnknown(pic, "p:pic", ctx, outer);
|
|
609
1071
|
|
|
610
1072
|
const mediaPath = ctx.slideRels.byId.get(blipRef)?.target;
|
|
@@ -614,8 +1076,20 @@ async function parsePic(
|
|
|
614
1076
|
const file = ctx.zip.file(fullPath);
|
|
615
1077
|
if (!file) return toUnknown(pic, "p:pic", ctx, outer);
|
|
616
1078
|
|
|
617
|
-
const base64 = await file.async("base64");
|
|
618
1079
|
const ext = (fullPath.split(".").pop() || "png").toLowerCase();
|
|
1080
|
+
// EMF / WMF are Microsoft vector formats that browsers can't render
|
|
1081
|
+
// natively. Skip them with a diagnostic — emitting them as
|
|
1082
|
+
// <img src="data:application/octet-stream…"> surfaces a broken-image
|
|
1083
|
+
// icon, and synthesising a fake placeholder only hides the gap.
|
|
1084
|
+
// Consumers needing fidelity should pre-rasterise the metafiles before
|
|
1085
|
+
// import; a true EMF→SVG path needs a dedicated JS decoder (separate PR).
|
|
1086
|
+
if (ext === "emf" || ext === "wmf") {
|
|
1087
|
+
ctx.diagnostics.warnings.push(
|
|
1088
|
+
`Skipped ${ext.toUpperCase()} image at ${fullPath} — vector metafiles aren't supported in the browser.`
|
|
1089
|
+
);
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
const base64 = await file.async("base64");
|
|
619
1093
|
const mime = mimeForExt(ext);
|
|
620
1094
|
|
|
621
1095
|
const blipFill = pic?.["p:blipFill"];
|
|
@@ -646,6 +1120,21 @@ async function parsePic(
|
|
|
646
1120
|
return image;
|
|
647
1121
|
}
|
|
648
1122
|
|
|
1123
|
+
/**
|
|
1124
|
+
* Pull the SVG blip rId from a:blip/a:extLst/a:ext/asvg:svgBlip if present.
|
|
1125
|
+
* Returns undefined when the picture is raster-only.
|
|
1126
|
+
*/
|
|
1127
|
+
function findSvgBlipRef(blip: any): string | undefined {
|
|
1128
|
+
if (!blip) return undefined;
|
|
1129
|
+
const exts = asArray(blip?.["a:extLst"]?.["a:ext"]);
|
|
1130
|
+
for (const ext of exts) {
|
|
1131
|
+
const svg = ext?.["asvg:svgBlip"];
|
|
1132
|
+
const ref = svg?.["@_r:embed"];
|
|
1133
|
+
if (ref) return ref;
|
|
1134
|
+
}
|
|
1135
|
+
return undefined;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
649
1138
|
function parseCxn(
|
|
650
1139
|
cxn: any,
|
|
651
1140
|
ctx: ParseContext,
|
|
@@ -746,7 +1235,7 @@ function parseTable(
|
|
|
746
1235
|
}
|
|
747
1236
|
const txBody = tc["a:txBody"];
|
|
748
1237
|
const text = txBody
|
|
749
|
-
? extractRuns(txBody, ctx.theme)
|
|
1238
|
+
? extractRuns(txBody, ctx.theme, undefined, undefined, ctx.themeFonts)
|
|
750
1239
|
: { plain: "", runs: [] as RunInfo[] };
|
|
751
1240
|
cells.push(text.plain);
|
|
752
1241
|
|
|
@@ -844,11 +1333,24 @@ function lookupPlaceholder(
|
|
|
844
1333
|
map.get(`${type}|${idx}`) ??
|
|
845
1334
|
map.get(`|${idx}`) ??
|
|
846
1335
|
map.get(`${type}|`) ??
|
|
847
|
-
(
|
|
848
|
-
(type === "
|
|
1336
|
+
findByType(map, type) ??
|
|
1337
|
+
(type === "ctrTitle" ? findByType(map, "title") : undefined) ??
|
|
1338
|
+
(type === "subTitle" ? findByType(map, "body") : undefined)
|
|
849
1339
|
);
|
|
850
1340
|
}
|
|
851
1341
|
|
|
1342
|
+
function findByType(
|
|
1343
|
+
map: Map<string, PlaceholderInfo>,
|
|
1344
|
+
type: string
|
|
1345
|
+
): PlaceholderInfo | undefined {
|
|
1346
|
+
if (!type) return undefined;
|
|
1347
|
+
const prefix = `${type}|`;
|
|
1348
|
+
for (const [key, value] of map) {
|
|
1349
|
+
if (key.startsWith(prefix)) return value;
|
|
1350
|
+
}
|
|
1351
|
+
return undefined;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
852
1354
|
function placeholderGeometry(
|
|
853
1355
|
ph: PlaceholderInfo | undefined,
|
|
854
1356
|
fit: Fit,
|
|
@@ -883,36 +1385,148 @@ function extractPlaceholders(rootXml: any): Map<string, PlaceholderInfo> {
|
|
|
883
1385
|
const paragraphs = asArray(txBody?.["a:p"]);
|
|
884
1386
|
const firstP = paragraphs[0];
|
|
885
1387
|
const firstR = asArray(firstP?.["a:r"])[0];
|
|
1388
|
+
// Default run/paragraph properties live on the placeholder's
|
|
1389
|
+
// <a:lstStyle><a:lvl1pPr> (font, size, colour, etc). A stub <a:r><a:rPr/>
|
|
1390
|
+
// with just `lang` carries no real style — prefer the lstStyle defaults
|
|
1391
|
+
// over an empty inline rPr so the layout's typography reaches the slide.
|
|
1392
|
+
const lstStyle = txBody?.["a:lstStyle"];
|
|
1393
|
+
const lvl1 = lstStyle?.["a:lvl1pPr"];
|
|
1394
|
+
const lvlPPr = collectLevelPPrs(lstStyle);
|
|
1395
|
+
const stubRPr = firstR?.["a:rPr"];
|
|
886
1396
|
const info: PlaceholderInfo = {
|
|
887
1397
|
rawX: off ? emuToPx(Number(off["@_x"] ?? 0)) : undefined,
|
|
888
1398
|
rawY: off ? emuToPx(Number(off["@_y"] ?? 0)) : undefined,
|
|
889
1399
|
rawW: ext ? emuToPx(Number(ext["@_cx"] ?? 0)) : undefined,
|
|
890
1400
|
rawH: ext ? emuToPx(Number(ext["@_cy"] ?? 0)) : undefined,
|
|
891
1401
|
rotation: xfrm?.["@_rot"] ? Number(xfrm["@_rot"]) / 60000 : 0,
|
|
892
|
-
rPr:
|
|
893
|
-
|
|
1402
|
+
rPr: pickMeaningful(
|
|
1403
|
+
stubRPr,
|
|
1404
|
+
lvl1?.["a:defRPr"],
|
|
1405
|
+
firstP?.["a:pPr"]?.["a:defRPr"]
|
|
1406
|
+
),
|
|
1407
|
+
pPr: lvl1 ?? firstP?.["a:pPr"],
|
|
894
1408
|
bodyPr: txBody?.["a:bodyPr"],
|
|
1409
|
+
lvlPPr: lvlPPr.some(Boolean) ? lvlPPr : undefined,
|
|
895
1410
|
paragraphs: hasAnyText(txBody) ? paragraphs : undefined,
|
|
1411
|
+
spPr: sp?.["p:spPr"],
|
|
896
1412
|
};
|
|
897
1413
|
out.set(placeholderKey(ph), info);
|
|
898
1414
|
}
|
|
899
1415
|
return out;
|
|
900
1416
|
}
|
|
901
1417
|
|
|
1418
|
+
/**
|
|
1419
|
+
* Return the first candidate that carries actual style fields (font, size,
|
|
1420
|
+
* colour, weight, italic, underline). An empty `<a:rPr lang="en-GB"/>` looks
|
|
1421
|
+
* truthy but contributes nothing, so it shouldn't shadow a meaningful
|
|
1422
|
+
* lstStyle/defRPr further down the chain.
|
|
1423
|
+
*/
|
|
1424
|
+
function pickMeaningful(...candidates: any[]): any {
|
|
1425
|
+
for (const c of candidates) {
|
|
1426
|
+
if (!c) continue;
|
|
1427
|
+
if (rPrHasStyle(c)) return c;
|
|
1428
|
+
}
|
|
1429
|
+
// Fall back to the first defined value, even if otherwise empty, so we
|
|
1430
|
+
// never regress callers that depend on truthiness.
|
|
1431
|
+
return candidates.find((c) => c !== undefined && c !== null);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function rPrHasStyle(rPr: any): boolean {
|
|
1435
|
+
if (!rPr || typeof rPr !== "object") return false;
|
|
1436
|
+
return (
|
|
1437
|
+
rPr["@_sz"] !== undefined ||
|
|
1438
|
+
rPr["@_b"] !== undefined ||
|
|
1439
|
+
rPr["@_i"] !== undefined ||
|
|
1440
|
+
rPr["@_u"] !== undefined ||
|
|
1441
|
+
rPr["@_spc"] !== undefined ||
|
|
1442
|
+
rPr["a:latin"] !== undefined ||
|
|
1443
|
+
rPr["a:solidFill"] !== undefined
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
|
|
902
1447
|
function extractMasterTextDefaults(masterXml: any): MasterTextDefaults {
|
|
903
1448
|
const txStyles = masterXml?.["p:sldMaster"]?.["p:txStyles"];
|
|
904
1449
|
if (!txStyles) return {};
|
|
905
1450
|
return {
|
|
906
|
-
title: txStyles?.["p:titleStyle"]
|
|
907
|
-
body: txStyles?.["p:bodyStyle"]
|
|
908
|
-
other: txStyles?.["p:otherStyle"]
|
|
1451
|
+
title: collectLevelPPrs(txStyles?.["p:titleStyle"]),
|
|
1452
|
+
body: collectLevelPPrs(txStyles?.["p:bodyStyle"]),
|
|
1453
|
+
other: collectLevelPPrs(txStyles?.["p:otherStyle"]),
|
|
909
1454
|
};
|
|
910
1455
|
}
|
|
911
1456
|
|
|
1457
|
+
function collectLevelPPrs(style: any): (any | undefined)[] {
|
|
1458
|
+
if (!style) return [];
|
|
1459
|
+
const out: (any | undefined)[] = [];
|
|
1460
|
+
for (let lvl = 1; lvl <= 9; lvl++) {
|
|
1461
|
+
out.push(style[`a:lvl${lvl}pPr`]);
|
|
1462
|
+
}
|
|
1463
|
+
return out;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
912
1466
|
// ---------------------------------------------------------------------------
|
|
913
1467
|
// theme
|
|
914
1468
|
// ---------------------------------------------------------------------------
|
|
915
1469
|
|
|
1470
|
+
function extractThemeFonts(themeXml: any): ThemeFonts {
|
|
1471
|
+
const fs = themeXml?.["a:theme"]?.["a:themeElements"]?.["a:fontScheme"];
|
|
1472
|
+
return {
|
|
1473
|
+
majorLatin: fs?.["a:majorFont"]?.["a:latin"]?.["@_typeface"] || undefined,
|
|
1474
|
+
minorLatin: fs?.["a:minorFont"]?.["a:latin"]?.["@_typeface"] || undefined,
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function extractClrMap(node: any): ClrMap {
|
|
1479
|
+
if (!node) return DEFAULT_CLR_MAP;
|
|
1480
|
+
const pick = (attr: string, fallback: ClrMap[keyof ClrMap]) => {
|
|
1481
|
+
const v = node[`@_${attr}`];
|
|
1482
|
+
return isThemeKey(v) ? (v as ClrMap[keyof ClrMap]) : fallback;
|
|
1483
|
+
};
|
|
1484
|
+
return {
|
|
1485
|
+
bg1: pick("bg1", DEFAULT_CLR_MAP.bg1),
|
|
1486
|
+
bg2: pick("bg2", DEFAULT_CLR_MAP.bg2),
|
|
1487
|
+
tx1: pick("tx1", DEFAULT_CLR_MAP.tx1),
|
|
1488
|
+
tx2: pick("tx2", DEFAULT_CLR_MAP.tx2),
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const THEME_KEYS = new Set([
|
|
1493
|
+
"dk1",
|
|
1494
|
+
"lt1",
|
|
1495
|
+
"dk2",
|
|
1496
|
+
"lt2",
|
|
1497
|
+
"accent1",
|
|
1498
|
+
"accent2",
|
|
1499
|
+
"accent3",
|
|
1500
|
+
"accent4",
|
|
1501
|
+
"accent5",
|
|
1502
|
+
"accent6",
|
|
1503
|
+
"hlink",
|
|
1504
|
+
"folHlink",
|
|
1505
|
+
]);
|
|
1506
|
+
|
|
1507
|
+
function isThemeKey(v: unknown): v is keyof ThemeColors {
|
|
1508
|
+
return typeof v === "string" && THEME_KEYS.has(v);
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
/**
|
|
1512
|
+
* Resolve OOXML major/minor font tokens (`+mj-lt`, `+mn-lt`, …) against the
|
|
1513
|
+
* theme's font scheme. Returns the original value when it isn't a token, and
|
|
1514
|
+
* `undefined` when the token can't be resolved.
|
|
1515
|
+
*/
|
|
1516
|
+
function resolveFontFamily(
|
|
1517
|
+
raw: string | undefined,
|
|
1518
|
+
fonts: ThemeFonts
|
|
1519
|
+
): string | undefined {
|
|
1520
|
+
if (!raw) return undefined;
|
|
1521
|
+
if (raw === "+mj-lt" || raw === "+mj-ea" || raw === "+mj-cs") {
|
|
1522
|
+
return fonts.majorLatin;
|
|
1523
|
+
}
|
|
1524
|
+
if (raw === "+mn-lt" || raw === "+mn-ea" || raw === "+mn-cs") {
|
|
1525
|
+
return fonts.minorLatin;
|
|
1526
|
+
}
|
|
1527
|
+
return raw;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
916
1530
|
function extractTheme(themeXml: any): ThemeColors {
|
|
917
1531
|
const scheme =
|
|
918
1532
|
themeXml?.["a:theme"]?.["a:themeElements"]?.["a:clrScheme"] ?? {};
|
|
@@ -1020,23 +1634,13 @@ function readBaseHex(node: any, theme: ThemeColors): string | undefined {
|
|
|
1020
1634
|
}
|
|
1021
1635
|
|
|
1022
1636
|
function resolveSchemeToken(token: string, theme: ThemeColors): string {
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
return theme.lt2;
|
|
1028
|
-
case "tx1":
|
|
1029
|
-
return theme.dk1;
|
|
1030
|
-
case "tx2":
|
|
1031
|
-
return theme.dk2;
|
|
1032
|
-
case "phClr":
|
|
1033
|
-
// Placeholder color sentinel — best-effort fallback.
|
|
1034
|
-
return theme.dk1;
|
|
1035
|
-
default: {
|
|
1036
|
-
const v = (theme as unknown as Record<string, string>)[token];
|
|
1037
|
-
return v ?? "#000000";
|
|
1038
|
-
}
|
|
1637
|
+
if (token === "phClr") {
|
|
1638
|
+
// Placeholder colour sentinel — caller (substitutePhClr) should already
|
|
1639
|
+
// have replaced this; surface a sensible fallback if it didn't.
|
|
1640
|
+
return theme.dk1;
|
|
1039
1641
|
}
|
|
1642
|
+
const v = (theme as unknown as Record<string, string>)[token];
|
|
1643
|
+
return v ?? "#000000";
|
|
1040
1644
|
}
|
|
1041
1645
|
|
|
1042
1646
|
function resolvePresetColor(name: string): string {
|
|
@@ -1150,6 +1754,33 @@ function extractShapeFill(spPr: any, theme: ThemeColors): string | undefined {
|
|
|
1150
1754
|
(s) => s.color.length === 9 && s.color.endsWith("00")
|
|
1151
1755
|
);
|
|
1152
1756
|
if (allTransparent) return "transparent";
|
|
1757
|
+
// Radial / path gradient: <a:path path="circle|rect|shape"> with
|
|
1758
|
+
// <a:fillToRect> giving the focus rectangle. l/t/r/b are percentage
|
|
1759
|
+
// insets from each edge of the shape; the rect's centre is the focus
|
|
1760
|
+
// point. OOXML radial stops use the SAME convention as CSS — pos=0 at
|
|
1761
|
+
// the centre, pos=100% at the outer boundary — so we keep the stop
|
|
1762
|
+
// positions verbatim. The visual blob lands where fillToRect sits;
|
|
1763
|
+
// on a tall, narrow panel the same radial reads almost vertical, on a
|
|
1764
|
+
// 16:9 slide it reads as the expected red orb fading to purple.
|
|
1765
|
+
const pathNode = gf["a:path"];
|
|
1766
|
+
if (pathNode) {
|
|
1767
|
+
const pathType = pathNode["@_path"];
|
|
1768
|
+
const ftr = pathNode["a:fillToRect"];
|
|
1769
|
+
const lIn = Number(ftr?.["@_l"] ?? 0) / 1000;
|
|
1770
|
+
const tIn = Number(ftr?.["@_t"] ?? 0) / 1000;
|
|
1771
|
+
const rIn = Number(ftr?.["@_r"] ?? 0) / 1000;
|
|
1772
|
+
const bIn = Number(ftr?.["@_b"] ?? 0) / 1000;
|
|
1773
|
+
const focusX = clampPct((lIn + (100 - rIn)) / 2);
|
|
1774
|
+
const focusY = clampPct((tIn + (100 - bIn)) / 2);
|
|
1775
|
+
// path="circle" — a true geometric circle in CSS terms (so the blob
|
|
1776
|
+
// stays round on rectangular shapes); path="rect" — anisotropic
|
|
1777
|
+
// ellipse stretched with the shape's aspect ratio.
|
|
1778
|
+
const shape = pathType === "circle" ? "circle" : "ellipse";
|
|
1779
|
+
const stopsCss = stops
|
|
1780
|
+
.map((s) => `${s.color} ${s.pos.toFixed(2)}%`)
|
|
1781
|
+
.join(", ");
|
|
1782
|
+
return `radial-gradient(${shape} at ${focusX.toFixed(2)}% ${focusY.toFixed(2)}%, ${stopsCss})`;
|
|
1783
|
+
}
|
|
1153
1784
|
const angDeg = gf["a:lin"]?.["@_ang"]
|
|
1154
1785
|
? (Number(gf["a:lin"]["@_ang"]) / 60000 + 90) % 360
|
|
1155
1786
|
: 90;
|
|
@@ -1159,11 +1790,185 @@ function extractShapeFill(spPr: any, theme: ThemeColors): string | undefined {
|
|
|
1159
1790
|
return undefined;
|
|
1160
1791
|
}
|
|
1161
1792
|
|
|
1162
|
-
function
|
|
1163
|
-
return
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1793
|
+
function clampPct(v: number): number {
|
|
1794
|
+
if (!Number.isFinite(v)) return 0;
|
|
1795
|
+
if (v < 0) return 0;
|
|
1796
|
+
if (v > 100) return 100;
|
|
1797
|
+
return v;
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
async function extractBackground(
|
|
1801
|
+
bg: any,
|
|
1802
|
+
ctx: ParseContext,
|
|
1803
|
+
rels: Rels,
|
|
1804
|
+
basePath: string
|
|
1805
|
+
): Promise<string | undefined> {
|
|
1806
|
+
if (!bg) return undefined;
|
|
1807
|
+
const bgPr = bg["p:bgPr"];
|
|
1808
|
+
if (bgPr) {
|
|
1809
|
+
if (bgPr["a:noFill"] !== undefined) return "transparent";
|
|
1810
|
+
const solid = resolveColor(bgPr["a:solidFill"], ctx.theme);
|
|
1811
|
+
if (solid) return solid;
|
|
1812
|
+
const grad = extractShapeFill({ "a:gradFill": bgPr["a:gradFill"] }, ctx.theme);
|
|
1813
|
+
if (grad) return grad;
|
|
1814
|
+
const blip = bgPr["a:blipFill"]?.["a:blip"];
|
|
1815
|
+
if (blip) {
|
|
1816
|
+
const url = await blipDataUrl(blip, ctx, rels, basePath);
|
|
1817
|
+
if (url) return `center / cover no-repeat url("${url}")`;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
const bgRef = bg["p:bgRef"];
|
|
1821
|
+
if (bgRef) return resolveBgRef(bgRef, ctx);
|
|
1822
|
+
return undefined;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
async function blipDataUrl(
|
|
1826
|
+
blip: any,
|
|
1827
|
+
ctx: ParseContext,
|
|
1828
|
+
rels: Rels,
|
|
1829
|
+
basePath: string
|
|
1830
|
+
): Promise<string | undefined> {
|
|
1831
|
+
// Prefer the vector embed when present (dual-blip SVG pattern); fall
|
|
1832
|
+
// back to the raster.
|
|
1833
|
+
const svgRef = findSvgBlipRef(blip);
|
|
1834
|
+
const rid = svgRef ?? blip?.["@_r:embed"];
|
|
1835
|
+
if (!rid) return undefined;
|
|
1836
|
+
const target = rels.byId.get(rid)?.target;
|
|
1837
|
+
if (!target) return undefined;
|
|
1838
|
+
const full = normalisePath(target, dirOf(basePath));
|
|
1839
|
+
const file = ctx.zip.file(full);
|
|
1840
|
+
if (!file) return undefined;
|
|
1841
|
+
const base64 = await file.async("base64");
|
|
1842
|
+
const ext = (full.split(".").pop() || "png").toLowerCase();
|
|
1843
|
+
return `data:${mimeForExt(ext)};base64,${base64}`;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
/**
|
|
1847
|
+
* Resolve <p:bgRef idx="..."> against the theme fill lists. idx 1001..1003
|
|
1848
|
+
* indexes a:bgFillStyleLst; idx 1+ indexes a:fillStyleLst (rarely used for
|
|
1849
|
+
* backgrounds). The <p:bgRef> also carries a color child (e.g. schemeClr) that
|
|
1850
|
+
* fills in any <a:schemeClr val="phClr"> placeholders inside the theme fill.
|
|
1851
|
+
*/
|
|
1852
|
+
function resolveBgRef(bgRef: any, ctx: ParseContext): string | undefined {
|
|
1853
|
+
const rawIdx = bgRef?.["@_idx"];
|
|
1854
|
+
if (rawIdx === undefined) return undefined;
|
|
1855
|
+
const idx = Number(rawIdx);
|
|
1856
|
+
if (!Number.isFinite(idx)) return undefined;
|
|
1857
|
+
const list = idx >= 1000 ? ctx.themeFills.bg : ctx.themeFills.fg;
|
|
1858
|
+
const entry = list[(idx >= 1000 ? idx - 1001 : idx - 1)];
|
|
1859
|
+
if (!entry) return undefined;
|
|
1860
|
+
const phColor = readBaseHex(bgRef, ctx.theme);
|
|
1861
|
+
if (entry.kind === "solidFill") {
|
|
1862
|
+
const node = substitutePhClr(entry.node, phColor);
|
|
1863
|
+
return resolveColor(node, ctx.theme);
|
|
1864
|
+
}
|
|
1865
|
+
if (entry.kind === "gradFill") {
|
|
1866
|
+
const node = substitutePhClr(entry.node, phColor);
|
|
1867
|
+
return extractShapeFill({ "a:gradFill": node }, ctx.theme);
|
|
1868
|
+
}
|
|
1869
|
+
// blipFill / pattFill / noFill — not modelled as a slide background yet.
|
|
1870
|
+
if (entry.kind === "noFill") return "transparent";
|
|
1871
|
+
return undefined;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
/**
|
|
1875
|
+
* Replace <a:schemeClr val="phClr"> placeholders inside a theme fill template
|
|
1876
|
+
* with the caller's actual color, so the modifier chain (lumMod, shade, etc.)
|
|
1877
|
+
* still applies. Returns a shallow-cloned tree.
|
|
1878
|
+
*/
|
|
1879
|
+
function substitutePhClr(node: any, phHex: string | undefined): any {
|
|
1880
|
+
if (!phHex) return node;
|
|
1881
|
+
const replacement = phHex.startsWith("#") ? phHex.slice(1) : phHex;
|
|
1882
|
+
const walk = (n: any): any => {
|
|
1883
|
+
if (n == null || typeof n !== "object") return n;
|
|
1884
|
+
if (Array.isArray(n)) return n.map(walk);
|
|
1885
|
+
const out: any = {};
|
|
1886
|
+
for (const k of Object.keys(n)) {
|
|
1887
|
+
if (k === "a:schemeClr" && n[k]?.["@_val"] === "phClr") {
|
|
1888
|
+
const mods: any = { ...n[k] };
|
|
1889
|
+
delete mods["@_val"];
|
|
1890
|
+
out["a:srgbClr"] = { ...mods, "@_val": replacement };
|
|
1891
|
+
} else {
|
|
1892
|
+
out[k] = walk(n[k]);
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
return out;
|
|
1896
|
+
};
|
|
1897
|
+
return walk(node);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
function extractThemeFills(themeXml: any, themeRaw: string): ThemeFills {
|
|
1901
|
+
const fmt = themeXml?.["a:theme"]?.["a:themeElements"]?.["a:fmtScheme"];
|
|
1902
|
+
if (!fmt) return { bg: [], fg: [] };
|
|
1903
|
+
const build = (blockTag: string, parsed: any): ThemeFill[] => {
|
|
1904
|
+
const kinds = extractDirectChildOrder(themeRaw, blockTag);
|
|
1905
|
+
if (!kinds.length || !parsed) return [];
|
|
1906
|
+
const buckets: Record<string, any[]> = {};
|
|
1907
|
+
for (const kind of new Set(kinds)) {
|
|
1908
|
+
buckets[kind] = asArray(parsed[`a:${kind}`]).slice();
|
|
1909
|
+
}
|
|
1910
|
+
const out: ThemeFill[] = [];
|
|
1911
|
+
for (const kind of kinds) {
|
|
1912
|
+
const node = buckets[kind]?.shift();
|
|
1913
|
+
if (node !== undefined) out.push({ kind: kind as ThemeFill["kind"], node });
|
|
1914
|
+
}
|
|
1915
|
+
return out;
|
|
1916
|
+
};
|
|
1917
|
+
return {
|
|
1918
|
+
bg: build("bgFillStyleLst", fmt["a:bgFillStyleLst"]),
|
|
1919
|
+
fg: build("fillStyleLst", fmt["a:fillStyleLst"]),
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* Returns the local tag names of direct children of the first <a:{blockTag}>
|
|
1925
|
+
* in document order. Used to recover the cross-tag-type order that
|
|
1926
|
+
* fast-xml-parser drops when it groups by element name.
|
|
1927
|
+
*/
|
|
1928
|
+
function extractDirectChildOrder(rawXml: string, blockTag: string): string[] {
|
|
1929
|
+
const openRe = new RegExp(`<a:${blockTag}\\b[^>]*>`);
|
|
1930
|
+
const close = `</a:${blockTag}>`;
|
|
1931
|
+
const m = openRe.exec(rawXml);
|
|
1932
|
+
if (!m) return [];
|
|
1933
|
+
const start = m.index + m[0].length;
|
|
1934
|
+
const end = rawXml.indexOf(close, start);
|
|
1935
|
+
if (end < 0) return [];
|
|
1936
|
+
const inner = rawXml.slice(start, end);
|
|
1937
|
+
const tags: string[] = [];
|
|
1938
|
+
let depth = 0;
|
|
1939
|
+
let i = 0;
|
|
1940
|
+
while (i < inner.length) {
|
|
1941
|
+
if (inner[i] !== "<") {
|
|
1942
|
+
i++;
|
|
1943
|
+
continue;
|
|
1944
|
+
}
|
|
1945
|
+
if (inner.startsWith("<!--", i)) {
|
|
1946
|
+
const j = inner.indexOf("-->", i);
|
|
1947
|
+
if (j < 0) break;
|
|
1948
|
+
i = j + 3;
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
if (inner.startsWith("<?", i)) {
|
|
1952
|
+
const j = inner.indexOf("?>", i);
|
|
1953
|
+
if (j < 0) break;
|
|
1954
|
+
i = j + 2;
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
const closeBracket = inner.indexOf(">", i);
|
|
1958
|
+
if (closeBracket < 0) break;
|
|
1959
|
+
const tag = inner.slice(i, closeBracket + 1);
|
|
1960
|
+
if (tag.startsWith("</")) {
|
|
1961
|
+
depth--;
|
|
1962
|
+
i = closeBracket + 1;
|
|
1963
|
+
continue;
|
|
1964
|
+
}
|
|
1965
|
+
const isSelfClose = tag.endsWith("/>");
|
|
1966
|
+
const nameMatch = /^<a:([\w]+)/.exec(tag);
|
|
1967
|
+
if (depth === 0 && nameMatch) tags.push(nameMatch[1]);
|
|
1968
|
+
if (!isSelfClose) depth++;
|
|
1969
|
+
i = closeBracket + 1;
|
|
1970
|
+
}
|
|
1971
|
+
return tags;
|
|
1167
1972
|
}
|
|
1168
1973
|
|
|
1169
1974
|
function readBodyVAlign(bodyPr: any): "top" | "middle" | "bottom" | undefined {
|
|
@@ -1198,7 +2003,10 @@ function extractRuns(
|
|
|
1198
2003
|
txBody: any,
|
|
1199
2004
|
theme: ThemeColors,
|
|
1200
2005
|
fallbackRPr?: any,
|
|
1201
|
-
fallbackPPr?: any
|
|
2006
|
+
fallbackPPr?: any,
|
|
2007
|
+
themeFonts: ThemeFonts = {},
|
|
2008
|
+
listStyle: (any | undefined)[][] = [],
|
|
2009
|
+
autoFit?: AutoFit
|
|
1202
2010
|
): {
|
|
1203
2011
|
runs: RunInfo[];
|
|
1204
2012
|
plain: string;
|
|
@@ -1210,57 +2018,92 @@ function extractRuns(
|
|
|
1210
2018
|
let lineHeightPct: number | undefined;
|
|
1211
2019
|
const paragraphs = asArray(txBody?.["a:p"]);
|
|
1212
2020
|
const pieces: string[] = [];
|
|
2021
|
+
const autoNumCounters = new Map<number, number>();
|
|
2022
|
+
let prevAutoKey: string | undefined;
|
|
1213
2023
|
|
|
1214
2024
|
for (let pi = 0; pi < paragraphs.length; pi++) {
|
|
1215
2025
|
const p = paragraphs[pi];
|
|
1216
2026
|
const pPr = p?.["a:pPr"];
|
|
2027
|
+
const lvl = clampLevel(Number(pPr?.["@_lvl"] ?? 0));
|
|
2028
|
+
const levelChain = listStyle[lvl] ?? [];
|
|
2029
|
+
const findLevel = (k: string): any =>
|
|
2030
|
+
[pPr, ...levelChain, fallbackPPr].find((s) => s?.[k] !== undefined);
|
|
1217
2031
|
if (!align) {
|
|
1218
|
-
align =
|
|
2032
|
+
align =
|
|
2033
|
+
readAlign(pPr) ??
|
|
2034
|
+
readAlign(levelChain.find((s) => s?.["@_algn"])) ??
|
|
2035
|
+
readAlign(fallbackPPr);
|
|
1219
2036
|
}
|
|
1220
2037
|
if (lineHeightPct === undefined) {
|
|
1221
|
-
const
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
2038
|
+
const src = findLevel("a:lnSpc");
|
|
2039
|
+
const lnPct = src?.["a:lnSpc"]?.["a:spcPct"]?.["@_val"];
|
|
2040
|
+
if (lnPct) {
|
|
2041
|
+
const base = Number(lnPct) / 100000;
|
|
2042
|
+
const reduction = autoFit?.lnSpcReduction ?? 0;
|
|
2043
|
+
lineHeightPct = base * (1 - reduction);
|
|
2044
|
+
}
|
|
1225
2045
|
}
|
|
2046
|
+
|
|
2047
|
+
// Resolve bullet across the inheritance chain (slide pPr > layout > master
|
|
2048
|
+
// placeholder > master txStyles). Each layer can specify just the bullet
|
|
2049
|
+
// without overriding everything else.
|
|
2050
|
+
const bullet = resolveBullet([pPr, ...levelChain]);
|
|
2051
|
+
const prefix = computeBulletPrefix(
|
|
2052
|
+
bullet,
|
|
2053
|
+
lvl,
|
|
2054
|
+
autoNumCounters,
|
|
2055
|
+
prevAutoKey
|
|
2056
|
+
);
|
|
2057
|
+
prevAutoKey = prefix.autoKey;
|
|
2058
|
+
|
|
1226
2059
|
const rs = asArray(p?.["a:r"]);
|
|
2060
|
+
const flds = asArray(p?.["a:fld"]);
|
|
2061
|
+
const paraStart = runs.length;
|
|
1227
2062
|
const paragraphText: string[] = [];
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
const
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
const
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
2063
|
+
if (prefix.text) paragraphText.push(prefix.text);
|
|
2064
|
+
|
|
2065
|
+
const onRun = (r: any) => {
|
|
2066
|
+
const built = buildRunInfo(r, theme, themeFonts, fallbackRPr);
|
|
2067
|
+
if (autoFit?.fontScale && built.run.fontSize) {
|
|
2068
|
+
built.run.fontSize *= autoFit.fontScale;
|
|
2069
|
+
}
|
|
2070
|
+
runs.push(built.run);
|
|
2071
|
+
paragraphText.push(built.text);
|
|
2072
|
+
};
|
|
2073
|
+
|
|
2074
|
+
// <a:br/> hard line breaks AND <a:fld> field placeholders (e.g.
|
|
2075
|
+
// datetime1, slidenum) are siblings of <a:r>; when any are present
|
|
2076
|
+
// walk the paragraph children in document order so they land in the
|
|
2077
|
+
// right place. Otherwise stay on the fast path.
|
|
2078
|
+
if (p?.["a:br"] || p?.["a:fld"]) {
|
|
2079
|
+
const order = paragraphChildOrder(p);
|
|
2080
|
+
for (const entry of order) {
|
|
2081
|
+
if (entry.kind === "br") {
|
|
2082
|
+
if (runs.length) runs[runs.length - 1].text += "\n";
|
|
2083
|
+
paragraphText.push("\n");
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
const source = entry.kind === "r" ? rs : flds;
|
|
2087
|
+
const r = source[entry.index];
|
|
2088
|
+
if (r) onRun(r);
|
|
2089
|
+
}
|
|
2090
|
+
} else {
|
|
2091
|
+
for (const r of rs) onRun(r);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
// Prepend the bullet prefix to the first run of this paragraph so it
|
|
2095
|
+
// survives renderers that walk `runs` instead of the joined `plain` text.
|
|
2096
|
+
if (prefix.text && runs.length > paraStart) {
|
|
2097
|
+
runs[paraStart].text = prefix.text + runs[paraStart].text;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// Carry the inter-paragraph break onto the last run we just emitted —
|
|
2101
|
+
// renderers that walk `runs` (mixed-formatting path) would otherwise
|
|
2102
|
+
// concatenate paragraphs into one long line.
|
|
2103
|
+
if (pi < paragraphs.length - 1 && runs.length > 0) {
|
|
2104
|
+
runs[runs.length - 1].text += "\n";
|
|
1263
2105
|
}
|
|
2106
|
+
|
|
1264
2107
|
pieces.push(paragraphText.join(""));
|
|
1265
2108
|
}
|
|
1266
2109
|
return {
|
|
@@ -1271,11 +2114,419 @@ function extractRuns(
|
|
|
1271
2114
|
};
|
|
1272
2115
|
}
|
|
1273
2116
|
|
|
2117
|
+
interface AutoFit {
|
|
2118
|
+
fontScale?: number; // 0..1
|
|
2119
|
+
lnSpcReduction?: number; // 0..1
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
function readNormAutofit(bodyPr: any): AutoFit | undefined {
|
|
2123
|
+
const af = bodyPr?.["a:normAutofit"];
|
|
2124
|
+
if (!af) return undefined;
|
|
2125
|
+
const fontScale = af["@_fontScale"] ? Number(af["@_fontScale"]) / 100000 : undefined;
|
|
2126
|
+
const lnSpcReduction = af["@_lnSpcReduction"]
|
|
2127
|
+
? Number(af["@_lnSpcReduction"]) / 100000
|
|
2128
|
+
: undefined;
|
|
2129
|
+
if (fontScale === undefined && lnSpcReduction === undefined) return undefined;
|
|
2130
|
+
return { fontScale, lnSpcReduction };
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
interface ResolvedBullet {
|
|
2134
|
+
kind: "none" | "char" | "auto";
|
|
2135
|
+
char?: string;
|
|
2136
|
+
autoType?: string;
|
|
2137
|
+
autoStartAt?: number;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
function resolveBullet(sources: (any | undefined)[]): ResolvedBullet {
|
|
2141
|
+
// First source that defines any of buNone/buChar/buAutoNum wins.
|
|
2142
|
+
for (const src of sources) {
|
|
2143
|
+
if (!src) continue;
|
|
2144
|
+
if (src["a:buNone"] !== undefined) return { kind: "none" };
|
|
2145
|
+
if (src["a:buChar"]?.["@_char"])
|
|
2146
|
+
return { kind: "char", char: String(src["a:buChar"]["@_char"]) };
|
|
2147
|
+
if (src["a:buAutoNum"]) {
|
|
2148
|
+
return {
|
|
2149
|
+
kind: "auto",
|
|
2150
|
+
autoType: src["a:buAutoNum"]["@_type"] ?? "arabicPeriod",
|
|
2151
|
+
autoStartAt: src["a:buAutoNum"]["@_startAt"]
|
|
2152
|
+
? Number(src["a:buAutoNum"]["@_startAt"])
|
|
2153
|
+
: 1,
|
|
2154
|
+
};
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
return { kind: "none" };
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
function computeBulletPrefix(
|
|
2161
|
+
bullet: ResolvedBullet,
|
|
2162
|
+
level: number,
|
|
2163
|
+
counters: Map<number, number>,
|
|
2164
|
+
prevAutoKey: string | undefined
|
|
2165
|
+
): { text: string; autoKey: string | undefined } {
|
|
2166
|
+
const indent = " ".repeat(level);
|
|
2167
|
+
if (bullet.kind === "none") {
|
|
2168
|
+
// Drop the counter so a later run of <a:buAutoNum> restarts at 1 even at
|
|
2169
|
+
// the same level.
|
|
2170
|
+
counters.delete(level);
|
|
2171
|
+
return { text: "", autoKey: undefined };
|
|
2172
|
+
}
|
|
2173
|
+
if (bullet.kind === "char") {
|
|
2174
|
+
counters.delete(level);
|
|
2175
|
+
return {
|
|
2176
|
+
text: `${indent}${bullet.char ?? "•"} `,
|
|
2177
|
+
autoKey: undefined,
|
|
2178
|
+
};
|
|
2179
|
+
}
|
|
2180
|
+
const key = `auto|${level}|${bullet.autoType ?? "arabicPeriod"}`;
|
|
2181
|
+
const continuing = key === prevAutoKey;
|
|
2182
|
+
const next = continuing
|
|
2183
|
+
? (counters.get(level) ?? bullet.autoStartAt ?? 1) + 1
|
|
2184
|
+
: bullet.autoStartAt ?? 1;
|
|
2185
|
+
counters.set(level, next);
|
|
2186
|
+
return {
|
|
2187
|
+
text: `${indent}${formatAutoNum(next, bullet.autoType ?? "arabicPeriod")} `,
|
|
2188
|
+
autoKey: key,
|
|
2189
|
+
};
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
function formatAutoNum(n: number, type: string): string {
|
|
2193
|
+
switch (type) {
|
|
2194
|
+
case "arabicPlain":
|
|
2195
|
+
return `${n}`;
|
|
2196
|
+
case "arabicParenR":
|
|
2197
|
+
return `${n})`;
|
|
2198
|
+
case "arabicParenBoth":
|
|
2199
|
+
return `(${n})`;
|
|
2200
|
+
case "arabicPeriod":
|
|
2201
|
+
return `${n}.`;
|
|
2202
|
+
case "alphaUcPeriod":
|
|
2203
|
+
return `${toAlpha(n).toUpperCase()}.`;
|
|
2204
|
+
case "alphaLcPeriod":
|
|
2205
|
+
return `${toAlpha(n)}.`;
|
|
2206
|
+
case "romanUcPeriod":
|
|
2207
|
+
return `${toRoman(n).toUpperCase()}.`;
|
|
2208
|
+
case "romanLcPeriod":
|
|
2209
|
+
return `${toRoman(n)}.`;
|
|
2210
|
+
default:
|
|
2211
|
+
return `${n}.`;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function toAlpha(n: number): string {
|
|
2216
|
+
let s = "";
|
|
2217
|
+
let v = n;
|
|
2218
|
+
while (v > 0) {
|
|
2219
|
+
const r = (v - 1) % 26;
|
|
2220
|
+
s = String.fromCharCode(97 + r) + s;
|
|
2221
|
+
v = Math.floor((v - 1) / 26);
|
|
2222
|
+
}
|
|
2223
|
+
return s || "a";
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
function toRoman(n: number): string {
|
|
2227
|
+
if (n <= 0) return "";
|
|
2228
|
+
const pairs: [number, string][] = [
|
|
2229
|
+
[1000, "m"],
|
|
2230
|
+
[900, "cm"],
|
|
2231
|
+
[500, "d"],
|
|
2232
|
+
[400, "cd"],
|
|
2233
|
+
[100, "c"],
|
|
2234
|
+
[90, "xc"],
|
|
2235
|
+
[50, "l"],
|
|
2236
|
+
[40, "xl"],
|
|
2237
|
+
[10, "x"],
|
|
2238
|
+
[9, "ix"],
|
|
2239
|
+
[5, "v"],
|
|
2240
|
+
[4, "iv"],
|
|
2241
|
+
[1, "i"],
|
|
2242
|
+
];
|
|
2243
|
+
let out = "";
|
|
2244
|
+
let v = n;
|
|
2245
|
+
for (const [val, sym] of pairs) {
|
|
2246
|
+
while (v >= val) {
|
|
2247
|
+
out += sym;
|
|
2248
|
+
v -= val;
|
|
2249
|
+
}
|
|
2250
|
+
}
|
|
2251
|
+
return out;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
function clampLevel(n: number): number {
|
|
2255
|
+
if (!Number.isFinite(n) || n < 0) return 0;
|
|
2256
|
+
if (n > 8) return 8;
|
|
2257
|
+
return Math.floor(n);
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
function buildRunInfo(
|
|
2261
|
+
r: any,
|
|
2262
|
+
theme: ThemeColors,
|
|
2263
|
+
themeFonts: ThemeFonts,
|
|
2264
|
+
fallbackRPr: any
|
|
2265
|
+
): { run: RunInfo; text: string } {
|
|
2266
|
+
const t = r?.["a:t"];
|
|
2267
|
+
const rPr = r?.["a:rPr"] ?? {};
|
|
2268
|
+
const text = typeof t === "string" ? t : t?.["#text"] ?? "";
|
|
2269
|
+
const spcRaw = rPr?.["@_spc"] ?? fallbackRPr?.["@_spc"];
|
|
2270
|
+
const letterSpacing =
|
|
2271
|
+
spcRaw !== undefined && spcRaw !== ""
|
|
2272
|
+
? pointsToPx(Number(spcRaw) / 100)
|
|
2273
|
+
: undefined;
|
|
2274
|
+
const fontSize =
|
|
2275
|
+
rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]
|
|
2276
|
+
? pointsToPx(Number(rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]) / 100)
|
|
2277
|
+
: undefined;
|
|
2278
|
+
const rawFontFamily =
|
|
2279
|
+
rPr?.["a:latin"]?.["@_typeface"] ??
|
|
2280
|
+
fallbackRPr?.["a:latin"]?.["@_typeface"];
|
|
2281
|
+
const fontFamily = resolveFontFamily(rawFontFamily, themeFonts);
|
|
2282
|
+
const color =
|
|
2283
|
+
resolveColor(rPr?.["a:solidFill"], theme) ??
|
|
2284
|
+
resolveColor(fallbackRPr?.["a:solidFill"], theme);
|
|
2285
|
+
const boldVal = rPr?.["@_b"] ?? fallbackRPr?.["@_b"];
|
|
2286
|
+
const italicVal = rPr?.["@_i"] ?? fallbackRPr?.["@_i"];
|
|
2287
|
+
const underlineVal = rPr?.["@_u"] ?? fallbackRPr?.["@_u"];
|
|
2288
|
+
const strikeVal = rPr?.["@_strike"] ?? fallbackRPr?.["@_strike"];
|
|
2289
|
+
return {
|
|
2290
|
+
text,
|
|
2291
|
+
run: {
|
|
2292
|
+
text,
|
|
2293
|
+
fontFamily,
|
|
2294
|
+
fontSize,
|
|
2295
|
+
bold: boldVal === "1" || boldVal === 1,
|
|
2296
|
+
italic: italicVal === "1" || italicVal === 1,
|
|
2297
|
+
underline: !!(underlineVal && underlineVal !== "none"),
|
|
2298
|
+
strike: strikeVal === "sngStrike",
|
|
2299
|
+
color,
|
|
2300
|
+
letterSpacing,
|
|
2301
|
+
},
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
/**
|
|
2306
|
+
* Returns the ordered list of <a:r>/<a:br> direct children of a paragraph
|
|
2307
|
+
* with each entry's running index into the corresponding array on the parsed
|
|
2308
|
+
* paragraph. The order is recovered from the paragraph's raw XML (attached
|
|
2309
|
+
* during readXml) because fast-xml-parser groups children by tag name.
|
|
2310
|
+
*/
|
|
2311
|
+
function paragraphChildOrder(
|
|
2312
|
+
p: any
|
|
2313
|
+
): { kind: "r" | "br" | "fld"; index: number }[] {
|
|
2314
|
+
const raw = (p as any)?._rawSrc as string | undefined;
|
|
2315
|
+
if (raw) return paragraphChildOrderFromRaw(raw);
|
|
2316
|
+
// No raw available (paragraph has no <a:br/> and pre-PR-2 readXml didn't
|
|
2317
|
+
// attach it). Fall back to the order implied by the parsed arrays: all
|
|
2318
|
+
// runs, then all fields. Document order across these tag types is lost
|
|
2319
|
+
// by fast-xml-parser, but the typical PPTX paragraph has either runs or
|
|
2320
|
+
// a field, not both, so this matches reality in practice.
|
|
2321
|
+
const out: { kind: "r" | "br" | "fld"; index: number }[] = [];
|
|
2322
|
+
asArray(p?.["a:r"]).forEach((_, i) => out.push({ kind: "r", index: i }));
|
|
2323
|
+
asArray(p?.["a:fld"]).forEach((_, i) => out.push({ kind: "fld", index: i }));
|
|
2324
|
+
return out;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
/**
|
|
2328
|
+
* Convert a PPTX `<a:custGeom>` (custom geometry — used for logos, brand
|
|
2329
|
+
* marks, hand-drawn shapes) into an SVG path. Reads command order from the
|
|
2330
|
+
* raw XML attached during readXml, since fast-xml-parser groups children by
|
|
2331
|
+
* tag name and drops cross-tag document order. Supports moveTo, lnTo,
|
|
2332
|
+
* cubicBezTo, quadBezTo, and close; arcTo and formula-based guide
|
|
2333
|
+
* references aren't translated yet (they degrade to a flat-fill rect).
|
|
2334
|
+
*/
|
|
2335
|
+
function parseCustGeomPath(custGeom: any): ShapePath | undefined {
|
|
2336
|
+
const raw = (custGeom as any)?._rawSrc as string | undefined;
|
|
2337
|
+
if (!raw) return undefined;
|
|
2338
|
+
// Each <a:path w="…" h="…"> defines its own coordinate system; the SVG
|
|
2339
|
+
// viewBox uses the FIRST path's dimensions and subsequent paths inherit
|
|
2340
|
+
// it. In practice almost every custGeom in real decks uses one viewbox
|
|
2341
|
+
// across all sub-paths.
|
|
2342
|
+
const paths = findAllElementRawBlocks(raw, "path");
|
|
2343
|
+
if (!paths.length) return undefined;
|
|
2344
|
+
let viewW = 0;
|
|
2345
|
+
let viewH = 0;
|
|
2346
|
+
let d = "";
|
|
2347
|
+
for (const block of paths) {
|
|
2348
|
+
const headerEnd = block.indexOf(">");
|
|
2349
|
+
if (headerEnd < 0) continue;
|
|
2350
|
+
const header = block.slice(0, headerEnd + 1);
|
|
2351
|
+
const w = Number(/\bw="(\d+)"/.exec(header)?.[1] ?? 0);
|
|
2352
|
+
const h = Number(/\bh="(\d+)"/.exec(header)?.[1] ?? 0);
|
|
2353
|
+
if (w > viewW) viewW = w;
|
|
2354
|
+
if (h > viewH) viewH = h;
|
|
2355
|
+
d += (d ? " " : "") + custGeomBodyToSvgD(block);
|
|
2356
|
+
}
|
|
2357
|
+
if (!d || viewW <= 0 || viewH <= 0) return undefined;
|
|
2358
|
+
// OOXML composite paths (multiple subpaths with internal holes — letters
|
|
2359
|
+
// like the "e" and "o" in the eon wordmark) render with even-odd winding
|
|
2360
|
+
// by default; the nonzero default of SVG would fill the holes.
|
|
2361
|
+
return { d, viewW, viewH, fillRule: "evenodd" };
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
function custGeomBodyToSvgD(pathBlock: string): string {
|
|
2365
|
+
const headerEnd = pathBlock.indexOf(">");
|
|
2366
|
+
const closeIdx = pathBlock.lastIndexOf("</a:path>");
|
|
2367
|
+
const inner =
|
|
2368
|
+
closeIdx > headerEnd
|
|
2369
|
+
? pathBlock.slice(headerEnd + 1, closeIdx)
|
|
2370
|
+
: pathBlock.slice(headerEnd + 1);
|
|
2371
|
+
let out = "";
|
|
2372
|
+
let i = 0;
|
|
2373
|
+
let depth = 0;
|
|
2374
|
+
// Track the current pen position so <a:arcTo> (which doesn't carry an
|
|
2375
|
+
// explicit end point) can compute its SVG `A` endpoint from start +
|
|
2376
|
+
// sweep angle, just like PowerPoint does at render time.
|
|
2377
|
+
let penX = 0;
|
|
2378
|
+
let penY = 0;
|
|
2379
|
+
while (i < inner.length) {
|
|
2380
|
+
if (inner[i] !== "<") {
|
|
2381
|
+
i++;
|
|
2382
|
+
continue;
|
|
2383
|
+
}
|
|
2384
|
+
if (inner.startsWith("</", i)) {
|
|
2385
|
+
depth--;
|
|
2386
|
+
const end = inner.indexOf(">", i);
|
|
2387
|
+
if (end < 0) break;
|
|
2388
|
+
i = end + 1;
|
|
2389
|
+
continue;
|
|
2390
|
+
}
|
|
2391
|
+
if (inner.startsWith("<!--", i)) {
|
|
2392
|
+
const end = inner.indexOf("-->", i);
|
|
2393
|
+
if (end < 0) break;
|
|
2394
|
+
i = end + 3;
|
|
2395
|
+
continue;
|
|
2396
|
+
}
|
|
2397
|
+
const close = inner.indexOf(">", i);
|
|
2398
|
+
if (close < 0) break;
|
|
2399
|
+
const tag = inner.slice(i, close + 1);
|
|
2400
|
+
const nameMatch = /^<a:([\w]+)/.exec(tag);
|
|
2401
|
+
const isSelfClose = tag.endsWith("/>");
|
|
2402
|
+
if (depth === 0 && nameMatch) {
|
|
2403
|
+
const name = nameMatch[1];
|
|
2404
|
+
if (name === "close") {
|
|
2405
|
+
out += " Z";
|
|
2406
|
+
} else if (
|
|
2407
|
+
name === "moveTo" ||
|
|
2408
|
+
name === "lnTo" ||
|
|
2409
|
+
name === "cubicBezTo" ||
|
|
2410
|
+
name === "quadBezTo"
|
|
2411
|
+
) {
|
|
2412
|
+
const cmdClose = inner.indexOf(`</a:${name}>`, close + 1);
|
|
2413
|
+
if (cmdClose < 0) break;
|
|
2414
|
+
const body = inner.slice(close + 1, cmdClose);
|
|
2415
|
+
const pts: Array<[number, number]> = [];
|
|
2416
|
+
const ptRe = /<a:pt\s+x="(-?\d+)"\s+y="(-?\d+)"\s*\/>/g;
|
|
2417
|
+
let m: RegExpExecArray | null;
|
|
2418
|
+
while ((m = ptRe.exec(body))) {
|
|
2419
|
+
pts.push([Number(m[1]), Number(m[2])]);
|
|
2420
|
+
}
|
|
2421
|
+
if (pts.length) {
|
|
2422
|
+
const letter =
|
|
2423
|
+
name === "moveTo"
|
|
2424
|
+
? "M"
|
|
2425
|
+
: name === "lnTo"
|
|
2426
|
+
? "L"
|
|
2427
|
+
: name === "cubicBezTo"
|
|
2428
|
+
? "C"
|
|
2429
|
+
: "Q";
|
|
2430
|
+
out += ` ${letter} ${pts.map(([x, y]) => `${x} ${y}`).join(" ")}`;
|
|
2431
|
+
const last = pts[pts.length - 1];
|
|
2432
|
+
penX = last[0];
|
|
2433
|
+
penY = last[1];
|
|
2434
|
+
}
|
|
2435
|
+
i = cmdClose + `</a:${name}>`.length;
|
|
2436
|
+
continue;
|
|
2437
|
+
} else if (name === "arcTo") {
|
|
2438
|
+
// <a:arcTo wR="" hR="" stAng="" swAng="" /> — elliptical arc
|
|
2439
|
+
// starting at the current pen position. wR/hR are the axis radii;
|
|
2440
|
+
// stAng/swAng are start/sweep angles measured in 60000ths of a
|
|
2441
|
+
// degree (OOXML convention). The start point on the ellipse is
|
|
2442
|
+
// (centre.x + wR·cos(stAng), centre.y + hR·sin(stAng)); the
|
|
2443
|
+
// centre is therefore (pen.x − wR·cos(stAng), pen.y − hR·sin(stAng)).
|
|
2444
|
+
// SVG `A` takes the END point instead of an angle, so we compute
|
|
2445
|
+
// the end from start + swAng.
|
|
2446
|
+
const wR = Number(/\bwR="(-?\d+)"/.exec(tag)?.[1] ?? 0);
|
|
2447
|
+
const hR = Number(/\bhR="(-?\d+)"/.exec(tag)?.[1] ?? 0);
|
|
2448
|
+
const stAng = Number(/\bstAng="(-?\d+)"/.exec(tag)?.[1] ?? 0) / 60000;
|
|
2449
|
+
const swAng = Number(/\bswAng="(-?\d+)"/.exec(tag)?.[1] ?? 0) / 60000;
|
|
2450
|
+
if (wR > 0 && hR > 0) {
|
|
2451
|
+
const rad = (deg: number) => (deg * Math.PI) / 180;
|
|
2452
|
+
const cx = penX - wR * Math.cos(rad(stAng));
|
|
2453
|
+
const cy = penY - hR * Math.sin(rad(stAng));
|
|
2454
|
+
const endAng = stAng + swAng;
|
|
2455
|
+
const ex = cx + wR * Math.cos(rad(endAng));
|
|
2456
|
+
const ey = cy + hR * Math.sin(rad(endAng));
|
|
2457
|
+
const largeArc = Math.abs(swAng) > 180 ? 1 : 0;
|
|
2458
|
+
const sweep = swAng > 0 ? 1 : 0;
|
|
2459
|
+
out += ` A ${wR} ${hR} 0 ${largeArc} ${sweep} ${ex.toFixed(2)} ${ey.toFixed(2)}`;
|
|
2460
|
+
penX = ex;
|
|
2461
|
+
penY = ey;
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
// Unknown commands are skipped — the rest of the path stays valid.
|
|
2465
|
+
}
|
|
2466
|
+
if (!isSelfClose) depth++;
|
|
2467
|
+
i = close + 1;
|
|
2468
|
+
}
|
|
2469
|
+
return out.trim();
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
function paragraphChildOrderFromRaw(
|
|
2473
|
+
raw: string
|
|
2474
|
+
): { kind: "r" | "br" | "fld"; index: number }[] {
|
|
2475
|
+
const tagEnd = raw.indexOf(">");
|
|
2476
|
+
const closeIdx = raw.lastIndexOf("</a:p>");
|
|
2477
|
+
if (tagEnd < 0 || closeIdx < 0) return [];
|
|
2478
|
+
const inner = raw.slice(tagEnd + 1, closeIdx);
|
|
2479
|
+
const out: { kind: "r" | "br" | "fld"; index: number }[] = [];
|
|
2480
|
+
let depth = 0;
|
|
2481
|
+
let rIdx = 0;
|
|
2482
|
+
let brIdx = 0;
|
|
2483
|
+
let fldIdx = 0;
|
|
2484
|
+
let i = 0;
|
|
2485
|
+
while (i < inner.length) {
|
|
2486
|
+
if (inner[i] !== "<") {
|
|
2487
|
+
i++;
|
|
2488
|
+
continue;
|
|
2489
|
+
}
|
|
2490
|
+
if (inner.startsWith("</", i)) {
|
|
2491
|
+
depth--;
|
|
2492
|
+
const end = inner.indexOf(">", i);
|
|
2493
|
+
if (end < 0) break;
|
|
2494
|
+
i = end + 1;
|
|
2495
|
+
continue;
|
|
2496
|
+
}
|
|
2497
|
+
if (inner.startsWith("<!--", i)) {
|
|
2498
|
+
const end = inner.indexOf("-->", i);
|
|
2499
|
+
if (end < 0) break;
|
|
2500
|
+
i = end + 3;
|
|
2501
|
+
continue;
|
|
2502
|
+
}
|
|
2503
|
+
if (inner.startsWith("<?", i)) {
|
|
2504
|
+
const end = inner.indexOf("?>", i);
|
|
2505
|
+
if (end < 0) break;
|
|
2506
|
+
i = end + 2;
|
|
2507
|
+
continue;
|
|
2508
|
+
}
|
|
2509
|
+
const close = inner.indexOf(">", i);
|
|
2510
|
+
if (close < 0) break;
|
|
2511
|
+
const tag = inner.slice(i, close + 1);
|
|
2512
|
+
const isSelfClose = tag.endsWith("/>");
|
|
2513
|
+
const nameMatch = /^<(a:[\w]+)/.exec(tag);
|
|
2514
|
+
if (depth === 0 && nameMatch) {
|
|
2515
|
+
const name = nameMatch[1];
|
|
2516
|
+
if (name === "a:r") out.push({ kind: "r", index: rIdx++ });
|
|
2517
|
+
else if (name === "a:br") out.push({ kind: "br", index: brIdx++ });
|
|
2518
|
+
else if (name === "a:fld") out.push({ kind: "fld", index: fldIdx++ });
|
|
2519
|
+
}
|
|
2520
|
+
if (!isSelfClose) depth++;
|
|
2521
|
+
i = close + 1;
|
|
2522
|
+
}
|
|
2523
|
+
return out;
|
|
2524
|
+
}
|
|
2525
|
+
|
|
1274
2526
|
function hasAnyText(txBody: any): boolean {
|
|
1275
2527
|
const ps = asArray(txBody?.["a:p"]);
|
|
1276
2528
|
for (const p of ps) {
|
|
1277
|
-
const
|
|
1278
|
-
for (const r of rs) {
|
|
2529
|
+
for (const r of [...asArray(p?.["a:r"]), ...asArray(p?.["a:fld"])]) {
|
|
1279
2530
|
const t = r?.["a:t"];
|
|
1280
2531
|
const text = typeof t === "string" ? t : t?.["#text"] ?? "";
|
|
1281
2532
|
if (text && String(text).length > 0) return true;
|
|
@@ -1291,6 +2542,27 @@ function mergeFirst<T>(...candidates: (T | undefined)[]): T | undefined {
|
|
|
1291
2542
|
return undefined;
|
|
1292
2543
|
}
|
|
1293
2544
|
|
|
2545
|
+
/**
|
|
2546
|
+
* Per-field rPr merge: earlier candidates win for each individual attribute
|
|
2547
|
+
* (font typeface, size, colour, weight, italic, …). Used to flatten the
|
|
2548
|
+
* layout→master→txStyles inheritance chain into a single fallback rPr for
|
|
2549
|
+
* the run extractor.
|
|
2550
|
+
*/
|
|
2551
|
+
function mergeRPrChain(...candidates: (any | undefined)[]): any | undefined {
|
|
2552
|
+
const out: any = {};
|
|
2553
|
+
let touched = false;
|
|
2554
|
+
for (const c of candidates) {
|
|
2555
|
+
if (!c || typeof c !== "object") continue;
|
|
2556
|
+
for (const k of Object.keys(c)) {
|
|
2557
|
+
if (out[k] === undefined) {
|
|
2558
|
+
out[k] = c[k];
|
|
2559
|
+
touched = true;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
return touched ? out : undefined;
|
|
2564
|
+
}
|
|
2565
|
+
|
|
1294
2566
|
/**
|
|
1295
2567
|
* Map an OOXML preset shape name to the closest Slidewise ShapeKind. Returns
|
|
1296
2568
|
* null only for shapes we genuinely cannot represent at all (the caller then
|
|
@@ -1436,7 +2708,274 @@ async function readXml(zip: JSZip, path: string): Promise<any | null> {
|
|
|
1436
2708
|
const file = zip.file(path);
|
|
1437
2709
|
if (!file) return null;
|
|
1438
2710
|
const text = await file.async("string");
|
|
1439
|
-
|
|
2711
|
+
const parsed = xmlParser.parse(text);
|
|
2712
|
+
// fast-xml-parser groups children by tag name and drops cross-tag
|
|
2713
|
+
// document order. Attach raw fragments for paragraphs (run/br/fld
|
|
2714
|
+
// interleaving), custGeom path commands, and spTree/grpSp children
|
|
2715
|
+
// (which carry z-index via source order).
|
|
2716
|
+
annotateRawOrder(parsed, text);
|
|
2717
|
+
return parsed;
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
function annotateRawOrder(parsed: any, rawText: string): void {
|
|
2721
|
+
annotateParagraphRawSrc(parsed, rawText);
|
|
2722
|
+
annotateSpTreeChildOrder(parsed, rawText);
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
/**
|
|
2726
|
+
* Attach `_childOrder: string[]` to every parsed `<p:spTree>` and
|
|
2727
|
+
* `<p:grpSp>` so callers can iterate children (sp, pic, cxnSp,
|
|
2728
|
+
* graphicFrame, grpSp) in document order. PPTX z-index follows source
|
|
2729
|
+
* order — a slide that lists `<p:pic>` before `<p:sp>` means the picture
|
|
2730
|
+
* sits behind the text — but fast-xml-parser groups children by tag name
|
|
2731
|
+
* and drops cross-tag ordering.
|
|
2732
|
+
*/
|
|
2733
|
+
function annotateSpTreeChildOrder(parsed: any, rawText: string): void {
|
|
2734
|
+
if (!rawText.includes("<p:spTree") && !rawText.includes("<p:grpSp")) return;
|
|
2735
|
+
for (const tag of ["p:spTree", "p:grpSp"] as const) {
|
|
2736
|
+
if (!rawText.includes(`<${tag}`)) continue;
|
|
2737
|
+
const blocks = findAllRawBlocks(rawText, tag);
|
|
2738
|
+
if (!blocks.length) continue;
|
|
2739
|
+
const nodes: any[] = [];
|
|
2740
|
+
collectNamedDfs(parsed, tag, nodes);
|
|
2741
|
+
const n = Math.min(blocks.length, nodes.length);
|
|
2742
|
+
for (let i = 0; i < n; i++) {
|
|
2743
|
+
const order = extractTopLevelChildNames(blocks[i]);
|
|
2744
|
+
Object.defineProperty(nodes[i], "_childOrder", {
|
|
2745
|
+
value: order,
|
|
2746
|
+
enumerable: false,
|
|
2747
|
+
configurable: true,
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function extractTopLevelChildNames(blockXml: string): string[] {
|
|
2754
|
+
const tagEnd = blockXml.indexOf(">");
|
|
2755
|
+
const close = blockXml.lastIndexOf("<");
|
|
2756
|
+
if (tagEnd < 0 || close <= tagEnd) return [];
|
|
2757
|
+
const inner = blockXml.slice(tagEnd + 1, close);
|
|
2758
|
+
const out: string[] = [];
|
|
2759
|
+
let depth = 0;
|
|
2760
|
+
let i = 0;
|
|
2761
|
+
while (i < inner.length) {
|
|
2762
|
+
if (inner[i] !== "<") {
|
|
2763
|
+
i++;
|
|
2764
|
+
continue;
|
|
2765
|
+
}
|
|
2766
|
+
if (inner.startsWith("</", i)) {
|
|
2767
|
+
depth--;
|
|
2768
|
+
const end = inner.indexOf(">", i);
|
|
2769
|
+
if (end < 0) break;
|
|
2770
|
+
i = end + 1;
|
|
2771
|
+
continue;
|
|
2772
|
+
}
|
|
2773
|
+
if (inner.startsWith("<!--", i)) {
|
|
2774
|
+
const end = inner.indexOf("-->", i);
|
|
2775
|
+
if (end < 0) break;
|
|
2776
|
+
i = end + 3;
|
|
2777
|
+
continue;
|
|
2778
|
+
}
|
|
2779
|
+
const end = inner.indexOf(">", i);
|
|
2780
|
+
if (end < 0) break;
|
|
2781
|
+
const tag = inner.slice(i, end + 1);
|
|
2782
|
+
const selfClose = tag.endsWith("/>");
|
|
2783
|
+
const nameMatch = /^<([\w:]+)/.exec(tag);
|
|
2784
|
+
if (depth === 0 && nameMatch) out.push(nameMatch[1]);
|
|
2785
|
+
if (!selfClose) depth++;
|
|
2786
|
+
i = end + 1;
|
|
2787
|
+
}
|
|
2788
|
+
return out;
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
function findAllRawBlocks(raw: string, fullName: string): string[] {
|
|
2792
|
+
const blocks: string[] = [];
|
|
2793
|
+
// Depth-aware scan so self-nesting tags (e.g. `<p:grpSp>` inside another
|
|
2794
|
+
// `<p:grpSp>`) match their correct closing tag instead of the first inner
|
|
2795
|
+
// one we encounter.
|
|
2796
|
+
const openRe = new RegExp(`<${fullName}\\b[^>]*?(/?)>`, "g");
|
|
2797
|
+
const closeTag = `</${fullName}>`;
|
|
2798
|
+
let i = 0;
|
|
2799
|
+
while (i < raw.length) {
|
|
2800
|
+
openRe.lastIndex = i;
|
|
2801
|
+
const m = openRe.exec(raw);
|
|
2802
|
+
if (!m) break;
|
|
2803
|
+
const start = m.index;
|
|
2804
|
+
const tagEnd = openRe.lastIndex;
|
|
2805
|
+
if (m[1] === "/") {
|
|
2806
|
+
blocks.push(raw.slice(start, tagEnd));
|
|
2807
|
+
i = tagEnd;
|
|
2808
|
+
continue;
|
|
2809
|
+
}
|
|
2810
|
+
let depth = 1;
|
|
2811
|
+
let scan = tagEnd;
|
|
2812
|
+
const innerOpenRe = new RegExp(`<${fullName}\\b[^>]*?(/?)>`, "g");
|
|
2813
|
+
while (depth > 0 && scan < raw.length) {
|
|
2814
|
+
innerOpenRe.lastIndex = scan;
|
|
2815
|
+
const nextOpen = innerOpenRe.exec(raw);
|
|
2816
|
+
const nextClose = raw.indexOf(closeTag, scan);
|
|
2817
|
+
if (nextClose < 0) {
|
|
2818
|
+
depth = -1;
|
|
2819
|
+
break;
|
|
2820
|
+
}
|
|
2821
|
+
if (nextOpen && nextOpen.index < nextClose) {
|
|
2822
|
+
// Self-closing opens don't change depth.
|
|
2823
|
+
if (nextOpen[1] !== "/") depth++;
|
|
2824
|
+
scan = innerOpenRe.lastIndex;
|
|
2825
|
+
} else {
|
|
2826
|
+
depth--;
|
|
2827
|
+
scan = nextClose + closeTag.length;
|
|
2828
|
+
}
|
|
2829
|
+
}
|
|
2830
|
+
if (depth !== 0) break;
|
|
2831
|
+
blocks.push(raw.slice(start, scan));
|
|
2832
|
+
i = scan;
|
|
2833
|
+
}
|
|
2834
|
+
return blocks;
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
function annotateParagraphRawSrc(parsed: any, rawText: string): void {
|
|
2838
|
+
// Cross-tag document order matters when a paragraph mixes <a:r>, <a:br>,
|
|
2839
|
+
// and <a:fld>. fast-xml-parser groups by tag name, so we keep the raw
|
|
2840
|
+
// XML for any paragraph that contains a break or a field.
|
|
2841
|
+
if (rawText.includes("<a:br") || rawText.includes("<a:fld")) {
|
|
2842
|
+
const blocks = findAllParagraphRawBlocks(rawText);
|
|
2843
|
+
if (blocks.length) {
|
|
2844
|
+
const parsedPs: any[] = [];
|
|
2845
|
+
collectParagraphsDfs(parsed, parsedPs);
|
|
2846
|
+
const n = Math.min(blocks.length, parsedPs.length);
|
|
2847
|
+
for (let i = 0; i < n; i++) {
|
|
2848
|
+
const block = blocks[i];
|
|
2849
|
+
if (block.includes("<a:br") || block.includes("<a:fld")) {
|
|
2850
|
+
Object.defineProperty(parsedPs[i], "_rawSrc", {
|
|
2851
|
+
value: block,
|
|
2852
|
+
enumerable: false,
|
|
2853
|
+
configurable: true,
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
// <a:custGeom> path commands (moveTo, lnTo, cubicBezTo, …) are siblings,
|
|
2860
|
+
// and their cross-tag order defines the silhouette of brand logos and
|
|
2861
|
+
// hand-drawn shapes. Same fast-xml-parser issue, same fix: attach raw.
|
|
2862
|
+
if (rawText.includes("<a:custGeom")) {
|
|
2863
|
+
const blocks = findAllElementRawBlocks(rawText, "custGeom");
|
|
2864
|
+
if (blocks.length) {
|
|
2865
|
+
const parsedCustGeoms: any[] = [];
|
|
2866
|
+
collectNamedDfs(parsed, "a:custGeom", parsedCustGeoms);
|
|
2867
|
+
const n = Math.min(blocks.length, parsedCustGeoms.length);
|
|
2868
|
+
for (let i = 0; i < n; i++) {
|
|
2869
|
+
Object.defineProperty(parsedCustGeoms[i], "_rawSrc", {
|
|
2870
|
+
value: blocks[i],
|
|
2871
|
+
enumerable: false,
|
|
2872
|
+
configurable: true,
|
|
2873
|
+
});
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
|
|
2879
|
+
function collectNamedDfs(node: any, key: string, acc: any[]): void {
|
|
2880
|
+
if (!node || typeof node !== "object") return;
|
|
2881
|
+
if (Array.isArray(node)) {
|
|
2882
|
+
for (const n of node) collectNamedDfs(n, key, acc);
|
|
2883
|
+
return;
|
|
2884
|
+
}
|
|
2885
|
+
for (const k of Object.keys(node)) {
|
|
2886
|
+
if (k.startsWith("@_") || k === "#text") continue;
|
|
2887
|
+
if (k === key) {
|
|
2888
|
+
for (const v of asArray(node[k])) acc.push(v);
|
|
2889
|
+
} else {
|
|
2890
|
+
collectNamedDfs(node[k], key, acc);
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
function findAllElementRawBlocks(raw: string, localName: string): string[] {
|
|
2896
|
+
const blocks: string[] = [];
|
|
2897
|
+
const openSelfClose = new RegExp(`<a:${localName}\\b[^>]*/>`);
|
|
2898
|
+
const openTag = new RegExp(`<a:${localName}\\b[^>]*>`);
|
|
2899
|
+
const close = `</a:${localName}>`;
|
|
2900
|
+
let cursor = 0;
|
|
2901
|
+
while (cursor < raw.length) {
|
|
2902
|
+
const slice = raw.slice(cursor);
|
|
2903
|
+
const sc = openSelfClose.exec(slice);
|
|
2904
|
+
const ot = openTag.exec(slice);
|
|
2905
|
+
// Pick whichever comes first (and only if it isn't a self-close that was
|
|
2906
|
+
// also matched by openTag).
|
|
2907
|
+
let start = -1;
|
|
2908
|
+
let selfClose = false;
|
|
2909
|
+
if (sc && (!ot || sc.index <= ot.index)) {
|
|
2910
|
+
start = cursor + sc.index;
|
|
2911
|
+
selfClose = true;
|
|
2912
|
+
} else if (ot) {
|
|
2913
|
+
start = cursor + ot.index;
|
|
2914
|
+
selfClose = ot[0].endsWith("/>");
|
|
2915
|
+
}
|
|
2916
|
+
if (start < 0) break;
|
|
2917
|
+
if (selfClose) {
|
|
2918
|
+
const end = raw.indexOf(">", start);
|
|
2919
|
+
blocks.push(raw.slice(start, end + 1));
|
|
2920
|
+
cursor = end + 1;
|
|
2921
|
+
continue;
|
|
2922
|
+
}
|
|
2923
|
+
const tagEnd = raw.indexOf(">", start);
|
|
2924
|
+
const closeIdx = raw.indexOf(close, tagEnd + 1);
|
|
2925
|
+
if (closeIdx < 0) break;
|
|
2926
|
+
blocks.push(raw.slice(start, closeIdx + close.length));
|
|
2927
|
+
cursor = closeIdx + close.length;
|
|
2928
|
+
}
|
|
2929
|
+
return blocks;
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
function findAllParagraphRawBlocks(raw: string): string[] {
|
|
2933
|
+
const blocks: string[] = [];
|
|
2934
|
+
let i = 0;
|
|
2935
|
+
while (i < raw.length) {
|
|
2936
|
+
const a = raw.indexOf("<a:p>", i);
|
|
2937
|
+
const b = raw.indexOf("<a:p ", i);
|
|
2938
|
+
let start: number;
|
|
2939
|
+
if (a < 0 && b < 0) break;
|
|
2940
|
+
if (a < 0) start = b;
|
|
2941
|
+
else if (b < 0) start = a;
|
|
2942
|
+
else start = Math.min(a, b);
|
|
2943
|
+
const tagEnd = raw.indexOf(">", start);
|
|
2944
|
+
if (tagEnd < 0) break;
|
|
2945
|
+
if (raw[tagEnd - 1] === "/") {
|
|
2946
|
+
blocks.push(raw.slice(start, tagEnd + 1));
|
|
2947
|
+
i = tagEnd + 1;
|
|
2948
|
+
continue;
|
|
2949
|
+
}
|
|
2950
|
+
// <a:p> never nests inside another <a:p>, so the first </a:p> is ours.
|
|
2951
|
+
const close = raw.indexOf("</a:p>", tagEnd + 1);
|
|
2952
|
+
if (close < 0) break;
|
|
2953
|
+
blocks.push(raw.slice(start, close + "</a:p>".length));
|
|
2954
|
+
i = close + "</a:p>".length;
|
|
2955
|
+
}
|
|
2956
|
+
return blocks;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
function collectParagraphsDfs(node: any, acc: any[]): void {
|
|
2960
|
+
if (!node || typeof node !== "object") return;
|
|
2961
|
+
if (Array.isArray(node)) {
|
|
2962
|
+
for (const n of node) collectParagraphsDfs(n, acc);
|
|
2963
|
+
return;
|
|
2964
|
+
}
|
|
2965
|
+
for (const k of Object.keys(node)) {
|
|
2966
|
+
if (k.startsWith("@_") || k === "#text") continue;
|
|
2967
|
+
if (k === "a:p") {
|
|
2968
|
+
for (const p of asArray(node[k])) acc.push(p);
|
|
2969
|
+
} else {
|
|
2970
|
+
collectParagraphsDfs(node[k], acc);
|
|
2971
|
+
}
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
async function readXmlRaw(zip: JSZip, path: string): Promise<string | null> {
|
|
2976
|
+
const file = zip.file(path);
|
|
2977
|
+
if (!file) return null;
|
|
2978
|
+
return file.async("string");
|
|
1440
2979
|
}
|
|
1441
2980
|
|
|
1442
2981
|
async function readRels(zip: JSZip, path: string): Promise<Rels> {
|