@textcortex/slidewise 1.7.0 → 1.9.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 +6303 -5377
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/SlidewiseEditor.tsx +10 -0
- package/src/SlidewiseFileEditor.tsx +8 -0
- package/src/components/editor/Canvas.tsx +15 -8
- package/src/components/editor/ElementView.tsx +97 -9
- package/src/components/editor/SlideView.tsx +6 -1
- package/src/compound/CanvasContext.tsx +176 -0
- package/src/compound/SlidewiseRoot.tsx +34 -11
- package/src/compound/index.ts +8 -0
- package/src/index.ts +5 -0
- package/src/lib/pptx/pptxToDeck.ts +1622 -126
- 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
|
/**
|
|
@@ -224,7 +282,29 @@ async function parseSlide(
|
|
|
224
282
|
? normalisePath(themeTarget, dirOf(masterPath))
|
|
225
283
|
: null;
|
|
226
284
|
const themeXml = themePath ? await readXml(zip, themePath) : null;
|
|
227
|
-
const
|
|
285
|
+
const themeRaw = themePath ? await readXmlRaw(zip, themePath) : null;
|
|
286
|
+
const baseTheme = themeXml ? extractTheme(themeXml) : DEFAULT_THEME;
|
|
287
|
+
const themeFills =
|
|
288
|
+
themeXml && themeRaw
|
|
289
|
+
? extractThemeFills(themeXml, themeRaw)
|
|
290
|
+
: { bg: [], fg: [] };
|
|
291
|
+
const themeFonts = themeXml ? extractThemeFonts(themeXml) : {};
|
|
292
|
+
// <p:clrMap> lives on the master; <p:clrMapOvr><a:overrideClrMapping/> on
|
|
293
|
+
// the slide can override individual mappings. Slides commonly only declare
|
|
294
|
+
// <a:masterClrMapping/> which means "inherit the master's map verbatim".
|
|
295
|
+
const masterClrMap = masterXml
|
|
296
|
+
? extractClrMap(masterXml?.["p:sldMaster"]?.["p:clrMap"])
|
|
297
|
+
: DEFAULT_CLR_MAP;
|
|
298
|
+
const clrMapOvr = xml["p:sld"]?.["p:clrMapOvr"]?.["a:overrideClrMapping"];
|
|
299
|
+
const clrMap = clrMapOvr ? extractClrMap(clrMapOvr) : masterClrMap;
|
|
300
|
+
// Bake the clrMap into the theme so bg1/bg2/tx1/tx2 stay flat lookups.
|
|
301
|
+
const theme: ThemeColors = {
|
|
302
|
+
...baseTheme,
|
|
303
|
+
bg1: baseTheme[clrMap.bg1],
|
|
304
|
+
bg2: baseTheme[clrMap.bg2],
|
|
305
|
+
tx1: baseTheme[clrMap.tx1],
|
|
306
|
+
tx2: baseTheme[clrMap.tx2],
|
|
307
|
+
};
|
|
228
308
|
|
|
229
309
|
const layoutPh = layoutXml ? extractPlaceholders(layoutXml) : new Map();
|
|
230
310
|
const masterPh = masterXml ? extractPlaceholders(masterXml) : new Map();
|
|
@@ -239,6 +319,8 @@ async function parseSlide(
|
|
|
239
319
|
slideRels,
|
|
240
320
|
fit,
|
|
241
321
|
theme,
|
|
322
|
+
themeFills,
|
|
323
|
+
themeFonts,
|
|
242
324
|
layoutPh,
|
|
243
325
|
masterPh,
|
|
244
326
|
masterTextDefaults,
|
|
@@ -246,27 +328,64 @@ async function parseSlide(
|
|
|
246
328
|
|
|
247
329
|
const sld = xml["p:sld"];
|
|
248
330
|
const cSld = sld?.["p:cSld"];
|
|
249
|
-
const slideBg =
|
|
331
|
+
const slideBg = await extractBackground(
|
|
332
|
+
cSld?.["p:bg"],
|
|
333
|
+
ctx,
|
|
334
|
+
slideRels,
|
|
335
|
+
slidePath
|
|
336
|
+
);
|
|
250
337
|
const layoutBg = layoutXml
|
|
251
|
-
?
|
|
338
|
+
? await extractBackground(
|
|
252
339
|
layoutXml?.["p:sldLayout"]?.["p:cSld"]?.["p:bg"],
|
|
253
|
-
|
|
340
|
+
ctx,
|
|
341
|
+
layoutRels,
|
|
342
|
+
layoutPath!
|
|
254
343
|
)
|
|
255
344
|
: undefined;
|
|
256
345
|
const masterBg = masterXml
|
|
257
|
-
?
|
|
346
|
+
? await extractBackground(
|
|
258
347
|
masterXml?.["p:sldMaster"]?.["p:cSld"]?.["p:bg"],
|
|
259
|
-
|
|
348
|
+
ctx,
|
|
349
|
+
masterRels,
|
|
350
|
+
masterPath!
|
|
260
351
|
)
|
|
261
352
|
: undefined;
|
|
262
353
|
const background = slideBg ?? layoutBg ?? masterBg ?? "#FFFFFF";
|
|
263
354
|
|
|
264
355
|
const spTree = cSld?.["p:spTree"];
|
|
356
|
+
|
|
357
|
+
// Layout & master visuals (non-placeholder shapes/pics, plus the fill of
|
|
358
|
+
// placeholder-bearing shapes) form an underlay so brand bars, side gradients,
|
|
359
|
+
// logo pics, and tinted placeholder boxes appear behind slide content.
|
|
360
|
+
// Placeholders the slide already overrides (e.g. picture placeholder filled
|
|
361
|
+
// by an in-slide <p:pic>) are skipped so their "Insert Picture" prompt
|
|
362
|
+
// background doesn't leak through.
|
|
363
|
+
const slidePhKeys = collectSlidePlaceholderKeys(spTree);
|
|
364
|
+
const masterUnderlay = masterXml
|
|
365
|
+
? await parseUnderlay(
|
|
366
|
+
masterXml["p:sldMaster"]?.["p:cSld"]?.["p:spTree"],
|
|
367
|
+
ctx,
|
|
368
|
+
masterPath!,
|
|
369
|
+
masterRels,
|
|
370
|
+
slidePhKeys
|
|
371
|
+
)
|
|
372
|
+
: [];
|
|
373
|
+
const layoutUnderlay = layoutXml
|
|
374
|
+
? await parseUnderlay(
|
|
375
|
+
layoutXml["p:sldLayout"]?.["p:cSld"]?.["p:spTree"],
|
|
376
|
+
ctx,
|
|
377
|
+
layoutPath!,
|
|
378
|
+
layoutRels,
|
|
379
|
+
slidePhKeys
|
|
380
|
+
)
|
|
381
|
+
: [];
|
|
265
382
|
const elements: SlideElement[] = [];
|
|
266
383
|
|
|
384
|
+
let z = 1;
|
|
385
|
+
for (const el of masterUnderlay) elements.push({ ...el, z: z++ });
|
|
386
|
+
for (const el of layoutUnderlay) elements.push({ ...el, z: z++ });
|
|
267
387
|
if (spTree) {
|
|
268
388
|
const collected = await parseSpTree(spTree, ctx, identityTransform());
|
|
269
|
-
let z = 1;
|
|
270
389
|
for (const el of collected) {
|
|
271
390
|
elements.push({ ...el, z: z++ });
|
|
272
391
|
}
|
|
@@ -279,29 +398,64 @@ async function parseSlide(
|
|
|
279
398
|
};
|
|
280
399
|
}
|
|
281
400
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
function
|
|
291
|
-
|
|
401
|
+
/**
|
|
402
|
+
* Walk a layout or master spTree and return elements to render behind the
|
|
403
|
+
* slide: non-placeholder shapes/pics (the brand bars, gradient bands, corner
|
|
404
|
+
* logos) and any explicit fill on placeholder-bearing shapes (the tinted
|
|
405
|
+
* boxes some templates host on layout placeholders). Hidden shapes are
|
|
406
|
+
* skipped. Placeholder text/positions remain handled by the existing
|
|
407
|
+
* inheritance path so we don't duplicate them.
|
|
408
|
+
*/
|
|
409
|
+
async function parseUnderlay(
|
|
410
|
+
spTree: any,
|
|
411
|
+
ctx: ParseContext,
|
|
412
|
+
ownerPath: string,
|
|
413
|
+
ownerRels: Rels,
|
|
414
|
+
slidePhKeys: Set<string>
|
|
415
|
+
): Promise<SlideElement[]> {
|
|
416
|
+
if (!spTree) return [];
|
|
417
|
+
const underlayCtx: ParseContext = {
|
|
418
|
+
...ctx,
|
|
419
|
+
slidePath: ownerPath,
|
|
420
|
+
slideRels: ownerRels,
|
|
421
|
+
};
|
|
422
|
+
return walkUnderlay(spTree, underlayCtx, identityTransform(), slidePhKeys);
|
|
292
423
|
}
|
|
293
424
|
|
|
294
|
-
async function
|
|
425
|
+
async function walkUnderlay(
|
|
295
426
|
spTree: any,
|
|
296
427
|
ctx: ParseContext,
|
|
297
|
-
outer: GroupTransform
|
|
428
|
+
outer: GroupTransform,
|
|
429
|
+
slidePhKeys: Set<string>
|
|
298
430
|
): Promise<SlideElement[]> {
|
|
299
431
|
const out: SlideElement[] = [];
|
|
300
432
|
for (const sp of asArray(spTree["p:sp"])) {
|
|
301
|
-
|
|
433
|
+
if (isHiddenNode(sp, "p:nvSpPr")) continue;
|
|
434
|
+
const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
|
|
435
|
+
if (ph) {
|
|
436
|
+
const isPicPrompt = ph["@_type"] === "pic";
|
|
437
|
+
const isOverridden = slidePhKeys.has(placeholderKey(ph));
|
|
438
|
+
// Picture placeholders are "Insert Picture" prompts; when the slide
|
|
439
|
+
// supplies an actual image, the prompt panel must hide.
|
|
440
|
+
if (isPicPrompt && isOverridden) continue;
|
|
441
|
+
// When the slide hosts this placeholder, its fill rides on the
|
|
442
|
+
// slide's text element (TextElement.background) so it stays at the
|
|
443
|
+
// text's z-index — important when the slide also has a full-bleed
|
|
444
|
+
// image that would otherwise cover an underlay-emitted backing.
|
|
445
|
+
if (isOverridden) continue;
|
|
446
|
+
// Unreferenced placeholders: emit a fill-only backing so coloured
|
|
447
|
+
// boxes (numbered chips, decorative panels) appear.
|
|
448
|
+
const filler = await placeholderFillUnderlay(sp, ctx, outer);
|
|
449
|
+
if (filler) out.push(filler);
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
const el = await parseSpOrText(sp, ctx, outer, { underlay: true });
|
|
302
453
|
if (el) out.push(el);
|
|
303
454
|
}
|
|
304
455
|
for (const pic of asArray(spTree["p:pic"])) {
|
|
456
|
+
if (isHiddenNode(pic, "p:nvPicPr")) continue;
|
|
457
|
+
const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
|
|
458
|
+
if (ph && slidePhKeys.has(placeholderKey(ph))) continue;
|
|
305
459
|
const el = await parsePic(pic, ctx, outer);
|
|
306
460
|
if (el) out.push(el);
|
|
307
461
|
}
|
|
@@ -309,14 +463,147 @@ async function parseSpTree(
|
|
|
309
463
|
const el = parseCxn(cxn, ctx, outer);
|
|
310
464
|
if (el) out.push(el);
|
|
311
465
|
}
|
|
312
|
-
for (const gf of asArray(spTree["p:graphicFrame"])) {
|
|
313
|
-
const el = parseGraphicFrame(gf, ctx, outer);
|
|
314
|
-
if (el) out.push(el);
|
|
315
|
-
}
|
|
316
466
|
for (const grp of asArray(spTree["p:grpSp"])) {
|
|
317
467
|
const inner = composeGroupTransform(grp, outer);
|
|
318
|
-
|
|
319
|
-
|
|
468
|
+
out.push(...(await walkUnderlay(grp, ctx, inner, slidePhKeys)));
|
|
469
|
+
}
|
|
470
|
+
return out;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function collectSlidePlaceholderKeys(spTree: any): Set<string> {
|
|
474
|
+
const keys = new Set<string>();
|
|
475
|
+
if (!spTree) return keys;
|
|
476
|
+
const visit = (tree: any) => {
|
|
477
|
+
if (!tree) return;
|
|
478
|
+
for (const sp of asArray(tree["p:sp"])) {
|
|
479
|
+
const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
|
|
480
|
+
if (ph) keys.add(placeholderKey(ph));
|
|
481
|
+
}
|
|
482
|
+
for (const pic of asArray(tree["p:pic"])) {
|
|
483
|
+
const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
|
|
484
|
+
if (ph) keys.add(placeholderKey(ph));
|
|
485
|
+
}
|
|
486
|
+
for (const grp of asArray(tree["p:grpSp"])) visit(grp);
|
|
487
|
+
};
|
|
488
|
+
visit(spTree);
|
|
489
|
+
return keys;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function isHiddenNode(node: any, nvKey: "p:nvSpPr" | "p:nvPicPr"): boolean {
|
|
493
|
+
const cNvPr = node?.[nvKey]?.["p:cNvPr"];
|
|
494
|
+
return cNvPr?.["@_hidden"] === "1" || cNvPr?.["@_hidden"] === 1;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Resolve a shape's <p:style><a:fillRef idx="N">...<a:schemeClr/></a:fillRef>
|
|
499
|
+
* against the theme's fillStyleLst. idx=0 = noFill; 1+ indexes the fillStyleLst
|
|
500
|
+
* children; 1001+ indexes bgFillStyleLst (rarely used here). The colour child
|
|
501
|
+
* inside fillRef plays the role of phClr in the theme fill template.
|
|
502
|
+
*/
|
|
503
|
+
function resolveStyleFillRef(sp: any, ctx: ParseContext): string | undefined {
|
|
504
|
+
const fillRef = sp?.["p:style"]?.["a:fillRef"];
|
|
505
|
+
if (!fillRef) return undefined;
|
|
506
|
+
const idx = Number(fillRef["@_idx"]);
|
|
507
|
+
if (!Number.isFinite(idx) || idx === 0) return undefined;
|
|
508
|
+
const list = idx >= 1000 ? ctx.themeFills.bg : ctx.themeFills.fg;
|
|
509
|
+
const entry = list[(idx >= 1000 ? idx - 1001 : idx - 1)];
|
|
510
|
+
if (!entry) return undefined;
|
|
511
|
+
const phColor = readBaseHex(fillRef, ctx.theme);
|
|
512
|
+
if (entry.kind === "solidFill") {
|
|
513
|
+
return resolveColor(substitutePhClr(entry.node, phColor), ctx.theme);
|
|
514
|
+
}
|
|
515
|
+
if (entry.kind === "gradFill") {
|
|
516
|
+
return extractShapeFill(
|
|
517
|
+
{ "a:gradFill": substitutePhClr(entry.node, phColor) },
|
|
518
|
+
ctx.theme
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
if (entry.kind === "noFill") return "transparent";
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function placeholderFillUnderlay(
|
|
526
|
+
sp: any,
|
|
527
|
+
ctx: ParseContext,
|
|
528
|
+
outer: GroupTransform = identityTransform()
|
|
529
|
+
): Promise<ShapeElement | null> {
|
|
530
|
+
const spPr = sp?.["p:spPr"];
|
|
531
|
+
if (!spPr) return null;
|
|
532
|
+
const fill = extractShapeFill(spPr, ctx.theme);
|
|
533
|
+
if (!fill || fill === "transparent") return null;
|
|
534
|
+
const geom = readGeometry(spPr["a:xfrm"], ctx.fit, outer);
|
|
535
|
+
if (!geom) return null;
|
|
536
|
+
return {
|
|
537
|
+
id: nanoid(8),
|
|
538
|
+
type: "shape",
|
|
539
|
+
...geom,
|
|
540
|
+
z: 0,
|
|
541
|
+
shape: "rect",
|
|
542
|
+
fill,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
interface GroupTransform {
|
|
547
|
+
/** Linear transform for child raw-px coordinates: x' = a*x + c, y' = b*y + d. */
|
|
548
|
+
a: number;
|
|
549
|
+
b: number;
|
|
550
|
+
c: number;
|
|
551
|
+
d: number;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function identityTransform(): GroupTransform {
|
|
555
|
+
return { a: 1, b: 1, c: 0, d: 0 };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function parseSpTree(
|
|
559
|
+
spTree: any,
|
|
560
|
+
ctx: ParseContext,
|
|
561
|
+
outer: GroupTransform
|
|
562
|
+
): Promise<SlideElement[]> {
|
|
563
|
+
const out: SlideElement[] = [];
|
|
564
|
+
// Cursor per tag — we pop from each parsed array as we encounter its tag
|
|
565
|
+
// in the document-order list, so the same elements get visited in the
|
|
566
|
+
// order they appeared in the source XML (which determines z-index).
|
|
567
|
+
const cursors: Record<string, number> = {
|
|
568
|
+
"p:sp": 0,
|
|
569
|
+
"p:pic": 0,
|
|
570
|
+
"p:cxnSp": 0,
|
|
571
|
+
"p:graphicFrame": 0,
|
|
572
|
+
"p:grpSp": 0,
|
|
573
|
+
};
|
|
574
|
+
const order: string[] = (spTree as any)?._childOrder ?? [
|
|
575
|
+
// Fall back to legacy tag-grouped order when raw isn't attached
|
|
576
|
+
// (e.g. tests that build a parsed structure by hand).
|
|
577
|
+
...asArray(spTree["p:sp"]).map(() => "p:sp"),
|
|
578
|
+
...asArray(spTree["p:pic"]).map(() => "p:pic"),
|
|
579
|
+
...asArray(spTree["p:cxnSp"]).map(() => "p:cxnSp"),
|
|
580
|
+
...asArray(spTree["p:graphicFrame"]).map(() => "p:graphicFrame"),
|
|
581
|
+
...asArray(spTree["p:grpSp"]).map(() => "p:grpSp"),
|
|
582
|
+
];
|
|
583
|
+
|
|
584
|
+
for (const tag of order) {
|
|
585
|
+
if (!(tag in cursors)) continue;
|
|
586
|
+
const arr = asArray((spTree as any)[tag]);
|
|
587
|
+
const idx = cursors[tag]++;
|
|
588
|
+
const node = arr[idx];
|
|
589
|
+
if (!node) continue;
|
|
590
|
+
if (tag === "p:sp") {
|
|
591
|
+
const el = await parseSpOrText(node, ctx, outer);
|
|
592
|
+
if (el) out.push(el);
|
|
593
|
+
} else if (tag === "p:pic") {
|
|
594
|
+
const el = await parsePic(node, ctx, outer);
|
|
595
|
+
if (el) out.push(el);
|
|
596
|
+
} else if (tag === "p:cxnSp") {
|
|
597
|
+
const el = parseCxn(node, ctx, outer);
|
|
598
|
+
if (el) out.push(el);
|
|
599
|
+
} else if (tag === "p:graphicFrame") {
|
|
600
|
+
const el = parseGraphicFrame(node, ctx, outer);
|
|
601
|
+
if (el) out.push(el);
|
|
602
|
+
} else if (tag === "p:grpSp") {
|
|
603
|
+
const inner = composeGroupTransform(node, outer);
|
|
604
|
+
const children = await parseSpTree(node, ctx, inner);
|
|
605
|
+
out.push(...children);
|
|
606
|
+
}
|
|
320
607
|
}
|
|
321
608
|
return out;
|
|
322
609
|
}
|
|
@@ -367,7 +654,8 @@ function composeGroupTransform(grp: any, outer: GroupTransform): GroupTransform
|
|
|
367
654
|
async function parseSpOrText(
|
|
368
655
|
sp: any,
|
|
369
656
|
ctx: ParseContext,
|
|
370
|
-
outer: GroupTransform
|
|
657
|
+
outer: GroupTransform,
|
|
658
|
+
opts: { underlay?: boolean } = {}
|
|
371
659
|
): Promise<SlideElement | null> {
|
|
372
660
|
const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
|
|
373
661
|
const phKey = ph ? placeholderKey(ph) : null;
|
|
@@ -386,6 +674,8 @@ async function parseSpOrText(
|
|
|
386
674
|
const txBody = sp["p:txBody"];
|
|
387
675
|
const prstGeom = sp?.["p:spPr"]?.["a:prstGeom"];
|
|
388
676
|
const presetName = prstGeom?.["@_prst"];
|
|
677
|
+
const custGeom = sp?.["p:spPr"]?.["a:custGeom"];
|
|
678
|
+
const customPath = custGeom ? parseCustGeomPath(custGeom) : undefined;
|
|
389
679
|
|
|
390
680
|
// Lines are sometimes authored as <p:sp prst="line">.
|
|
391
681
|
if (presetName === "line" || presetName === "straightConnector1") {
|
|
@@ -401,10 +691,13 @@ async function parseSpOrText(
|
|
|
401
691
|
const phType = ph?.["@_type"];
|
|
402
692
|
const isPlaceholderTextHost = !!ph && phType !== "pic";
|
|
403
693
|
const hasText = !!txBody && hasAnyText(txBody);
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
694
|
+
// Treat as text when the element actually carries text OR when it's a
|
|
695
|
+
// placeholder text host with no preset geometry override. A
|
|
696
|
+
// non-placeholder shape with an empty <p:txBody> (commonly authored
|
|
697
|
+
// around <a:custGeom> graphics like brand icons) is a SHAPE — promoting
|
|
698
|
+
// it to a text element would drop the silhouette and fill.
|
|
699
|
+
const isText = hasText || (isPlaceholderTextHost && !presetName);
|
|
700
|
+
void opts;
|
|
408
701
|
|
|
409
702
|
if (isText) {
|
|
410
703
|
return makeTextElement(sp, txBody, geom, ctx, ph, layoutPh, masterPh);
|
|
@@ -412,7 +705,10 @@ async function parseSpOrText(
|
|
|
412
705
|
|
|
413
706
|
// Fill / stroke. Use placeholder-inherited spPr if slide spPr is empty.
|
|
414
707
|
const spPr = sp?.["p:spPr"];
|
|
415
|
-
const fillColor =
|
|
708
|
+
const fillColor =
|
|
709
|
+
extractShapeFill(spPr, ctx.theme)
|
|
710
|
+
?? resolveStyleFillRef(sp, ctx)
|
|
711
|
+
?? "transparent";
|
|
416
712
|
const lineProps = spPr?.["a:ln"];
|
|
417
713
|
const lineHasNoFill = lineProps?.["a:noFill"] !== undefined;
|
|
418
714
|
const stroke = lineHasNoFill
|
|
@@ -427,7 +723,9 @@ async function parseSpOrText(
|
|
|
427
723
|
if (!kind) {
|
|
428
724
|
// Fall back to a rect with the shape's fill so it remains visible at the
|
|
429
725
|
// correct position rather than dropping to an opaque "Imported content"
|
|
430
|
-
// tile.
|
|
726
|
+
// tile. When the source carried a <a:custGeom> path we attach it so the
|
|
727
|
+
// renderer draws the actual silhouette (logos, brand marks) instead of
|
|
728
|
+
// the rectangle stand-in.
|
|
431
729
|
const fallback: ShapeElement = {
|
|
432
730
|
id: nanoid(8),
|
|
433
731
|
type: "shape",
|
|
@@ -439,6 +737,7 @@ async function parseSpOrText(
|
|
|
439
737
|
strokeWidth: strokeWidthEmu
|
|
440
738
|
? Math.max(1, Math.round(emuToPx(strokeWidthEmu) * ctx.fit.scale))
|
|
441
739
|
: undefined,
|
|
740
|
+
...(customPath ? { path: customPath } : {}),
|
|
442
741
|
};
|
|
443
742
|
return fallback;
|
|
444
743
|
}
|
|
@@ -473,7 +772,7 @@ async function parseSpOrText(
|
|
|
473
772
|
}
|
|
474
773
|
|
|
475
774
|
function makeTextElement(
|
|
476
|
-
|
|
775
|
+
sp: any,
|
|
477
776
|
txBody: any,
|
|
478
777
|
geom: { x: number; y: number; w: number; h: number; rotation: number },
|
|
479
778
|
ctx: ParseContext,
|
|
@@ -493,31 +792,74 @@ function makeTextElement(
|
|
|
493
792
|
? { "a:bodyPr": masterPh.bodyPr, "a:p": masterPh.paragraphs }
|
|
494
793
|
: txBody;
|
|
495
794
|
|
|
496
|
-
// Master defaults for the placeholder type (title vs body vs other)
|
|
795
|
+
// Master defaults for the placeholder type (title vs body vs other), as
|
|
796
|
+
// an array of lvl1..lvl9 paragraph properties.
|
|
497
797
|
const phType = ph?.["@_type"];
|
|
498
|
-
const
|
|
798
|
+
const masterLevels: (any | undefined)[] =
|
|
499
799
|
phType === "title" || phType === "ctrTitle"
|
|
500
|
-
? ctx.masterTextDefaults.title
|
|
800
|
+
? (ctx.masterTextDefaults.title ?? [])
|
|
501
801
|
: phType === "body" || phType === "subTitle"
|
|
502
|
-
? ctx.masterTextDefaults.body
|
|
503
|
-
: ctx.masterTextDefaults.other;
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
802
|
+
? (ctx.masterTextDefaults.body ?? [])
|
|
803
|
+
: (ctx.masterTextDefaults.other ?? []);
|
|
804
|
+
const masterLvl1 = masterLevels[0];
|
|
805
|
+
|
|
806
|
+
// Accumulate inheritance: slide < layout < master < masterDefaults. Each
|
|
807
|
+
// level can specify just a subset of fields (the layout might set the
|
|
808
|
+
// typeface while only the master defines the colour), so merge field by
|
|
809
|
+
// field with earlier candidates winning.
|
|
810
|
+
const fallbackRPr = mergeRPrChain(
|
|
507
811
|
layoutPh?.rPr,
|
|
508
812
|
masterPh?.rPr,
|
|
509
|
-
|
|
813
|
+
masterLvl1?.["a:defRPr"]
|
|
510
814
|
);
|
|
511
815
|
const fallbackPPr = mergeFirst(
|
|
512
816
|
layoutPh?.pPr,
|
|
513
817
|
masterPh?.pPr,
|
|
514
|
-
|
|
818
|
+
masterLvl1
|
|
515
819
|
);
|
|
516
820
|
const fallbackBodyPr = mergeFirst(layoutPh?.bodyPr, masterPh?.bodyPr);
|
|
517
821
|
|
|
518
|
-
|
|
822
|
+
// Resolve a per-level [layoutLvl, masterPhLvl, masterTxStyleLvl] chain so
|
|
823
|
+
// bullet/alignment/lineSpacing each fall through independently when an
|
|
824
|
+
// earlier layer is silent on that particular field.
|
|
825
|
+
const listStyle: (any | undefined)[][] = [];
|
|
826
|
+
for (let i = 0; i < 9; i++) {
|
|
827
|
+
const chain = [
|
|
828
|
+
layoutPh?.lvlPPr?.[i],
|
|
829
|
+
masterPh?.lvlPPr?.[i],
|
|
830
|
+
masterLevels[i],
|
|
831
|
+
].filter(Boolean);
|
|
832
|
+
listStyle.push(chain);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// <a:bodyPr><a:normAutofit fontScale="..." lnSpcReduction="..."/> shrinks
|
|
836
|
+
// text that overflowed when authored — apply so wraps don't push runs off
|
|
837
|
+
// the slide.
|
|
838
|
+
const autoFit = readNormAutofit(
|
|
839
|
+
effectiveTxBody?.["a:bodyPr"] ?? fallbackBodyPr
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
const text = extractRuns(
|
|
843
|
+
effectiveTxBody,
|
|
844
|
+
ctx.theme,
|
|
845
|
+
fallbackRPr,
|
|
846
|
+
fallbackPPr,
|
|
847
|
+
ctx.themeFonts,
|
|
848
|
+
listStyle,
|
|
849
|
+
autoFit
|
|
850
|
+
);
|
|
519
851
|
const first = text.runs[0];
|
|
520
|
-
|
|
852
|
+
// Each layer of the inheritance chain may set @algn independently — a
|
|
853
|
+
// layout placeholder can override geometry without touching alignment,
|
|
854
|
+
// expecting the master's algn="r" (slide-number, page-footer right-edge
|
|
855
|
+
// style) to still apply. mergeFirst would lock onto the layout's whole
|
|
856
|
+
// pPr and hide the master's algn, so check each layer in turn.
|
|
857
|
+
const align =
|
|
858
|
+
text.align ??
|
|
859
|
+
readAlign(layoutPh?.pPr) ??
|
|
860
|
+
readAlign(masterPh?.pPr) ??
|
|
861
|
+
readAlign(masterLvl1) ??
|
|
862
|
+
"left";
|
|
521
863
|
const valign =
|
|
522
864
|
readBodyVAlign(effectiveTxBody?.["a:bodyPr"]) ??
|
|
523
865
|
readBodyVAlign(fallbackBodyPr) ??
|
|
@@ -529,7 +871,10 @@ function makeTextElement(
|
|
|
529
871
|
: Math.round(defaultFontSizePx(phType, ctx) * scale);
|
|
530
872
|
const fontFamily =
|
|
531
873
|
first?.fontFamily ??
|
|
532
|
-
|
|
874
|
+
resolveFontFamily(
|
|
875
|
+
fallbackRPr?.["a:latin"]?.["@_typeface"],
|
|
876
|
+
ctx.themeFonts
|
|
877
|
+
) ??
|
|
533
878
|
"Inter";
|
|
534
879
|
const fontWeight = first?.bold ? 700 : 400;
|
|
535
880
|
const color =
|
|
@@ -585,6 +930,65 @@ function makeTextElement(
|
|
|
585
930
|
: 0,
|
|
586
931
|
...(hasMixedFormatting ? { runs } : {}),
|
|
587
932
|
};
|
|
933
|
+
// Inner padding from <a:bodyPr lIns/tIns/rIns/bIns>. PowerPoint applies
|
|
934
|
+
// these as text-box insets (the typographic equivalent of CSS padding).
|
|
935
|
+
// Default values in OOXML are 91440 / 45720 / 91440 / 45720 EMU. The
|
|
936
|
+
// slide often carries an empty <a:bodyPr/> that should silently inherit
|
|
937
|
+
// each attribute from the layout/master, so we read per-field rather
|
|
938
|
+
// than swap whole bodyPr objects.
|
|
939
|
+
const slideBp = effectiveTxBody?.["a:bodyPr"];
|
|
940
|
+
const layoutBp = layoutPh?.bodyPr;
|
|
941
|
+
const masterBp = masterPh?.bodyPr;
|
|
942
|
+
const readIns = (key: string, fallback: number): number => {
|
|
943
|
+
const v =
|
|
944
|
+
slideBp?.[`@_${key}`] ??
|
|
945
|
+
layoutBp?.[`@_${key}`] ??
|
|
946
|
+
masterBp?.[`@_${key}`];
|
|
947
|
+
return v !== undefined ? Number(v) : fallback;
|
|
948
|
+
};
|
|
949
|
+
const lIns = readIns("lIns", 91440);
|
|
950
|
+
const tIns = readIns("tIns", 45720);
|
|
951
|
+
const rIns = readIns("rIns", 91440);
|
|
952
|
+
const bIns = readIns("bIns", 45720);
|
|
953
|
+
const padding = {
|
|
954
|
+
l: Math.round(emuToPx(lIns) * ctx.fit.scale),
|
|
955
|
+
t: Math.round(emuToPx(tIns) * ctx.fit.scale),
|
|
956
|
+
r: Math.round(emuToPx(rIns) * ctx.fit.scale),
|
|
957
|
+
b: Math.round(emuToPx(bIns) * ctx.fit.scale),
|
|
958
|
+
};
|
|
959
|
+
if (padding.l || padding.t || padding.r || padding.b) {
|
|
960
|
+
el.padding = padding;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Layout placeholders often supply a fill (e.g. a tinted body box) or a
|
|
964
|
+
// <a:custGeom> path (a white brand-logo plate) that should sit
|
|
965
|
+
// *immediately* behind the slide's hosted text — at the same z, not in
|
|
966
|
+
// the underlay. Otherwise a full-bleed image on the slide will cover the
|
|
967
|
+
// backing. The slide's own <p:spPr> can also override the fill on a
|
|
968
|
+
// per-element basis (e.g. one chip in a roadmap is the "active" red
|
|
969
|
+
// tile) — read the slide's spPr first and fall back to the layout's.
|
|
970
|
+
const slideSpPr = sp?.["p:spPr"];
|
|
971
|
+
const layoutSpPr = layoutPh?.spPr;
|
|
972
|
+
const slideFill = slideSpPr ? extractShapeFill(slideSpPr, ctx.theme) : undefined;
|
|
973
|
+
const layoutFill = layoutSpPr ? extractShapeFill(layoutSpPr, ctx.theme) : undefined;
|
|
974
|
+
const phFill = slideFill ?? layoutFill;
|
|
975
|
+
const phSpPr = layoutSpPr;
|
|
976
|
+
const phPath = phSpPr?.["a:custGeom"]
|
|
977
|
+
? parseCustGeomPath(phSpPr["a:custGeom"])
|
|
978
|
+
: undefined;
|
|
979
|
+
if (phPath && phFill && phFill !== "transparent") {
|
|
980
|
+
// custGeom defines the actual rendered silhouette; the fill applies to
|
|
981
|
+
// it. Skip the flat background and render the glyph as a backing path.
|
|
982
|
+
el.backingPath = {
|
|
983
|
+
d: phPath.d,
|
|
984
|
+
viewW: phPath.viewW,
|
|
985
|
+
viewH: phPath.viewH,
|
|
986
|
+
fill: phFill,
|
|
987
|
+
fillRule: phPath.fillRule,
|
|
988
|
+
};
|
|
989
|
+
} else if (phFill && phFill !== "transparent") {
|
|
990
|
+
el.background = phFill;
|
|
991
|
+
}
|
|
588
992
|
return el;
|
|
589
993
|
}
|
|
590
994
|
|
|
@@ -601,10 +1005,25 @@ async function parsePic(
|
|
|
601
1005
|
outer: GroupTransform
|
|
602
1006
|
): Promise<SlideElement | null> {
|
|
603
1007
|
const xfrm = pic?.["p:spPr"]?.["a:xfrm"];
|
|
604
|
-
|
|
1008
|
+
// Picture placeholders (<p:ph type="pic">) often omit xfrm — inherit
|
|
1009
|
+
// geometry from the layout/master placeholder of the same key.
|
|
1010
|
+
const ph = pic?.["p:nvPicPr"]?.["p:nvPr"]?.["p:ph"];
|
|
1011
|
+
const layoutPh = ph ? lookupPlaceholder(ctx.layoutPh, ph) : undefined;
|
|
1012
|
+
const masterPh = ph ? lookupPlaceholder(ctx.masterPh, ph) : undefined;
|
|
1013
|
+
const geom =
|
|
1014
|
+
readGeometry(xfrm, ctx.fit, outer)
|
|
1015
|
+
?? placeholderGeometry(layoutPh, ctx.fit, outer)
|
|
1016
|
+
?? placeholderGeometry(masterPh, ctx.fit, outer);
|
|
605
1017
|
if (!geom) return toUnknown(pic, "p:pic", ctx, outer);
|
|
606
1018
|
|
|
607
|
-
|
|
1019
|
+
// Modern PPTX embeds SVGs via a dual-blip: <a:blip r:embed="rId_png">…
|
|
1020
|
+
// <a:extLst><a:ext uri="…"><asvg:svgBlip r:embed="rId_svg"/></a:ext></a:extLst>
|
|
1021
|
+
// </a:blip>. The outer embed is the raster fallback; prefer the SVG when
|
|
1022
|
+
// present so vector logos stay sharp.
|
|
1023
|
+
const blip = pic?.["p:blipFill"]?.["a:blip"];
|
|
1024
|
+
const svgRef = findSvgBlipRef(blip);
|
|
1025
|
+
const rasterRef = blip?.["@_r:embed"];
|
|
1026
|
+
const blipRef = svgRef ?? rasterRef;
|
|
608
1027
|
if (!blipRef) return toUnknown(pic, "p:pic", ctx, outer);
|
|
609
1028
|
|
|
610
1029
|
const mediaPath = ctx.slideRels.byId.get(blipRef)?.target;
|
|
@@ -614,8 +1033,20 @@ async function parsePic(
|
|
|
614
1033
|
const file = ctx.zip.file(fullPath);
|
|
615
1034
|
if (!file) return toUnknown(pic, "p:pic", ctx, outer);
|
|
616
1035
|
|
|
617
|
-
const base64 = await file.async("base64");
|
|
618
1036
|
const ext = (fullPath.split(".").pop() || "png").toLowerCase();
|
|
1037
|
+
// EMF / WMF are Microsoft vector formats that browsers can't render
|
|
1038
|
+
// natively. Skip them with a diagnostic — emitting them as
|
|
1039
|
+
// <img src="data:application/octet-stream…"> surfaces a broken-image
|
|
1040
|
+
// icon, and synthesising a fake placeholder only hides the gap.
|
|
1041
|
+
// Consumers needing fidelity should pre-rasterise the metafiles before
|
|
1042
|
+
// import; a true EMF→SVG path needs a dedicated JS decoder (separate PR).
|
|
1043
|
+
if (ext === "emf" || ext === "wmf") {
|
|
1044
|
+
ctx.diagnostics.warnings.push(
|
|
1045
|
+
`Skipped ${ext.toUpperCase()} image at ${fullPath} — vector metafiles aren't supported in the browser.`
|
|
1046
|
+
);
|
|
1047
|
+
return null;
|
|
1048
|
+
}
|
|
1049
|
+
const base64 = await file.async("base64");
|
|
619
1050
|
const mime = mimeForExt(ext);
|
|
620
1051
|
|
|
621
1052
|
const blipFill = pic?.["p:blipFill"];
|
|
@@ -646,6 +1077,21 @@ async function parsePic(
|
|
|
646
1077
|
return image;
|
|
647
1078
|
}
|
|
648
1079
|
|
|
1080
|
+
/**
|
|
1081
|
+
* Pull the SVG blip rId from a:blip/a:extLst/a:ext/asvg:svgBlip if present.
|
|
1082
|
+
* Returns undefined when the picture is raster-only.
|
|
1083
|
+
*/
|
|
1084
|
+
function findSvgBlipRef(blip: any): string | undefined {
|
|
1085
|
+
if (!blip) return undefined;
|
|
1086
|
+
const exts = asArray(blip?.["a:extLst"]?.["a:ext"]);
|
|
1087
|
+
for (const ext of exts) {
|
|
1088
|
+
const svg = ext?.["asvg:svgBlip"];
|
|
1089
|
+
const ref = svg?.["@_r:embed"];
|
|
1090
|
+
if (ref) return ref;
|
|
1091
|
+
}
|
|
1092
|
+
return undefined;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
649
1095
|
function parseCxn(
|
|
650
1096
|
cxn: any,
|
|
651
1097
|
ctx: ParseContext,
|
|
@@ -746,7 +1192,7 @@ function parseTable(
|
|
|
746
1192
|
}
|
|
747
1193
|
const txBody = tc["a:txBody"];
|
|
748
1194
|
const text = txBody
|
|
749
|
-
? extractRuns(txBody, ctx.theme)
|
|
1195
|
+
? extractRuns(txBody, ctx.theme, undefined, undefined, ctx.themeFonts)
|
|
750
1196
|
: { plain: "", runs: [] as RunInfo[] };
|
|
751
1197
|
cells.push(text.plain);
|
|
752
1198
|
|
|
@@ -844,11 +1290,24 @@ function lookupPlaceholder(
|
|
|
844
1290
|
map.get(`${type}|${idx}`) ??
|
|
845
1291
|
map.get(`|${idx}`) ??
|
|
846
1292
|
map.get(`${type}|`) ??
|
|
847
|
-
(
|
|
848
|
-
(type === "
|
|
1293
|
+
findByType(map, type) ??
|
|
1294
|
+
(type === "ctrTitle" ? findByType(map, "title") : undefined) ??
|
|
1295
|
+
(type === "subTitle" ? findByType(map, "body") : undefined)
|
|
849
1296
|
);
|
|
850
1297
|
}
|
|
851
1298
|
|
|
1299
|
+
function findByType(
|
|
1300
|
+
map: Map<string, PlaceholderInfo>,
|
|
1301
|
+
type: string
|
|
1302
|
+
): PlaceholderInfo | undefined {
|
|
1303
|
+
if (!type) return undefined;
|
|
1304
|
+
const prefix = `${type}|`;
|
|
1305
|
+
for (const [key, value] of map) {
|
|
1306
|
+
if (key.startsWith(prefix)) return value;
|
|
1307
|
+
}
|
|
1308
|
+
return undefined;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
852
1311
|
function placeholderGeometry(
|
|
853
1312
|
ph: PlaceholderInfo | undefined,
|
|
854
1313
|
fit: Fit,
|
|
@@ -883,36 +1342,148 @@ function extractPlaceholders(rootXml: any): Map<string, PlaceholderInfo> {
|
|
|
883
1342
|
const paragraphs = asArray(txBody?.["a:p"]);
|
|
884
1343
|
const firstP = paragraphs[0];
|
|
885
1344
|
const firstR = asArray(firstP?.["a:r"])[0];
|
|
1345
|
+
// Default run/paragraph properties live on the placeholder's
|
|
1346
|
+
// <a:lstStyle><a:lvl1pPr> (font, size, colour, etc). A stub <a:r><a:rPr/>
|
|
1347
|
+
// with just `lang` carries no real style — prefer the lstStyle defaults
|
|
1348
|
+
// over an empty inline rPr so the layout's typography reaches the slide.
|
|
1349
|
+
const lstStyle = txBody?.["a:lstStyle"];
|
|
1350
|
+
const lvl1 = lstStyle?.["a:lvl1pPr"];
|
|
1351
|
+
const lvlPPr = collectLevelPPrs(lstStyle);
|
|
1352
|
+
const stubRPr = firstR?.["a:rPr"];
|
|
886
1353
|
const info: PlaceholderInfo = {
|
|
887
1354
|
rawX: off ? emuToPx(Number(off["@_x"] ?? 0)) : undefined,
|
|
888
1355
|
rawY: off ? emuToPx(Number(off["@_y"] ?? 0)) : undefined,
|
|
889
1356
|
rawW: ext ? emuToPx(Number(ext["@_cx"] ?? 0)) : undefined,
|
|
890
1357
|
rawH: ext ? emuToPx(Number(ext["@_cy"] ?? 0)) : undefined,
|
|
891
1358
|
rotation: xfrm?.["@_rot"] ? Number(xfrm["@_rot"]) / 60000 : 0,
|
|
892
|
-
rPr:
|
|
893
|
-
|
|
1359
|
+
rPr: pickMeaningful(
|
|
1360
|
+
stubRPr,
|
|
1361
|
+
lvl1?.["a:defRPr"],
|
|
1362
|
+
firstP?.["a:pPr"]?.["a:defRPr"]
|
|
1363
|
+
),
|
|
1364
|
+
pPr: lvl1 ?? firstP?.["a:pPr"],
|
|
894
1365
|
bodyPr: txBody?.["a:bodyPr"],
|
|
1366
|
+
lvlPPr: lvlPPr.some(Boolean) ? lvlPPr : undefined,
|
|
895
1367
|
paragraphs: hasAnyText(txBody) ? paragraphs : undefined,
|
|
1368
|
+
spPr: sp?.["p:spPr"],
|
|
896
1369
|
};
|
|
897
1370
|
out.set(placeholderKey(ph), info);
|
|
898
1371
|
}
|
|
899
1372
|
return out;
|
|
900
1373
|
}
|
|
901
1374
|
|
|
1375
|
+
/**
|
|
1376
|
+
* Return the first candidate that carries actual style fields (font, size,
|
|
1377
|
+
* colour, weight, italic, underline). An empty `<a:rPr lang="en-GB"/>` looks
|
|
1378
|
+
* truthy but contributes nothing, so it shouldn't shadow a meaningful
|
|
1379
|
+
* lstStyle/defRPr further down the chain.
|
|
1380
|
+
*/
|
|
1381
|
+
function pickMeaningful(...candidates: any[]): any {
|
|
1382
|
+
for (const c of candidates) {
|
|
1383
|
+
if (!c) continue;
|
|
1384
|
+
if (rPrHasStyle(c)) return c;
|
|
1385
|
+
}
|
|
1386
|
+
// Fall back to the first defined value, even if otherwise empty, so we
|
|
1387
|
+
// never regress callers that depend on truthiness.
|
|
1388
|
+
return candidates.find((c) => c !== undefined && c !== null);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
function rPrHasStyle(rPr: any): boolean {
|
|
1392
|
+
if (!rPr || typeof rPr !== "object") return false;
|
|
1393
|
+
return (
|
|
1394
|
+
rPr["@_sz"] !== undefined ||
|
|
1395
|
+
rPr["@_b"] !== undefined ||
|
|
1396
|
+
rPr["@_i"] !== undefined ||
|
|
1397
|
+
rPr["@_u"] !== undefined ||
|
|
1398
|
+
rPr["@_spc"] !== undefined ||
|
|
1399
|
+
rPr["a:latin"] !== undefined ||
|
|
1400
|
+
rPr["a:solidFill"] !== undefined
|
|
1401
|
+
);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
902
1404
|
function extractMasterTextDefaults(masterXml: any): MasterTextDefaults {
|
|
903
1405
|
const txStyles = masterXml?.["p:sldMaster"]?.["p:txStyles"];
|
|
904
1406
|
if (!txStyles) return {};
|
|
905
1407
|
return {
|
|
906
|
-
title: txStyles?.["p:titleStyle"]
|
|
907
|
-
body: txStyles?.["p:bodyStyle"]
|
|
908
|
-
other: txStyles?.["p:otherStyle"]
|
|
1408
|
+
title: collectLevelPPrs(txStyles?.["p:titleStyle"]),
|
|
1409
|
+
body: collectLevelPPrs(txStyles?.["p:bodyStyle"]),
|
|
1410
|
+
other: collectLevelPPrs(txStyles?.["p:otherStyle"]),
|
|
909
1411
|
};
|
|
910
1412
|
}
|
|
911
1413
|
|
|
1414
|
+
function collectLevelPPrs(style: any): (any | undefined)[] {
|
|
1415
|
+
if (!style) return [];
|
|
1416
|
+
const out: (any | undefined)[] = [];
|
|
1417
|
+
for (let lvl = 1; lvl <= 9; lvl++) {
|
|
1418
|
+
out.push(style[`a:lvl${lvl}pPr`]);
|
|
1419
|
+
}
|
|
1420
|
+
return out;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
912
1423
|
// ---------------------------------------------------------------------------
|
|
913
1424
|
// theme
|
|
914
1425
|
// ---------------------------------------------------------------------------
|
|
915
1426
|
|
|
1427
|
+
function extractThemeFonts(themeXml: any): ThemeFonts {
|
|
1428
|
+
const fs = themeXml?.["a:theme"]?.["a:themeElements"]?.["a:fontScheme"];
|
|
1429
|
+
return {
|
|
1430
|
+
majorLatin: fs?.["a:majorFont"]?.["a:latin"]?.["@_typeface"] || undefined,
|
|
1431
|
+
minorLatin: fs?.["a:minorFont"]?.["a:latin"]?.["@_typeface"] || undefined,
|
|
1432
|
+
};
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
function extractClrMap(node: any): ClrMap {
|
|
1436
|
+
if (!node) return DEFAULT_CLR_MAP;
|
|
1437
|
+
const pick = (attr: string, fallback: ClrMap[keyof ClrMap]) => {
|
|
1438
|
+
const v = node[`@_${attr}`];
|
|
1439
|
+
return isThemeKey(v) ? (v as ClrMap[keyof ClrMap]) : fallback;
|
|
1440
|
+
};
|
|
1441
|
+
return {
|
|
1442
|
+
bg1: pick("bg1", DEFAULT_CLR_MAP.bg1),
|
|
1443
|
+
bg2: pick("bg2", DEFAULT_CLR_MAP.bg2),
|
|
1444
|
+
tx1: pick("tx1", DEFAULT_CLR_MAP.tx1),
|
|
1445
|
+
tx2: pick("tx2", DEFAULT_CLR_MAP.tx2),
|
|
1446
|
+
};
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
const THEME_KEYS = new Set([
|
|
1450
|
+
"dk1",
|
|
1451
|
+
"lt1",
|
|
1452
|
+
"dk2",
|
|
1453
|
+
"lt2",
|
|
1454
|
+
"accent1",
|
|
1455
|
+
"accent2",
|
|
1456
|
+
"accent3",
|
|
1457
|
+
"accent4",
|
|
1458
|
+
"accent5",
|
|
1459
|
+
"accent6",
|
|
1460
|
+
"hlink",
|
|
1461
|
+
"folHlink",
|
|
1462
|
+
]);
|
|
1463
|
+
|
|
1464
|
+
function isThemeKey(v: unknown): v is keyof ThemeColors {
|
|
1465
|
+
return typeof v === "string" && THEME_KEYS.has(v);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* Resolve OOXML major/minor font tokens (`+mj-lt`, `+mn-lt`, …) against the
|
|
1470
|
+
* theme's font scheme. Returns the original value when it isn't a token, and
|
|
1471
|
+
* `undefined` when the token can't be resolved.
|
|
1472
|
+
*/
|
|
1473
|
+
function resolveFontFamily(
|
|
1474
|
+
raw: string | undefined,
|
|
1475
|
+
fonts: ThemeFonts
|
|
1476
|
+
): string | undefined {
|
|
1477
|
+
if (!raw) return undefined;
|
|
1478
|
+
if (raw === "+mj-lt" || raw === "+mj-ea" || raw === "+mj-cs") {
|
|
1479
|
+
return fonts.majorLatin;
|
|
1480
|
+
}
|
|
1481
|
+
if (raw === "+mn-lt" || raw === "+mn-ea" || raw === "+mn-cs") {
|
|
1482
|
+
return fonts.minorLatin;
|
|
1483
|
+
}
|
|
1484
|
+
return raw;
|
|
1485
|
+
}
|
|
1486
|
+
|
|
916
1487
|
function extractTheme(themeXml: any): ThemeColors {
|
|
917
1488
|
const scheme =
|
|
918
1489
|
themeXml?.["a:theme"]?.["a:themeElements"]?.["a:clrScheme"] ?? {};
|
|
@@ -1020,23 +1591,13 @@ function readBaseHex(node: any, theme: ThemeColors): string | undefined {
|
|
|
1020
1591
|
}
|
|
1021
1592
|
|
|
1022
1593
|
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
|
-
}
|
|
1594
|
+
if (token === "phClr") {
|
|
1595
|
+
// Placeholder colour sentinel — caller (substitutePhClr) should already
|
|
1596
|
+
// have replaced this; surface a sensible fallback if it didn't.
|
|
1597
|
+
return theme.dk1;
|
|
1039
1598
|
}
|
|
1599
|
+
const v = (theme as unknown as Record<string, string>)[token];
|
|
1600
|
+
return v ?? "#000000";
|
|
1040
1601
|
}
|
|
1041
1602
|
|
|
1042
1603
|
function resolvePresetColor(name: string): string {
|
|
@@ -1150,6 +1711,33 @@ function extractShapeFill(spPr: any, theme: ThemeColors): string | undefined {
|
|
|
1150
1711
|
(s) => s.color.length === 9 && s.color.endsWith("00")
|
|
1151
1712
|
);
|
|
1152
1713
|
if (allTransparent) return "transparent";
|
|
1714
|
+
// Radial / path gradient: <a:path path="circle|rect|shape"> with
|
|
1715
|
+
// <a:fillToRect> giving the focus rectangle. l/t/r/b are percentage
|
|
1716
|
+
// insets from each edge of the shape; the rect's centre is the focus
|
|
1717
|
+
// point. OOXML radial stops use the SAME convention as CSS — pos=0 at
|
|
1718
|
+
// the centre, pos=100% at the outer boundary — so we keep the stop
|
|
1719
|
+
// positions verbatim. The visual blob lands where fillToRect sits;
|
|
1720
|
+
// on a tall, narrow panel the same radial reads almost vertical, on a
|
|
1721
|
+
// 16:9 slide it reads as the expected red orb fading to purple.
|
|
1722
|
+
const pathNode = gf["a:path"];
|
|
1723
|
+
if (pathNode) {
|
|
1724
|
+
const pathType = pathNode["@_path"];
|
|
1725
|
+
const ftr = pathNode["a:fillToRect"];
|
|
1726
|
+
const lIn = Number(ftr?.["@_l"] ?? 0) / 1000;
|
|
1727
|
+
const tIn = Number(ftr?.["@_t"] ?? 0) / 1000;
|
|
1728
|
+
const rIn = Number(ftr?.["@_r"] ?? 0) / 1000;
|
|
1729
|
+
const bIn = Number(ftr?.["@_b"] ?? 0) / 1000;
|
|
1730
|
+
const focusX = clampPct((lIn + (100 - rIn)) / 2);
|
|
1731
|
+
const focusY = clampPct((tIn + (100 - bIn)) / 2);
|
|
1732
|
+
// path="circle" — a true geometric circle in CSS terms (so the blob
|
|
1733
|
+
// stays round on rectangular shapes); path="rect" — anisotropic
|
|
1734
|
+
// ellipse stretched with the shape's aspect ratio.
|
|
1735
|
+
const shape = pathType === "circle" ? "circle" : "ellipse";
|
|
1736
|
+
const stopsCss = stops
|
|
1737
|
+
.map((s) => `${s.color} ${s.pos.toFixed(2)}%`)
|
|
1738
|
+
.join(", ");
|
|
1739
|
+
return `radial-gradient(${shape} at ${focusX.toFixed(2)}% ${focusY.toFixed(2)}%, ${stopsCss})`;
|
|
1740
|
+
}
|
|
1153
1741
|
const angDeg = gf["a:lin"]?.["@_ang"]
|
|
1154
1742
|
? (Number(gf["a:lin"]["@_ang"]) / 60000 + 90) % 360
|
|
1155
1743
|
: 90;
|
|
@@ -1159,11 +1747,185 @@ function extractShapeFill(spPr: any, theme: ThemeColors): string | undefined {
|
|
|
1159
1747
|
return undefined;
|
|
1160
1748
|
}
|
|
1161
1749
|
|
|
1162
|
-
function
|
|
1163
|
-
return
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1750
|
+
function clampPct(v: number): number {
|
|
1751
|
+
if (!Number.isFinite(v)) return 0;
|
|
1752
|
+
if (v < 0) return 0;
|
|
1753
|
+
if (v > 100) return 100;
|
|
1754
|
+
return v;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
async function extractBackground(
|
|
1758
|
+
bg: any,
|
|
1759
|
+
ctx: ParseContext,
|
|
1760
|
+
rels: Rels,
|
|
1761
|
+
basePath: string
|
|
1762
|
+
): Promise<string | undefined> {
|
|
1763
|
+
if (!bg) return undefined;
|
|
1764
|
+
const bgPr = bg["p:bgPr"];
|
|
1765
|
+
if (bgPr) {
|
|
1766
|
+
if (bgPr["a:noFill"] !== undefined) return "transparent";
|
|
1767
|
+
const solid = resolveColor(bgPr["a:solidFill"], ctx.theme);
|
|
1768
|
+
if (solid) return solid;
|
|
1769
|
+
const grad = extractShapeFill({ "a:gradFill": bgPr["a:gradFill"] }, ctx.theme);
|
|
1770
|
+
if (grad) return grad;
|
|
1771
|
+
const blip = bgPr["a:blipFill"]?.["a:blip"];
|
|
1772
|
+
if (blip) {
|
|
1773
|
+
const url = await blipDataUrl(blip, ctx, rels, basePath);
|
|
1774
|
+
if (url) return `center / cover no-repeat url("${url}")`;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
const bgRef = bg["p:bgRef"];
|
|
1778
|
+
if (bgRef) return resolveBgRef(bgRef, ctx);
|
|
1779
|
+
return undefined;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
async function blipDataUrl(
|
|
1783
|
+
blip: any,
|
|
1784
|
+
ctx: ParseContext,
|
|
1785
|
+
rels: Rels,
|
|
1786
|
+
basePath: string
|
|
1787
|
+
): Promise<string | undefined> {
|
|
1788
|
+
// Prefer the vector embed when present (dual-blip SVG pattern); fall
|
|
1789
|
+
// back to the raster.
|
|
1790
|
+
const svgRef = findSvgBlipRef(blip);
|
|
1791
|
+
const rid = svgRef ?? blip?.["@_r:embed"];
|
|
1792
|
+
if (!rid) return undefined;
|
|
1793
|
+
const target = rels.byId.get(rid)?.target;
|
|
1794
|
+
if (!target) return undefined;
|
|
1795
|
+
const full = normalisePath(target, dirOf(basePath));
|
|
1796
|
+
const file = ctx.zip.file(full);
|
|
1797
|
+
if (!file) return undefined;
|
|
1798
|
+
const base64 = await file.async("base64");
|
|
1799
|
+
const ext = (full.split(".").pop() || "png").toLowerCase();
|
|
1800
|
+
return `data:${mimeForExt(ext)};base64,${base64}`;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
/**
|
|
1804
|
+
* Resolve <p:bgRef idx="..."> against the theme fill lists. idx 1001..1003
|
|
1805
|
+
* indexes a:bgFillStyleLst; idx 1+ indexes a:fillStyleLst (rarely used for
|
|
1806
|
+
* backgrounds). The <p:bgRef> also carries a color child (e.g. schemeClr) that
|
|
1807
|
+
* fills in any <a:schemeClr val="phClr"> placeholders inside the theme fill.
|
|
1808
|
+
*/
|
|
1809
|
+
function resolveBgRef(bgRef: any, ctx: ParseContext): string | undefined {
|
|
1810
|
+
const rawIdx = bgRef?.["@_idx"];
|
|
1811
|
+
if (rawIdx === undefined) return undefined;
|
|
1812
|
+
const idx = Number(rawIdx);
|
|
1813
|
+
if (!Number.isFinite(idx)) return undefined;
|
|
1814
|
+
const list = idx >= 1000 ? ctx.themeFills.bg : ctx.themeFills.fg;
|
|
1815
|
+
const entry = list[(idx >= 1000 ? idx - 1001 : idx - 1)];
|
|
1816
|
+
if (!entry) return undefined;
|
|
1817
|
+
const phColor = readBaseHex(bgRef, ctx.theme);
|
|
1818
|
+
if (entry.kind === "solidFill") {
|
|
1819
|
+
const node = substitutePhClr(entry.node, phColor);
|
|
1820
|
+
return resolveColor(node, ctx.theme);
|
|
1821
|
+
}
|
|
1822
|
+
if (entry.kind === "gradFill") {
|
|
1823
|
+
const node = substitutePhClr(entry.node, phColor);
|
|
1824
|
+
return extractShapeFill({ "a:gradFill": node }, ctx.theme);
|
|
1825
|
+
}
|
|
1826
|
+
// blipFill / pattFill / noFill — not modelled as a slide background yet.
|
|
1827
|
+
if (entry.kind === "noFill") return "transparent";
|
|
1828
|
+
return undefined;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* Replace <a:schemeClr val="phClr"> placeholders inside a theme fill template
|
|
1833
|
+
* with the caller's actual color, so the modifier chain (lumMod, shade, etc.)
|
|
1834
|
+
* still applies. Returns a shallow-cloned tree.
|
|
1835
|
+
*/
|
|
1836
|
+
function substitutePhClr(node: any, phHex: string | undefined): any {
|
|
1837
|
+
if (!phHex) return node;
|
|
1838
|
+
const replacement = phHex.startsWith("#") ? phHex.slice(1) : phHex;
|
|
1839
|
+
const walk = (n: any): any => {
|
|
1840
|
+
if (n == null || typeof n !== "object") return n;
|
|
1841
|
+
if (Array.isArray(n)) return n.map(walk);
|
|
1842
|
+
const out: any = {};
|
|
1843
|
+
for (const k of Object.keys(n)) {
|
|
1844
|
+
if (k === "a:schemeClr" && n[k]?.["@_val"] === "phClr") {
|
|
1845
|
+
const mods: any = { ...n[k] };
|
|
1846
|
+
delete mods["@_val"];
|
|
1847
|
+
out["a:srgbClr"] = { ...mods, "@_val": replacement };
|
|
1848
|
+
} else {
|
|
1849
|
+
out[k] = walk(n[k]);
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
return out;
|
|
1853
|
+
};
|
|
1854
|
+
return walk(node);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
function extractThemeFills(themeXml: any, themeRaw: string): ThemeFills {
|
|
1858
|
+
const fmt = themeXml?.["a:theme"]?.["a:themeElements"]?.["a:fmtScheme"];
|
|
1859
|
+
if (!fmt) return { bg: [], fg: [] };
|
|
1860
|
+
const build = (blockTag: string, parsed: any): ThemeFill[] => {
|
|
1861
|
+
const kinds = extractDirectChildOrder(themeRaw, blockTag);
|
|
1862
|
+
if (!kinds.length || !parsed) return [];
|
|
1863
|
+
const buckets: Record<string, any[]> = {};
|
|
1864
|
+
for (const kind of new Set(kinds)) {
|
|
1865
|
+
buckets[kind] = asArray(parsed[`a:${kind}`]).slice();
|
|
1866
|
+
}
|
|
1867
|
+
const out: ThemeFill[] = [];
|
|
1868
|
+
for (const kind of kinds) {
|
|
1869
|
+
const node = buckets[kind]?.shift();
|
|
1870
|
+
if (node !== undefined) out.push({ kind: kind as ThemeFill["kind"], node });
|
|
1871
|
+
}
|
|
1872
|
+
return out;
|
|
1873
|
+
};
|
|
1874
|
+
return {
|
|
1875
|
+
bg: build("bgFillStyleLst", fmt["a:bgFillStyleLst"]),
|
|
1876
|
+
fg: build("fillStyleLst", fmt["a:fillStyleLst"]),
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
/**
|
|
1881
|
+
* Returns the local tag names of direct children of the first <a:{blockTag}>
|
|
1882
|
+
* in document order. Used to recover the cross-tag-type order that
|
|
1883
|
+
* fast-xml-parser drops when it groups by element name.
|
|
1884
|
+
*/
|
|
1885
|
+
function extractDirectChildOrder(rawXml: string, blockTag: string): string[] {
|
|
1886
|
+
const openRe = new RegExp(`<a:${blockTag}\\b[^>]*>`);
|
|
1887
|
+
const close = `</a:${blockTag}>`;
|
|
1888
|
+
const m = openRe.exec(rawXml);
|
|
1889
|
+
if (!m) return [];
|
|
1890
|
+
const start = m.index + m[0].length;
|
|
1891
|
+
const end = rawXml.indexOf(close, start);
|
|
1892
|
+
if (end < 0) return [];
|
|
1893
|
+
const inner = rawXml.slice(start, end);
|
|
1894
|
+
const tags: string[] = [];
|
|
1895
|
+
let depth = 0;
|
|
1896
|
+
let i = 0;
|
|
1897
|
+
while (i < inner.length) {
|
|
1898
|
+
if (inner[i] !== "<") {
|
|
1899
|
+
i++;
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
if (inner.startsWith("<!--", i)) {
|
|
1903
|
+
const j = inner.indexOf("-->", i);
|
|
1904
|
+
if (j < 0) break;
|
|
1905
|
+
i = j + 3;
|
|
1906
|
+
continue;
|
|
1907
|
+
}
|
|
1908
|
+
if (inner.startsWith("<?", i)) {
|
|
1909
|
+
const j = inner.indexOf("?>", i);
|
|
1910
|
+
if (j < 0) break;
|
|
1911
|
+
i = j + 2;
|
|
1912
|
+
continue;
|
|
1913
|
+
}
|
|
1914
|
+
const closeBracket = inner.indexOf(">", i);
|
|
1915
|
+
if (closeBracket < 0) break;
|
|
1916
|
+
const tag = inner.slice(i, closeBracket + 1);
|
|
1917
|
+
if (tag.startsWith("</")) {
|
|
1918
|
+
depth--;
|
|
1919
|
+
i = closeBracket + 1;
|
|
1920
|
+
continue;
|
|
1921
|
+
}
|
|
1922
|
+
const isSelfClose = tag.endsWith("/>");
|
|
1923
|
+
const nameMatch = /^<a:([\w]+)/.exec(tag);
|
|
1924
|
+
if (depth === 0 && nameMatch) tags.push(nameMatch[1]);
|
|
1925
|
+
if (!isSelfClose) depth++;
|
|
1926
|
+
i = closeBracket + 1;
|
|
1927
|
+
}
|
|
1928
|
+
return tags;
|
|
1167
1929
|
}
|
|
1168
1930
|
|
|
1169
1931
|
function readBodyVAlign(bodyPr: any): "top" | "middle" | "bottom" | undefined {
|
|
@@ -1198,7 +1960,10 @@ function extractRuns(
|
|
|
1198
1960
|
txBody: any,
|
|
1199
1961
|
theme: ThemeColors,
|
|
1200
1962
|
fallbackRPr?: any,
|
|
1201
|
-
fallbackPPr?: any
|
|
1963
|
+
fallbackPPr?: any,
|
|
1964
|
+
themeFonts: ThemeFonts = {},
|
|
1965
|
+
listStyle: (any | undefined)[][] = [],
|
|
1966
|
+
autoFit?: AutoFit
|
|
1202
1967
|
): {
|
|
1203
1968
|
runs: RunInfo[];
|
|
1204
1969
|
plain: string;
|
|
@@ -1210,57 +1975,92 @@ function extractRuns(
|
|
|
1210
1975
|
let lineHeightPct: number | undefined;
|
|
1211
1976
|
const paragraphs = asArray(txBody?.["a:p"]);
|
|
1212
1977
|
const pieces: string[] = [];
|
|
1978
|
+
const autoNumCounters = new Map<number, number>();
|
|
1979
|
+
let prevAutoKey: string | undefined;
|
|
1213
1980
|
|
|
1214
1981
|
for (let pi = 0; pi < paragraphs.length; pi++) {
|
|
1215
1982
|
const p = paragraphs[pi];
|
|
1216
1983
|
const pPr = p?.["a:pPr"];
|
|
1984
|
+
const lvl = clampLevel(Number(pPr?.["@_lvl"] ?? 0));
|
|
1985
|
+
const levelChain = listStyle[lvl] ?? [];
|
|
1986
|
+
const findLevel = (k: string): any =>
|
|
1987
|
+
[pPr, ...levelChain, fallbackPPr].find((s) => s?.[k] !== undefined);
|
|
1217
1988
|
if (!align) {
|
|
1218
|
-
align =
|
|
1989
|
+
align =
|
|
1990
|
+
readAlign(pPr) ??
|
|
1991
|
+
readAlign(levelChain.find((s) => s?.["@_algn"])) ??
|
|
1992
|
+
readAlign(fallbackPPr);
|
|
1219
1993
|
}
|
|
1220
1994
|
if (lineHeightPct === undefined) {
|
|
1221
|
-
const
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1995
|
+
const src = findLevel("a:lnSpc");
|
|
1996
|
+
const lnPct = src?.["a:lnSpc"]?.["a:spcPct"]?.["@_val"];
|
|
1997
|
+
if (lnPct) {
|
|
1998
|
+
const base = Number(lnPct) / 100000;
|
|
1999
|
+
const reduction = autoFit?.lnSpcReduction ?? 0;
|
|
2000
|
+
lineHeightPct = base * (1 - reduction);
|
|
2001
|
+
}
|
|
1225
2002
|
}
|
|
2003
|
+
|
|
2004
|
+
// Resolve bullet across the inheritance chain (slide pPr > layout > master
|
|
2005
|
+
// placeholder > master txStyles). Each layer can specify just the bullet
|
|
2006
|
+
// without overriding everything else.
|
|
2007
|
+
const bullet = resolveBullet([pPr, ...levelChain]);
|
|
2008
|
+
const prefix = computeBulletPrefix(
|
|
2009
|
+
bullet,
|
|
2010
|
+
lvl,
|
|
2011
|
+
autoNumCounters,
|
|
2012
|
+
prevAutoKey
|
|
2013
|
+
);
|
|
2014
|
+
prevAutoKey = prefix.autoKey;
|
|
2015
|
+
|
|
1226
2016
|
const rs = asArray(p?.["a:r"]);
|
|
2017
|
+
const flds = asArray(p?.["a:fld"]);
|
|
2018
|
+
const paraStart = runs.length;
|
|
1227
2019
|
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
|
-
italic: italicVal === "1" || italicVal === 1,
|
|
1258
|
-
underline: underlineVal && underlineVal !== "none",
|
|
1259
|
-
strike: strikeVal === "sngStrike",
|
|
1260
|
-
color,
|
|
1261
|
-
letterSpacing,
|
|
1262
|
-
});
|
|
2020
|
+
if (prefix.text) paragraphText.push(prefix.text);
|
|
2021
|
+
|
|
2022
|
+
const onRun = (r: any) => {
|
|
2023
|
+
const built = buildRunInfo(r, theme, themeFonts, fallbackRPr);
|
|
2024
|
+
if (autoFit?.fontScale && built.run.fontSize) {
|
|
2025
|
+
built.run.fontSize *= autoFit.fontScale;
|
|
2026
|
+
}
|
|
2027
|
+
runs.push(built.run);
|
|
2028
|
+
paragraphText.push(built.text);
|
|
2029
|
+
};
|
|
2030
|
+
|
|
2031
|
+
// <a:br/> hard line breaks AND <a:fld> field placeholders (e.g.
|
|
2032
|
+
// datetime1, slidenum) are siblings of <a:r>; when any are present
|
|
2033
|
+
// walk the paragraph children in document order so they land in the
|
|
2034
|
+
// right place. Otherwise stay on the fast path.
|
|
2035
|
+
if (p?.["a:br"] || p?.["a:fld"]) {
|
|
2036
|
+
const order = paragraphChildOrder(p);
|
|
2037
|
+
for (const entry of order) {
|
|
2038
|
+
if (entry.kind === "br") {
|
|
2039
|
+
if (runs.length) runs[runs.length - 1].text += "\n";
|
|
2040
|
+
paragraphText.push("\n");
|
|
2041
|
+
continue;
|
|
2042
|
+
}
|
|
2043
|
+
const source = entry.kind === "r" ? rs : flds;
|
|
2044
|
+
const r = source[entry.index];
|
|
2045
|
+
if (r) onRun(r);
|
|
2046
|
+
}
|
|
2047
|
+
} else {
|
|
2048
|
+
for (const r of rs) onRun(r);
|
|
1263
2049
|
}
|
|
2050
|
+
|
|
2051
|
+
// Prepend the bullet prefix to the first run of this paragraph so it
|
|
2052
|
+
// survives renderers that walk `runs` instead of the joined `plain` text.
|
|
2053
|
+
if (prefix.text && runs.length > paraStart) {
|
|
2054
|
+
runs[paraStart].text = prefix.text + runs[paraStart].text;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
// Carry the inter-paragraph break onto the last run we just emitted —
|
|
2058
|
+
// renderers that walk `runs` (mixed-formatting path) would otherwise
|
|
2059
|
+
// concatenate paragraphs into one long line.
|
|
2060
|
+
if (pi < paragraphs.length - 1 && runs.length > 0) {
|
|
2061
|
+
runs[runs.length - 1].text += "\n";
|
|
2062
|
+
}
|
|
2063
|
+
|
|
1264
2064
|
pieces.push(paragraphText.join(""));
|
|
1265
2065
|
}
|
|
1266
2066
|
return {
|
|
@@ -1271,11 +2071,419 @@ function extractRuns(
|
|
|
1271
2071
|
};
|
|
1272
2072
|
}
|
|
1273
2073
|
|
|
2074
|
+
interface AutoFit {
|
|
2075
|
+
fontScale?: number; // 0..1
|
|
2076
|
+
lnSpcReduction?: number; // 0..1
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
function readNormAutofit(bodyPr: any): AutoFit | undefined {
|
|
2080
|
+
const af = bodyPr?.["a:normAutofit"];
|
|
2081
|
+
if (!af) return undefined;
|
|
2082
|
+
const fontScale = af["@_fontScale"] ? Number(af["@_fontScale"]) / 100000 : undefined;
|
|
2083
|
+
const lnSpcReduction = af["@_lnSpcReduction"]
|
|
2084
|
+
? Number(af["@_lnSpcReduction"]) / 100000
|
|
2085
|
+
: undefined;
|
|
2086
|
+
if (fontScale === undefined && lnSpcReduction === undefined) return undefined;
|
|
2087
|
+
return { fontScale, lnSpcReduction };
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
interface ResolvedBullet {
|
|
2091
|
+
kind: "none" | "char" | "auto";
|
|
2092
|
+
char?: string;
|
|
2093
|
+
autoType?: string;
|
|
2094
|
+
autoStartAt?: number;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
function resolveBullet(sources: (any | undefined)[]): ResolvedBullet {
|
|
2098
|
+
// First source that defines any of buNone/buChar/buAutoNum wins.
|
|
2099
|
+
for (const src of sources) {
|
|
2100
|
+
if (!src) continue;
|
|
2101
|
+
if (src["a:buNone"] !== undefined) return { kind: "none" };
|
|
2102
|
+
if (src["a:buChar"]?.["@_char"])
|
|
2103
|
+
return { kind: "char", char: String(src["a:buChar"]["@_char"]) };
|
|
2104
|
+
if (src["a:buAutoNum"]) {
|
|
2105
|
+
return {
|
|
2106
|
+
kind: "auto",
|
|
2107
|
+
autoType: src["a:buAutoNum"]["@_type"] ?? "arabicPeriod",
|
|
2108
|
+
autoStartAt: src["a:buAutoNum"]["@_startAt"]
|
|
2109
|
+
? Number(src["a:buAutoNum"]["@_startAt"])
|
|
2110
|
+
: 1,
|
|
2111
|
+
};
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
return { kind: "none" };
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
function computeBulletPrefix(
|
|
2118
|
+
bullet: ResolvedBullet,
|
|
2119
|
+
level: number,
|
|
2120
|
+
counters: Map<number, number>,
|
|
2121
|
+
prevAutoKey: string | undefined
|
|
2122
|
+
): { text: string; autoKey: string | undefined } {
|
|
2123
|
+
const indent = " ".repeat(level);
|
|
2124
|
+
if (bullet.kind === "none") {
|
|
2125
|
+
// Drop the counter so a later run of <a:buAutoNum> restarts at 1 even at
|
|
2126
|
+
// the same level.
|
|
2127
|
+
counters.delete(level);
|
|
2128
|
+
return { text: "", autoKey: undefined };
|
|
2129
|
+
}
|
|
2130
|
+
if (bullet.kind === "char") {
|
|
2131
|
+
counters.delete(level);
|
|
2132
|
+
return {
|
|
2133
|
+
text: `${indent}${bullet.char ?? "•"} `,
|
|
2134
|
+
autoKey: undefined,
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
const key = `auto|${level}|${bullet.autoType ?? "arabicPeriod"}`;
|
|
2138
|
+
const continuing = key === prevAutoKey;
|
|
2139
|
+
const next = continuing
|
|
2140
|
+
? (counters.get(level) ?? bullet.autoStartAt ?? 1) + 1
|
|
2141
|
+
: bullet.autoStartAt ?? 1;
|
|
2142
|
+
counters.set(level, next);
|
|
2143
|
+
return {
|
|
2144
|
+
text: `${indent}${formatAutoNum(next, bullet.autoType ?? "arabicPeriod")} `,
|
|
2145
|
+
autoKey: key,
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
function formatAutoNum(n: number, type: string): string {
|
|
2150
|
+
switch (type) {
|
|
2151
|
+
case "arabicPlain":
|
|
2152
|
+
return `${n}`;
|
|
2153
|
+
case "arabicParenR":
|
|
2154
|
+
return `${n})`;
|
|
2155
|
+
case "arabicParenBoth":
|
|
2156
|
+
return `(${n})`;
|
|
2157
|
+
case "arabicPeriod":
|
|
2158
|
+
return `${n}.`;
|
|
2159
|
+
case "alphaUcPeriod":
|
|
2160
|
+
return `${toAlpha(n).toUpperCase()}.`;
|
|
2161
|
+
case "alphaLcPeriod":
|
|
2162
|
+
return `${toAlpha(n)}.`;
|
|
2163
|
+
case "romanUcPeriod":
|
|
2164
|
+
return `${toRoman(n).toUpperCase()}.`;
|
|
2165
|
+
case "romanLcPeriod":
|
|
2166
|
+
return `${toRoman(n)}.`;
|
|
2167
|
+
default:
|
|
2168
|
+
return `${n}.`;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
function toAlpha(n: number): string {
|
|
2173
|
+
let s = "";
|
|
2174
|
+
let v = n;
|
|
2175
|
+
while (v > 0) {
|
|
2176
|
+
const r = (v - 1) % 26;
|
|
2177
|
+
s = String.fromCharCode(97 + r) + s;
|
|
2178
|
+
v = Math.floor((v - 1) / 26);
|
|
2179
|
+
}
|
|
2180
|
+
return s || "a";
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
function toRoman(n: number): string {
|
|
2184
|
+
if (n <= 0) return "";
|
|
2185
|
+
const pairs: [number, string][] = [
|
|
2186
|
+
[1000, "m"],
|
|
2187
|
+
[900, "cm"],
|
|
2188
|
+
[500, "d"],
|
|
2189
|
+
[400, "cd"],
|
|
2190
|
+
[100, "c"],
|
|
2191
|
+
[90, "xc"],
|
|
2192
|
+
[50, "l"],
|
|
2193
|
+
[40, "xl"],
|
|
2194
|
+
[10, "x"],
|
|
2195
|
+
[9, "ix"],
|
|
2196
|
+
[5, "v"],
|
|
2197
|
+
[4, "iv"],
|
|
2198
|
+
[1, "i"],
|
|
2199
|
+
];
|
|
2200
|
+
let out = "";
|
|
2201
|
+
let v = n;
|
|
2202
|
+
for (const [val, sym] of pairs) {
|
|
2203
|
+
while (v >= val) {
|
|
2204
|
+
out += sym;
|
|
2205
|
+
v -= val;
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
return out;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
function clampLevel(n: number): number {
|
|
2212
|
+
if (!Number.isFinite(n) || n < 0) return 0;
|
|
2213
|
+
if (n > 8) return 8;
|
|
2214
|
+
return Math.floor(n);
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
function buildRunInfo(
|
|
2218
|
+
r: any,
|
|
2219
|
+
theme: ThemeColors,
|
|
2220
|
+
themeFonts: ThemeFonts,
|
|
2221
|
+
fallbackRPr: any
|
|
2222
|
+
): { run: RunInfo; text: string } {
|
|
2223
|
+
const t = r?.["a:t"];
|
|
2224
|
+
const rPr = r?.["a:rPr"] ?? {};
|
|
2225
|
+
const text = typeof t === "string" ? t : t?.["#text"] ?? "";
|
|
2226
|
+
const spcRaw = rPr?.["@_spc"] ?? fallbackRPr?.["@_spc"];
|
|
2227
|
+
const letterSpacing =
|
|
2228
|
+
spcRaw !== undefined && spcRaw !== ""
|
|
2229
|
+
? pointsToPx(Number(spcRaw) / 100)
|
|
2230
|
+
: undefined;
|
|
2231
|
+
const fontSize =
|
|
2232
|
+
rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]
|
|
2233
|
+
? pointsToPx(Number(rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]) / 100)
|
|
2234
|
+
: undefined;
|
|
2235
|
+
const rawFontFamily =
|
|
2236
|
+
rPr?.["a:latin"]?.["@_typeface"] ??
|
|
2237
|
+
fallbackRPr?.["a:latin"]?.["@_typeface"];
|
|
2238
|
+
const fontFamily = resolveFontFamily(rawFontFamily, themeFonts);
|
|
2239
|
+
const color =
|
|
2240
|
+
resolveColor(rPr?.["a:solidFill"], theme) ??
|
|
2241
|
+
resolveColor(fallbackRPr?.["a:solidFill"], theme);
|
|
2242
|
+
const boldVal = rPr?.["@_b"] ?? fallbackRPr?.["@_b"];
|
|
2243
|
+
const italicVal = rPr?.["@_i"] ?? fallbackRPr?.["@_i"];
|
|
2244
|
+
const underlineVal = rPr?.["@_u"] ?? fallbackRPr?.["@_u"];
|
|
2245
|
+
const strikeVal = rPr?.["@_strike"] ?? fallbackRPr?.["@_strike"];
|
|
2246
|
+
return {
|
|
2247
|
+
text,
|
|
2248
|
+
run: {
|
|
2249
|
+
text,
|
|
2250
|
+
fontFamily,
|
|
2251
|
+
fontSize,
|
|
2252
|
+
bold: boldVal === "1" || boldVal === 1,
|
|
2253
|
+
italic: italicVal === "1" || italicVal === 1,
|
|
2254
|
+
underline: !!(underlineVal && underlineVal !== "none"),
|
|
2255
|
+
strike: strikeVal === "sngStrike",
|
|
2256
|
+
color,
|
|
2257
|
+
letterSpacing,
|
|
2258
|
+
},
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
/**
|
|
2263
|
+
* Returns the ordered list of <a:r>/<a:br> direct children of a paragraph
|
|
2264
|
+
* with each entry's running index into the corresponding array on the parsed
|
|
2265
|
+
* paragraph. The order is recovered from the paragraph's raw XML (attached
|
|
2266
|
+
* during readXml) because fast-xml-parser groups children by tag name.
|
|
2267
|
+
*/
|
|
2268
|
+
function paragraphChildOrder(
|
|
2269
|
+
p: any
|
|
2270
|
+
): { kind: "r" | "br" | "fld"; index: number }[] {
|
|
2271
|
+
const raw = (p as any)?._rawSrc as string | undefined;
|
|
2272
|
+
if (raw) return paragraphChildOrderFromRaw(raw);
|
|
2273
|
+
// No raw available (paragraph has no <a:br/> and pre-PR-2 readXml didn't
|
|
2274
|
+
// attach it). Fall back to the order implied by the parsed arrays: all
|
|
2275
|
+
// runs, then all fields. Document order across these tag types is lost
|
|
2276
|
+
// by fast-xml-parser, but the typical PPTX paragraph has either runs or
|
|
2277
|
+
// a field, not both, so this matches reality in practice.
|
|
2278
|
+
const out: { kind: "r" | "br" | "fld"; index: number }[] = [];
|
|
2279
|
+
asArray(p?.["a:r"]).forEach((_, i) => out.push({ kind: "r", index: i }));
|
|
2280
|
+
asArray(p?.["a:fld"]).forEach((_, i) => out.push({ kind: "fld", index: i }));
|
|
2281
|
+
return out;
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
/**
|
|
2285
|
+
* Convert a PPTX `<a:custGeom>` (custom geometry — used for logos, brand
|
|
2286
|
+
* marks, hand-drawn shapes) into an SVG path. Reads command order from the
|
|
2287
|
+
* raw XML attached during readXml, since fast-xml-parser groups children by
|
|
2288
|
+
* tag name and drops cross-tag document order. Supports moveTo, lnTo,
|
|
2289
|
+
* cubicBezTo, quadBezTo, and close; arcTo and formula-based guide
|
|
2290
|
+
* references aren't translated yet (they degrade to a flat-fill rect).
|
|
2291
|
+
*/
|
|
2292
|
+
function parseCustGeomPath(custGeom: any): ShapePath | undefined {
|
|
2293
|
+
const raw = (custGeom as any)?._rawSrc as string | undefined;
|
|
2294
|
+
if (!raw) return undefined;
|
|
2295
|
+
// Each <a:path w="…" h="…"> defines its own coordinate system; the SVG
|
|
2296
|
+
// viewBox uses the FIRST path's dimensions and subsequent paths inherit
|
|
2297
|
+
// it. In practice almost every custGeom in real decks uses one viewbox
|
|
2298
|
+
// across all sub-paths.
|
|
2299
|
+
const paths = findAllElementRawBlocks(raw, "path");
|
|
2300
|
+
if (!paths.length) return undefined;
|
|
2301
|
+
let viewW = 0;
|
|
2302
|
+
let viewH = 0;
|
|
2303
|
+
let d = "";
|
|
2304
|
+
for (const block of paths) {
|
|
2305
|
+
const headerEnd = block.indexOf(">");
|
|
2306
|
+
if (headerEnd < 0) continue;
|
|
2307
|
+
const header = block.slice(0, headerEnd + 1);
|
|
2308
|
+
const w = Number(/\bw="(\d+)"/.exec(header)?.[1] ?? 0);
|
|
2309
|
+
const h = Number(/\bh="(\d+)"/.exec(header)?.[1] ?? 0);
|
|
2310
|
+
if (w > viewW) viewW = w;
|
|
2311
|
+
if (h > viewH) viewH = h;
|
|
2312
|
+
d += (d ? " " : "") + custGeomBodyToSvgD(block);
|
|
2313
|
+
}
|
|
2314
|
+
if (!d || viewW <= 0 || viewH <= 0) return undefined;
|
|
2315
|
+
// OOXML composite paths (multiple subpaths with internal holes — letters
|
|
2316
|
+
// like the "e" and "o" in the eon wordmark) render with even-odd winding
|
|
2317
|
+
// by default; the nonzero default of SVG would fill the holes.
|
|
2318
|
+
return { d, viewW, viewH, fillRule: "evenodd" };
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
function custGeomBodyToSvgD(pathBlock: string): string {
|
|
2322
|
+
const headerEnd = pathBlock.indexOf(">");
|
|
2323
|
+
const closeIdx = pathBlock.lastIndexOf("</a:path>");
|
|
2324
|
+
const inner =
|
|
2325
|
+
closeIdx > headerEnd
|
|
2326
|
+
? pathBlock.slice(headerEnd + 1, closeIdx)
|
|
2327
|
+
: pathBlock.slice(headerEnd + 1);
|
|
2328
|
+
let out = "";
|
|
2329
|
+
let i = 0;
|
|
2330
|
+
let depth = 0;
|
|
2331
|
+
// Track the current pen position so <a:arcTo> (which doesn't carry an
|
|
2332
|
+
// explicit end point) can compute its SVG `A` endpoint from start +
|
|
2333
|
+
// sweep angle, just like PowerPoint does at render time.
|
|
2334
|
+
let penX = 0;
|
|
2335
|
+
let penY = 0;
|
|
2336
|
+
while (i < inner.length) {
|
|
2337
|
+
if (inner[i] !== "<") {
|
|
2338
|
+
i++;
|
|
2339
|
+
continue;
|
|
2340
|
+
}
|
|
2341
|
+
if (inner.startsWith("</", i)) {
|
|
2342
|
+
depth--;
|
|
2343
|
+
const end = inner.indexOf(">", i);
|
|
2344
|
+
if (end < 0) break;
|
|
2345
|
+
i = end + 1;
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2348
|
+
if (inner.startsWith("<!--", i)) {
|
|
2349
|
+
const end = inner.indexOf("-->", i);
|
|
2350
|
+
if (end < 0) break;
|
|
2351
|
+
i = end + 3;
|
|
2352
|
+
continue;
|
|
2353
|
+
}
|
|
2354
|
+
const close = inner.indexOf(">", i);
|
|
2355
|
+
if (close < 0) break;
|
|
2356
|
+
const tag = inner.slice(i, close + 1);
|
|
2357
|
+
const nameMatch = /^<a:([\w]+)/.exec(tag);
|
|
2358
|
+
const isSelfClose = tag.endsWith("/>");
|
|
2359
|
+
if (depth === 0 && nameMatch) {
|
|
2360
|
+
const name = nameMatch[1];
|
|
2361
|
+
if (name === "close") {
|
|
2362
|
+
out += " Z";
|
|
2363
|
+
} else if (
|
|
2364
|
+
name === "moveTo" ||
|
|
2365
|
+
name === "lnTo" ||
|
|
2366
|
+
name === "cubicBezTo" ||
|
|
2367
|
+
name === "quadBezTo"
|
|
2368
|
+
) {
|
|
2369
|
+
const cmdClose = inner.indexOf(`</a:${name}>`, close + 1);
|
|
2370
|
+
if (cmdClose < 0) break;
|
|
2371
|
+
const body = inner.slice(close + 1, cmdClose);
|
|
2372
|
+
const pts: Array<[number, number]> = [];
|
|
2373
|
+
const ptRe = /<a:pt\s+x="(-?\d+)"\s+y="(-?\d+)"\s*\/>/g;
|
|
2374
|
+
let m: RegExpExecArray | null;
|
|
2375
|
+
while ((m = ptRe.exec(body))) {
|
|
2376
|
+
pts.push([Number(m[1]), Number(m[2])]);
|
|
2377
|
+
}
|
|
2378
|
+
if (pts.length) {
|
|
2379
|
+
const letter =
|
|
2380
|
+
name === "moveTo"
|
|
2381
|
+
? "M"
|
|
2382
|
+
: name === "lnTo"
|
|
2383
|
+
? "L"
|
|
2384
|
+
: name === "cubicBezTo"
|
|
2385
|
+
? "C"
|
|
2386
|
+
: "Q";
|
|
2387
|
+
out += ` ${letter} ${pts.map(([x, y]) => `${x} ${y}`).join(" ")}`;
|
|
2388
|
+
const last = pts[pts.length - 1];
|
|
2389
|
+
penX = last[0];
|
|
2390
|
+
penY = last[1];
|
|
2391
|
+
}
|
|
2392
|
+
i = cmdClose + `</a:${name}>`.length;
|
|
2393
|
+
continue;
|
|
2394
|
+
} else if (name === "arcTo") {
|
|
2395
|
+
// <a:arcTo wR="" hR="" stAng="" swAng="" /> — elliptical arc
|
|
2396
|
+
// starting at the current pen position. wR/hR are the axis radii;
|
|
2397
|
+
// stAng/swAng are start/sweep angles measured in 60000ths of a
|
|
2398
|
+
// degree (OOXML convention). The start point on the ellipse is
|
|
2399
|
+
// (centre.x + wR·cos(stAng), centre.y + hR·sin(stAng)); the
|
|
2400
|
+
// centre is therefore (pen.x − wR·cos(stAng), pen.y − hR·sin(stAng)).
|
|
2401
|
+
// SVG `A` takes the END point instead of an angle, so we compute
|
|
2402
|
+
// the end from start + swAng.
|
|
2403
|
+
const wR = Number(/\bwR="(-?\d+)"/.exec(tag)?.[1] ?? 0);
|
|
2404
|
+
const hR = Number(/\bhR="(-?\d+)"/.exec(tag)?.[1] ?? 0);
|
|
2405
|
+
const stAng = Number(/\bstAng="(-?\d+)"/.exec(tag)?.[1] ?? 0) / 60000;
|
|
2406
|
+
const swAng = Number(/\bswAng="(-?\d+)"/.exec(tag)?.[1] ?? 0) / 60000;
|
|
2407
|
+
if (wR > 0 && hR > 0) {
|
|
2408
|
+
const rad = (deg: number) => (deg * Math.PI) / 180;
|
|
2409
|
+
const cx = penX - wR * Math.cos(rad(stAng));
|
|
2410
|
+
const cy = penY - hR * Math.sin(rad(stAng));
|
|
2411
|
+
const endAng = stAng + swAng;
|
|
2412
|
+
const ex = cx + wR * Math.cos(rad(endAng));
|
|
2413
|
+
const ey = cy + hR * Math.sin(rad(endAng));
|
|
2414
|
+
const largeArc = Math.abs(swAng) > 180 ? 1 : 0;
|
|
2415
|
+
const sweep = swAng > 0 ? 1 : 0;
|
|
2416
|
+
out += ` A ${wR} ${hR} 0 ${largeArc} ${sweep} ${ex.toFixed(2)} ${ey.toFixed(2)}`;
|
|
2417
|
+
penX = ex;
|
|
2418
|
+
penY = ey;
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
// Unknown commands are skipped — the rest of the path stays valid.
|
|
2422
|
+
}
|
|
2423
|
+
if (!isSelfClose) depth++;
|
|
2424
|
+
i = close + 1;
|
|
2425
|
+
}
|
|
2426
|
+
return out.trim();
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
function paragraphChildOrderFromRaw(
|
|
2430
|
+
raw: string
|
|
2431
|
+
): { kind: "r" | "br" | "fld"; index: number }[] {
|
|
2432
|
+
const tagEnd = raw.indexOf(">");
|
|
2433
|
+
const closeIdx = raw.lastIndexOf("</a:p>");
|
|
2434
|
+
if (tagEnd < 0 || closeIdx < 0) return [];
|
|
2435
|
+
const inner = raw.slice(tagEnd + 1, closeIdx);
|
|
2436
|
+
const out: { kind: "r" | "br" | "fld"; index: number }[] = [];
|
|
2437
|
+
let depth = 0;
|
|
2438
|
+
let rIdx = 0;
|
|
2439
|
+
let brIdx = 0;
|
|
2440
|
+
let fldIdx = 0;
|
|
2441
|
+
let i = 0;
|
|
2442
|
+
while (i < inner.length) {
|
|
2443
|
+
if (inner[i] !== "<") {
|
|
2444
|
+
i++;
|
|
2445
|
+
continue;
|
|
2446
|
+
}
|
|
2447
|
+
if (inner.startsWith("</", i)) {
|
|
2448
|
+
depth--;
|
|
2449
|
+
const end = inner.indexOf(">", i);
|
|
2450
|
+
if (end < 0) break;
|
|
2451
|
+
i = end + 1;
|
|
2452
|
+
continue;
|
|
2453
|
+
}
|
|
2454
|
+
if (inner.startsWith("<!--", i)) {
|
|
2455
|
+
const end = inner.indexOf("-->", i);
|
|
2456
|
+
if (end < 0) break;
|
|
2457
|
+
i = end + 3;
|
|
2458
|
+
continue;
|
|
2459
|
+
}
|
|
2460
|
+
if (inner.startsWith("<?", i)) {
|
|
2461
|
+
const end = inner.indexOf("?>", i);
|
|
2462
|
+
if (end < 0) break;
|
|
2463
|
+
i = end + 2;
|
|
2464
|
+
continue;
|
|
2465
|
+
}
|
|
2466
|
+
const close = inner.indexOf(">", i);
|
|
2467
|
+
if (close < 0) break;
|
|
2468
|
+
const tag = inner.slice(i, close + 1);
|
|
2469
|
+
const isSelfClose = tag.endsWith("/>");
|
|
2470
|
+
const nameMatch = /^<(a:[\w]+)/.exec(tag);
|
|
2471
|
+
if (depth === 0 && nameMatch) {
|
|
2472
|
+
const name = nameMatch[1];
|
|
2473
|
+
if (name === "a:r") out.push({ kind: "r", index: rIdx++ });
|
|
2474
|
+
else if (name === "a:br") out.push({ kind: "br", index: brIdx++ });
|
|
2475
|
+
else if (name === "a:fld") out.push({ kind: "fld", index: fldIdx++ });
|
|
2476
|
+
}
|
|
2477
|
+
if (!isSelfClose) depth++;
|
|
2478
|
+
i = close + 1;
|
|
2479
|
+
}
|
|
2480
|
+
return out;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
1274
2483
|
function hasAnyText(txBody: any): boolean {
|
|
1275
2484
|
const ps = asArray(txBody?.["a:p"]);
|
|
1276
2485
|
for (const p of ps) {
|
|
1277
|
-
const
|
|
1278
|
-
for (const r of rs) {
|
|
2486
|
+
for (const r of [...asArray(p?.["a:r"]), ...asArray(p?.["a:fld"])]) {
|
|
1279
2487
|
const t = r?.["a:t"];
|
|
1280
2488
|
const text = typeof t === "string" ? t : t?.["#text"] ?? "";
|
|
1281
2489
|
if (text && String(text).length > 0) return true;
|
|
@@ -1291,6 +2499,27 @@ function mergeFirst<T>(...candidates: (T | undefined)[]): T | undefined {
|
|
|
1291
2499
|
return undefined;
|
|
1292
2500
|
}
|
|
1293
2501
|
|
|
2502
|
+
/**
|
|
2503
|
+
* Per-field rPr merge: earlier candidates win for each individual attribute
|
|
2504
|
+
* (font typeface, size, colour, weight, italic, …). Used to flatten the
|
|
2505
|
+
* layout→master→txStyles inheritance chain into a single fallback rPr for
|
|
2506
|
+
* the run extractor.
|
|
2507
|
+
*/
|
|
2508
|
+
function mergeRPrChain(...candidates: (any | undefined)[]): any | undefined {
|
|
2509
|
+
const out: any = {};
|
|
2510
|
+
let touched = false;
|
|
2511
|
+
for (const c of candidates) {
|
|
2512
|
+
if (!c || typeof c !== "object") continue;
|
|
2513
|
+
for (const k of Object.keys(c)) {
|
|
2514
|
+
if (out[k] === undefined) {
|
|
2515
|
+
out[k] = c[k];
|
|
2516
|
+
touched = true;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
return touched ? out : undefined;
|
|
2521
|
+
}
|
|
2522
|
+
|
|
1294
2523
|
/**
|
|
1295
2524
|
* Map an OOXML preset shape name to the closest Slidewise ShapeKind. Returns
|
|
1296
2525
|
* null only for shapes we genuinely cannot represent at all (the caller then
|
|
@@ -1436,7 +2665,274 @@ async function readXml(zip: JSZip, path: string): Promise<any | null> {
|
|
|
1436
2665
|
const file = zip.file(path);
|
|
1437
2666
|
if (!file) return null;
|
|
1438
2667
|
const text = await file.async("string");
|
|
1439
|
-
|
|
2668
|
+
const parsed = xmlParser.parse(text);
|
|
2669
|
+
// fast-xml-parser groups children by tag name and drops cross-tag
|
|
2670
|
+
// document order. Attach raw fragments for paragraphs (run/br/fld
|
|
2671
|
+
// interleaving), custGeom path commands, and spTree/grpSp children
|
|
2672
|
+
// (which carry z-index via source order).
|
|
2673
|
+
annotateRawOrder(parsed, text);
|
|
2674
|
+
return parsed;
|
|
2675
|
+
}
|
|
2676
|
+
|
|
2677
|
+
function annotateRawOrder(parsed: any, rawText: string): void {
|
|
2678
|
+
annotateParagraphRawSrc(parsed, rawText);
|
|
2679
|
+
annotateSpTreeChildOrder(parsed, rawText);
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
/**
|
|
2683
|
+
* Attach `_childOrder: string[]` to every parsed `<p:spTree>` and
|
|
2684
|
+
* `<p:grpSp>` so callers can iterate children (sp, pic, cxnSp,
|
|
2685
|
+
* graphicFrame, grpSp) in document order. PPTX z-index follows source
|
|
2686
|
+
* order — a slide that lists `<p:pic>` before `<p:sp>` means the picture
|
|
2687
|
+
* sits behind the text — but fast-xml-parser groups children by tag name
|
|
2688
|
+
* and drops cross-tag ordering.
|
|
2689
|
+
*/
|
|
2690
|
+
function annotateSpTreeChildOrder(parsed: any, rawText: string): void {
|
|
2691
|
+
if (!rawText.includes("<p:spTree") && !rawText.includes("<p:grpSp")) return;
|
|
2692
|
+
for (const tag of ["p:spTree", "p:grpSp"] as const) {
|
|
2693
|
+
if (!rawText.includes(`<${tag}`)) continue;
|
|
2694
|
+
const blocks = findAllRawBlocks(rawText, tag);
|
|
2695
|
+
if (!blocks.length) continue;
|
|
2696
|
+
const nodes: any[] = [];
|
|
2697
|
+
collectNamedDfs(parsed, tag, nodes);
|
|
2698
|
+
const n = Math.min(blocks.length, nodes.length);
|
|
2699
|
+
for (let i = 0; i < n; i++) {
|
|
2700
|
+
const order = extractTopLevelChildNames(blocks[i]);
|
|
2701
|
+
Object.defineProperty(nodes[i], "_childOrder", {
|
|
2702
|
+
value: order,
|
|
2703
|
+
enumerable: false,
|
|
2704
|
+
configurable: true,
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
|
|
2710
|
+
function extractTopLevelChildNames(blockXml: string): string[] {
|
|
2711
|
+
const tagEnd = blockXml.indexOf(">");
|
|
2712
|
+
const close = blockXml.lastIndexOf("<");
|
|
2713
|
+
if (tagEnd < 0 || close <= tagEnd) return [];
|
|
2714
|
+
const inner = blockXml.slice(tagEnd + 1, close);
|
|
2715
|
+
const out: string[] = [];
|
|
2716
|
+
let depth = 0;
|
|
2717
|
+
let i = 0;
|
|
2718
|
+
while (i < inner.length) {
|
|
2719
|
+
if (inner[i] !== "<") {
|
|
2720
|
+
i++;
|
|
2721
|
+
continue;
|
|
2722
|
+
}
|
|
2723
|
+
if (inner.startsWith("</", i)) {
|
|
2724
|
+
depth--;
|
|
2725
|
+
const end = inner.indexOf(">", i);
|
|
2726
|
+
if (end < 0) break;
|
|
2727
|
+
i = end + 1;
|
|
2728
|
+
continue;
|
|
2729
|
+
}
|
|
2730
|
+
if (inner.startsWith("<!--", i)) {
|
|
2731
|
+
const end = inner.indexOf("-->", i);
|
|
2732
|
+
if (end < 0) break;
|
|
2733
|
+
i = end + 3;
|
|
2734
|
+
continue;
|
|
2735
|
+
}
|
|
2736
|
+
const end = inner.indexOf(">", i);
|
|
2737
|
+
if (end < 0) break;
|
|
2738
|
+
const tag = inner.slice(i, end + 1);
|
|
2739
|
+
const selfClose = tag.endsWith("/>");
|
|
2740
|
+
const nameMatch = /^<([\w:]+)/.exec(tag);
|
|
2741
|
+
if (depth === 0 && nameMatch) out.push(nameMatch[1]);
|
|
2742
|
+
if (!selfClose) depth++;
|
|
2743
|
+
i = end + 1;
|
|
2744
|
+
}
|
|
2745
|
+
return out;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
function findAllRawBlocks(raw: string, fullName: string): string[] {
|
|
2749
|
+
const blocks: string[] = [];
|
|
2750
|
+
// Depth-aware scan so self-nesting tags (e.g. `<p:grpSp>` inside another
|
|
2751
|
+
// `<p:grpSp>`) match their correct closing tag instead of the first inner
|
|
2752
|
+
// one we encounter.
|
|
2753
|
+
const openRe = new RegExp(`<${fullName}\\b[^>]*?(/?)>`, "g");
|
|
2754
|
+
const closeTag = `</${fullName}>`;
|
|
2755
|
+
let i = 0;
|
|
2756
|
+
while (i < raw.length) {
|
|
2757
|
+
openRe.lastIndex = i;
|
|
2758
|
+
const m = openRe.exec(raw);
|
|
2759
|
+
if (!m) break;
|
|
2760
|
+
const start = m.index;
|
|
2761
|
+
const tagEnd = openRe.lastIndex;
|
|
2762
|
+
if (m[1] === "/") {
|
|
2763
|
+
blocks.push(raw.slice(start, tagEnd));
|
|
2764
|
+
i = tagEnd;
|
|
2765
|
+
continue;
|
|
2766
|
+
}
|
|
2767
|
+
let depth = 1;
|
|
2768
|
+
let scan = tagEnd;
|
|
2769
|
+
const innerOpenRe = new RegExp(`<${fullName}\\b[^>]*?(/?)>`, "g");
|
|
2770
|
+
while (depth > 0 && scan < raw.length) {
|
|
2771
|
+
innerOpenRe.lastIndex = scan;
|
|
2772
|
+
const nextOpen = innerOpenRe.exec(raw);
|
|
2773
|
+
const nextClose = raw.indexOf(closeTag, scan);
|
|
2774
|
+
if (nextClose < 0) {
|
|
2775
|
+
depth = -1;
|
|
2776
|
+
break;
|
|
2777
|
+
}
|
|
2778
|
+
if (nextOpen && nextOpen.index < nextClose) {
|
|
2779
|
+
// Self-closing opens don't change depth.
|
|
2780
|
+
if (nextOpen[1] !== "/") depth++;
|
|
2781
|
+
scan = innerOpenRe.lastIndex;
|
|
2782
|
+
} else {
|
|
2783
|
+
depth--;
|
|
2784
|
+
scan = nextClose + closeTag.length;
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
if (depth !== 0) break;
|
|
2788
|
+
blocks.push(raw.slice(start, scan));
|
|
2789
|
+
i = scan;
|
|
2790
|
+
}
|
|
2791
|
+
return blocks;
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
function annotateParagraphRawSrc(parsed: any, rawText: string): void {
|
|
2795
|
+
// Cross-tag document order matters when a paragraph mixes <a:r>, <a:br>,
|
|
2796
|
+
// and <a:fld>. fast-xml-parser groups by tag name, so we keep the raw
|
|
2797
|
+
// XML for any paragraph that contains a break or a field.
|
|
2798
|
+
if (rawText.includes("<a:br") || rawText.includes("<a:fld")) {
|
|
2799
|
+
const blocks = findAllParagraphRawBlocks(rawText);
|
|
2800
|
+
if (blocks.length) {
|
|
2801
|
+
const parsedPs: any[] = [];
|
|
2802
|
+
collectParagraphsDfs(parsed, parsedPs);
|
|
2803
|
+
const n = Math.min(blocks.length, parsedPs.length);
|
|
2804
|
+
for (let i = 0; i < n; i++) {
|
|
2805
|
+
const block = blocks[i];
|
|
2806
|
+
if (block.includes("<a:br") || block.includes("<a:fld")) {
|
|
2807
|
+
Object.defineProperty(parsedPs[i], "_rawSrc", {
|
|
2808
|
+
value: block,
|
|
2809
|
+
enumerable: false,
|
|
2810
|
+
configurable: true,
|
|
2811
|
+
});
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
// <a:custGeom> path commands (moveTo, lnTo, cubicBezTo, …) are siblings,
|
|
2817
|
+
// and their cross-tag order defines the silhouette of brand logos and
|
|
2818
|
+
// hand-drawn shapes. Same fast-xml-parser issue, same fix: attach raw.
|
|
2819
|
+
if (rawText.includes("<a:custGeom")) {
|
|
2820
|
+
const blocks = findAllElementRawBlocks(rawText, "custGeom");
|
|
2821
|
+
if (blocks.length) {
|
|
2822
|
+
const parsedCustGeoms: any[] = [];
|
|
2823
|
+
collectNamedDfs(parsed, "a:custGeom", parsedCustGeoms);
|
|
2824
|
+
const n = Math.min(blocks.length, parsedCustGeoms.length);
|
|
2825
|
+
for (let i = 0; i < n; i++) {
|
|
2826
|
+
Object.defineProperty(parsedCustGeoms[i], "_rawSrc", {
|
|
2827
|
+
value: blocks[i],
|
|
2828
|
+
enumerable: false,
|
|
2829
|
+
configurable: true,
|
|
2830
|
+
});
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
function collectNamedDfs(node: any, key: string, acc: any[]): void {
|
|
2837
|
+
if (!node || typeof node !== "object") return;
|
|
2838
|
+
if (Array.isArray(node)) {
|
|
2839
|
+
for (const n of node) collectNamedDfs(n, key, acc);
|
|
2840
|
+
return;
|
|
2841
|
+
}
|
|
2842
|
+
for (const k of Object.keys(node)) {
|
|
2843
|
+
if (k.startsWith("@_") || k === "#text") continue;
|
|
2844
|
+
if (k === key) {
|
|
2845
|
+
for (const v of asArray(node[k])) acc.push(v);
|
|
2846
|
+
} else {
|
|
2847
|
+
collectNamedDfs(node[k], key, acc);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
function findAllElementRawBlocks(raw: string, localName: string): string[] {
|
|
2853
|
+
const blocks: string[] = [];
|
|
2854
|
+
const openSelfClose = new RegExp(`<a:${localName}\\b[^>]*/>`);
|
|
2855
|
+
const openTag = new RegExp(`<a:${localName}\\b[^>]*>`);
|
|
2856
|
+
const close = `</a:${localName}>`;
|
|
2857
|
+
let cursor = 0;
|
|
2858
|
+
while (cursor < raw.length) {
|
|
2859
|
+
const slice = raw.slice(cursor);
|
|
2860
|
+
const sc = openSelfClose.exec(slice);
|
|
2861
|
+
const ot = openTag.exec(slice);
|
|
2862
|
+
// Pick whichever comes first (and only if it isn't a self-close that was
|
|
2863
|
+
// also matched by openTag).
|
|
2864
|
+
let start = -1;
|
|
2865
|
+
let selfClose = false;
|
|
2866
|
+
if (sc && (!ot || sc.index <= ot.index)) {
|
|
2867
|
+
start = cursor + sc.index;
|
|
2868
|
+
selfClose = true;
|
|
2869
|
+
} else if (ot) {
|
|
2870
|
+
start = cursor + ot.index;
|
|
2871
|
+
selfClose = ot[0].endsWith("/>");
|
|
2872
|
+
}
|
|
2873
|
+
if (start < 0) break;
|
|
2874
|
+
if (selfClose) {
|
|
2875
|
+
const end = raw.indexOf(">", start);
|
|
2876
|
+
blocks.push(raw.slice(start, end + 1));
|
|
2877
|
+
cursor = end + 1;
|
|
2878
|
+
continue;
|
|
2879
|
+
}
|
|
2880
|
+
const tagEnd = raw.indexOf(">", start);
|
|
2881
|
+
const closeIdx = raw.indexOf(close, tagEnd + 1);
|
|
2882
|
+
if (closeIdx < 0) break;
|
|
2883
|
+
blocks.push(raw.slice(start, closeIdx + close.length));
|
|
2884
|
+
cursor = closeIdx + close.length;
|
|
2885
|
+
}
|
|
2886
|
+
return blocks;
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
function findAllParagraphRawBlocks(raw: string): string[] {
|
|
2890
|
+
const blocks: string[] = [];
|
|
2891
|
+
let i = 0;
|
|
2892
|
+
while (i < raw.length) {
|
|
2893
|
+
const a = raw.indexOf("<a:p>", i);
|
|
2894
|
+
const b = raw.indexOf("<a:p ", i);
|
|
2895
|
+
let start: number;
|
|
2896
|
+
if (a < 0 && b < 0) break;
|
|
2897
|
+
if (a < 0) start = b;
|
|
2898
|
+
else if (b < 0) start = a;
|
|
2899
|
+
else start = Math.min(a, b);
|
|
2900
|
+
const tagEnd = raw.indexOf(">", start);
|
|
2901
|
+
if (tagEnd < 0) break;
|
|
2902
|
+
if (raw[tagEnd - 1] === "/") {
|
|
2903
|
+
blocks.push(raw.slice(start, tagEnd + 1));
|
|
2904
|
+
i = tagEnd + 1;
|
|
2905
|
+
continue;
|
|
2906
|
+
}
|
|
2907
|
+
// <a:p> never nests inside another <a:p>, so the first </a:p> is ours.
|
|
2908
|
+
const close = raw.indexOf("</a:p>", tagEnd + 1);
|
|
2909
|
+
if (close < 0) break;
|
|
2910
|
+
blocks.push(raw.slice(start, close + "</a:p>".length));
|
|
2911
|
+
i = close + "</a:p>".length;
|
|
2912
|
+
}
|
|
2913
|
+
return blocks;
|
|
2914
|
+
}
|
|
2915
|
+
|
|
2916
|
+
function collectParagraphsDfs(node: any, acc: any[]): void {
|
|
2917
|
+
if (!node || typeof node !== "object") return;
|
|
2918
|
+
if (Array.isArray(node)) {
|
|
2919
|
+
for (const n of node) collectParagraphsDfs(n, acc);
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
for (const k of Object.keys(node)) {
|
|
2923
|
+
if (k.startsWith("@_") || k === "#text") continue;
|
|
2924
|
+
if (k === "a:p") {
|
|
2925
|
+
for (const p of asArray(node[k])) acc.push(p);
|
|
2926
|
+
} else {
|
|
2927
|
+
collectParagraphsDfs(node[k], acc);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
|
|
2932
|
+
async function readXmlRaw(zip: JSZip, path: string): Promise<string | null> {
|
|
2933
|
+
const file = zip.file(path);
|
|
2934
|
+
if (!file) return null;
|
|
2935
|
+
return file.async("string");
|
|
1440
2936
|
}
|
|
1441
2937
|
|
|
1442
2938
|
async function readRels(zip: JSZip, path: string): Promise<Rels> {
|