@textcortex/slidewise 1.0.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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/__vite-browser-external-DYxpcVy9.js +5 -0
  4. package/dist/__vite-browser-external-DYxpcVy9.js.map +1 -0
  5. package/dist/file.svg +1 -0
  6. package/dist/globe.svg +1 -0
  7. package/dist/index.mjs +16697 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/slidewise.css +1 -0
  10. package/dist/types/SlidewiseEditor.d.ts +47 -0
  11. package/dist/types/SlidewiseFileEditor.d.ts +54 -0
  12. package/dist/types/components/editor/BottomToolbar.d.ts +1 -0
  13. package/dist/types/components/editor/Canvas.d.ts +1 -0
  14. package/dist/types/components/editor/Editor.d.ts +8 -0
  15. package/dist/types/components/editor/ElementView.d.ts +6 -0
  16. package/dist/types/components/editor/FloatingToolbar.d.ts +6 -0
  17. package/dist/types/components/editor/GridView.d.ts +1 -0
  18. package/dist/types/components/editor/PlayMode.d.ts +1 -0
  19. package/dist/types/components/editor/SelectionFrame.d.ts +8 -0
  20. package/dist/types/components/editor/SlideRail.d.ts +1 -0
  21. package/dist/types/components/editor/SlideView.d.ts +5 -0
  22. package/dist/types/components/editor/TopBar.d.ts +7 -0
  23. package/dist/types/index.d.ts +7 -0
  24. package/dist/types/lib/StoreProvider.d.ts +8 -0
  25. package/dist/types/lib/fonts.d.ts +9 -0
  26. package/dist/types/lib/pptx/deckToPptx.d.ts +9 -0
  27. package/dist/types/lib/pptx/index.d.ts +3 -0
  28. package/dist/types/lib/pptx/pptxToDeck.d.ts +18 -0
  29. package/dist/types/lib/pptx/types.d.ts +15 -0
  30. package/dist/types/lib/pptx/units.d.ts +25 -0
  31. package/dist/types/lib/schema/migrate.d.ts +25 -0
  32. package/dist/types/lib/seed.d.ts +2 -0
  33. package/dist/types/lib/store.d.ts +55 -0
  34. package/dist/types/lib/types.d.ts +141 -0
  35. package/dist/window.svg +1 -0
  36. package/package.json +86 -0
  37. package/src/App.tsx +261 -0
  38. package/src/SlidewiseEditor.css +146 -0
  39. package/src/SlidewiseEditor.tsx +214 -0
  40. package/src/SlidewiseFileEditor.tsx +242 -0
  41. package/src/components/editor/BottomToolbar.tsx +216 -0
  42. package/src/components/editor/Canvas.tsx +467 -0
  43. package/src/components/editor/Editor.tsx +53 -0
  44. package/src/components/editor/ElementView.tsx +588 -0
  45. package/src/components/editor/FloatingToolbar.tsx +729 -0
  46. package/src/components/editor/GridView.tsx +232 -0
  47. package/src/components/editor/PlayMode.tsx +260 -0
  48. package/src/components/editor/SelectionFrame.tsx +241 -0
  49. package/src/components/editor/SlideRail.tsx +285 -0
  50. package/src/components/editor/SlideView.tsx +55 -0
  51. package/src/components/editor/TopBar.tsx +240 -0
  52. package/src/fonts.css +2 -0
  53. package/src/index.css +13 -0
  54. package/src/index.ts +36 -0
  55. package/src/lib/StoreProvider.tsx +43 -0
  56. package/src/lib/__tests__/css-scope.test.ts +133 -0
  57. package/src/lib/fonts.ts +104 -0
  58. package/src/lib/pptx/__tests__/roundtrip.test.ts +240 -0
  59. package/src/lib/pptx/deckToPptx.ts +300 -0
  60. package/src/lib/pptx/index.ts +3 -0
  61. package/src/lib/pptx/pptxToDeck.ts +1515 -0
  62. package/src/lib/pptx/types.ts +17 -0
  63. package/src/lib/pptx/units.ts +32 -0
  64. package/src/lib/schema/__tests__/migrate.test.ts +70 -0
  65. package/src/lib/schema/migrate.ts +102 -0
  66. package/src/lib/seed.ts +777 -0
  67. package/src/lib/store.ts +384 -0
  68. package/src/lib/types.ts +185 -0
  69. package/src/main.tsx +10 -0
  70. package/src/vite-env.d.ts +3 -0
@@ -0,0 +1,1515 @@
1
+ import JSZip from "jszip";
2
+ import { XMLParser, XMLBuilder } from "fast-xml-parser";
3
+ import { nanoid } from "nanoid";
4
+ import type {
5
+ Deck,
6
+ Slide,
7
+ SlideElement,
8
+ TextElement,
9
+ TextRun,
10
+ ShapeElement,
11
+ ShapeKind,
12
+ ImageElement,
13
+ LineElement,
14
+ TableElement,
15
+ UnknownElement,
16
+ } from "@/lib/types";
17
+ import { SLIDE_W, SLIDE_H } from "@/lib/types";
18
+ import { CURRENT_DECK_VERSION } from "@/lib/schema/migrate";
19
+ import { emuToPx, pointsToPx } from "./units";
20
+ import type { ParseDiagnostics } from "./types";
21
+
22
+ /**
23
+ * Linear transform from raw source-PPTX pixels (EMU/EMU_PER_PX) into
24
+ * Slidewise's fixed 1920×1080 canvas. We pick a uniform scale that fits the
25
+ * source slide entirely, then center it — preserves aspect, letterboxes when
26
+ * source is 4:3 and target is 16:9.
27
+ */
28
+ interface Fit {
29
+ scale: number;
30
+ offsetX: number;
31
+ offsetY: number;
32
+ }
33
+
34
+ interface Rels {
35
+ byId: Map<string, { target: string; type: string }>;
36
+ }
37
+
38
+ interface ThemeColors {
39
+ // Theme color scheme. Keys match OOXML schemeClr @val tokens.
40
+ dk1: string;
41
+ lt1: string;
42
+ dk2: string;
43
+ lt2: string;
44
+ accent1: string;
45
+ accent2: string;
46
+ accent3: string;
47
+ accent4: string;
48
+ accent5: string;
49
+ accent6: string;
50
+ hlink: string;
51
+ folHlink: string;
52
+ }
53
+
54
+ interface PlaceholderInfo {
55
+ /** Geometry from layout/master in raw px (pre-fit). */
56
+ rawX?: number;
57
+ rawY?: number;
58
+ rawW?: number;
59
+ rawH?: number;
60
+ rotation?: number;
61
+ /** Default text style inherited when slide-level rPr is absent. */
62
+ rPr?: any;
63
+ pPr?: any;
64
+ bodyPr?: any;
65
+ /** Fallback paragraphs (used when the slide placeholder has no text). */
66
+ paragraphs?: any[];
67
+ }
68
+
69
+ interface MasterTextDefaults {
70
+ title?: any; // a:lvl1pPr (and friends) — we only use lvl1.
71
+ body?: any;
72
+ other?: any;
73
+ }
74
+
75
+ interface ParseContext {
76
+ diagnostics: ParseDiagnostics;
77
+ zip: JSZip;
78
+ slidePath: string;
79
+ slideRels: Rels;
80
+ fit: Fit;
81
+ theme: ThemeColors;
82
+ layoutPh: Map<string, PlaceholderInfo>;
83
+ masterPh: Map<string, PlaceholderInfo>;
84
+ masterTextDefaults: MasterTextDefaults;
85
+ }
86
+
87
+ const xmlParser = new XMLParser({
88
+ ignoreAttributes: false,
89
+ attributeNamePrefix: "@_",
90
+ parseTagValue: false,
91
+ parseAttributeValue: false,
92
+ trimValues: false,
93
+ preserveOrder: false,
94
+ isArray: (name) => ARRAY_TAGS.has(name),
95
+ });
96
+
97
+ const xmlBuilder = new XMLBuilder({
98
+ ignoreAttributes: false,
99
+ attributeNamePrefix: "@_",
100
+ format: false,
101
+ suppressEmptyNode: true,
102
+ });
103
+
104
+ // Tags that should always be arrays even when only one occurs.
105
+ const ARRAY_TAGS = new Set([
106
+ "p:sp",
107
+ "p:pic",
108
+ "p:cxnSp",
109
+ "p:graphicFrame",
110
+ "p:grpSp",
111
+ "a:p",
112
+ "a:r",
113
+ "a:tr",
114
+ "a:tc",
115
+ "Relationship",
116
+ ]);
117
+
118
+ const DEFAULT_THEME: ThemeColors = {
119
+ dk1: "#000000",
120
+ lt1: "#FFFFFF",
121
+ dk2: "#1F497D",
122
+ lt2: "#EEECE1",
123
+ accent1: "#4F81BD",
124
+ accent2: "#C0504D",
125
+ accent3: "#9BBB59",
126
+ accent4: "#8064A2",
127
+ accent5: "#4BACC6",
128
+ accent6: "#F79646",
129
+ hlink: "#0000FF",
130
+ folHlink: "#800080",
131
+ };
132
+
133
+ /**
134
+ * Parse a PPTX blob into a Slidewise Deck. Coverage:
135
+ * - Slide background (solid + theme color)
136
+ * - Text boxes with placeholder inheritance from layout/master, theme-color
137
+ * resolution, multi-run formatting, paragraph alignment, lineHeight
138
+ * - Preset shapes (rect, roundRect, ellipse, triangle, diamond, star — and
139
+ * many other prsts mapped to the closest available kind so they at least
140
+ * render with correct fill/position)
141
+ * - Images (embedded media → data URLs, srcRect crop preserved)
142
+ * - Connector lines (cxnSp) and shapes authored as prst="line"
143
+ * - Tables (basic row/cell content + header/body fills)
144
+ * - Group shapes (recursed and flattened with the group transform applied)
145
+ * - Anything else (charts, SmartArt, embedded video) is preserved as
146
+ * UnknownElement carrying its raw OOXML so a save round-trip can re-emit
147
+ * it without data loss.
148
+ */
149
+ export async function parsePptx(blob: Blob | ArrayBuffer): Promise<Deck> {
150
+ const zip = await JSZip.loadAsync(blob);
151
+ const diagnostics: ParseDiagnostics = {
152
+ unknownElementCount: 0,
153
+ droppedAnimations: 0,
154
+ warnings: [],
155
+ };
156
+
157
+ const presentationXml = await readXml(zip, "ppt/presentation.xml");
158
+ const presentationRels = await readRels(zip, "ppt/_rels/presentation.xml.rels");
159
+
160
+ const fit = computeFit(presentationXml);
161
+
162
+ const slideIdList = asArray<{ "@_r:id": string }>(
163
+ presentationXml?.["p:presentation"]?.["p:sldIdLst"]?.["p:sldId"]
164
+ );
165
+ const slidePaths = slideIdList
166
+ .map((entry) => presentationRels.byId.get(entry["@_r:id"])?.target)
167
+ .filter((p): p is string => Boolean(p))
168
+ .map((p) => normalisePath(p, "ppt"));
169
+
170
+ const title = await readTitle(zip);
171
+
172
+ const slides: Slide[] = [];
173
+ for (const slidePath of slidePaths) {
174
+ const slide = await parseSlide(zip, slidePath, diagnostics, fit);
175
+ if (slide) slides.push(slide);
176
+ }
177
+
178
+ if (!slides.length) {
179
+ slides.push({ id: nanoid(8), background: "#FFFFFF", elements: [] });
180
+ diagnostics.warnings.push("PPTX contained no slides; created an empty one.");
181
+ }
182
+
183
+ const deck: Deck = { version: CURRENT_DECK_VERSION, title, slides };
184
+ if (diagnostics.warnings.length) {
185
+ console.info("[slidewise/pptx] parse diagnostics:", diagnostics);
186
+ }
187
+ return deck;
188
+ }
189
+
190
+ async function parseSlide(
191
+ zip: JSZip,
192
+ slidePath: string,
193
+ diagnostics: ParseDiagnostics,
194
+ fit: Fit
195
+ ): Promise<Slide | null> {
196
+ const xml = await readXml(zip, slidePath);
197
+ if (!xml) return null;
198
+ const slideRelsPath = relsPathFor(slidePath);
199
+ const slideRels = await readRels(zip, slideRelsPath);
200
+
201
+ // Walk the rels chain: slide → slideLayout → slideMaster → theme.
202
+ const layoutTarget = firstByType(slideRels, "slideLayout");
203
+ const layoutPath = layoutTarget
204
+ ? normalisePath(layoutTarget, dirOf(slidePath))
205
+ : null;
206
+ const layoutXml = layoutPath ? await readXml(zip, layoutPath) : null;
207
+ const layoutRels = layoutPath
208
+ ? await readRels(zip, relsPathFor(layoutPath))
209
+ : { byId: new Map() };
210
+
211
+ const masterTarget = firstByType(layoutRels, "slideMaster");
212
+ const masterPath =
213
+ layoutPath && masterTarget
214
+ ? normalisePath(masterTarget, dirOf(layoutPath))
215
+ : null;
216
+ const masterXml = masterPath ? await readXml(zip, masterPath) : null;
217
+ const masterRels = masterPath
218
+ ? await readRels(zip, relsPathFor(masterPath))
219
+ : { byId: new Map() };
220
+
221
+ const themeTarget = firstByType(masterRels, "theme");
222
+ const themePath =
223
+ masterPath && themeTarget
224
+ ? normalisePath(themeTarget, dirOf(masterPath))
225
+ : null;
226
+ const themeXml = themePath ? await readXml(zip, themePath) : null;
227
+ const theme = themeXml ? extractTheme(themeXml) : DEFAULT_THEME;
228
+
229
+ const layoutPh = layoutXml ? extractPlaceholders(layoutXml) : new Map();
230
+ const masterPh = masterXml ? extractPlaceholders(masterXml) : new Map();
231
+ const masterTextDefaults: MasterTextDefaults = masterXml
232
+ ? extractMasterTextDefaults(masterXml)
233
+ : {};
234
+
235
+ const ctx: ParseContext = {
236
+ diagnostics,
237
+ zip,
238
+ slidePath,
239
+ slideRels,
240
+ fit,
241
+ theme,
242
+ layoutPh,
243
+ masterPh,
244
+ masterTextDefaults,
245
+ };
246
+
247
+ const sld = xml["p:sld"];
248
+ const cSld = sld?.["p:cSld"];
249
+ const slideBg = extractBackgroundColor(cSld?.["p:bg"], theme);
250
+ const layoutBg = layoutXml
251
+ ? extractBackgroundColor(
252
+ layoutXml?.["p:sldLayout"]?.["p:cSld"]?.["p:bg"],
253
+ theme
254
+ )
255
+ : undefined;
256
+ const masterBg = masterXml
257
+ ? extractBackgroundColor(
258
+ masterXml?.["p:sldMaster"]?.["p:cSld"]?.["p:bg"],
259
+ theme
260
+ )
261
+ : undefined;
262
+ const background = slideBg ?? layoutBg ?? masterBg ?? "#FFFFFF";
263
+
264
+ const spTree = cSld?.["p:spTree"];
265
+ const elements: SlideElement[] = [];
266
+
267
+ if (spTree) {
268
+ const collected = await parseSpTree(spTree, ctx, identityTransform());
269
+ let z = 1;
270
+ for (const el of collected) {
271
+ elements.push({ ...el, z: z++ });
272
+ }
273
+ }
274
+
275
+ return {
276
+ id: nanoid(8),
277
+ background,
278
+ elements,
279
+ };
280
+ }
281
+
282
+ interface GroupTransform {
283
+ /** Linear transform for child raw-px coordinates: x' = a*x + c, y' = b*y + d. */
284
+ a: number;
285
+ b: number;
286
+ c: number;
287
+ d: number;
288
+ }
289
+
290
+ function identityTransform(): GroupTransform {
291
+ return { a: 1, b: 1, c: 0, d: 0 };
292
+ }
293
+
294
+ async function parseSpTree(
295
+ spTree: any,
296
+ ctx: ParseContext,
297
+ outer: GroupTransform
298
+ ): Promise<SlideElement[]> {
299
+ const out: SlideElement[] = [];
300
+ for (const sp of asArray(spTree["p:sp"])) {
301
+ const el = await parseSpOrText(sp, ctx, outer);
302
+ if (el) out.push(el);
303
+ }
304
+ for (const pic of asArray(spTree["p:pic"])) {
305
+ const el = await parsePic(pic, ctx, outer);
306
+ if (el) out.push(el);
307
+ }
308
+ for (const cxn of asArray(spTree["p:cxnSp"])) {
309
+ const el = parseCxn(cxn, ctx, outer);
310
+ if (el) out.push(el);
311
+ }
312
+ for (const gf of asArray(spTree["p:graphicFrame"])) {
313
+ const el = parseGraphicFrame(gf, ctx, outer);
314
+ if (el) out.push(el);
315
+ }
316
+ for (const grp of asArray(spTree["p:grpSp"])) {
317
+ const inner = composeGroupTransform(grp, outer);
318
+ const children = await parseSpTree(grp, ctx, inner);
319
+ out.push(...children);
320
+ }
321
+ return out;
322
+ }
323
+
324
+ /**
325
+ * Compose the group transform. PPTX groups carry both an outer xfrm
326
+ * (off/ext, where the group sits on the slide) and chOff/chExt (the
327
+ * coordinate system its children author in). Mapping a child raw-px point
328
+ * (cx, cy) onto the slide is:
329
+ * x = (cx - chOffX) * (extX / chExtX) + offX
330
+ * y = (cy - chOffY) * (extY / chExtY) + offY
331
+ * Then the outer group's own transform is applied on top.
332
+ */
333
+ function composeGroupTransform(grp: any, outer: GroupTransform): GroupTransform {
334
+ const xfrm = grp?.["p:grpSpPr"]?.["a:xfrm"];
335
+ if (!xfrm) return outer;
336
+ const off = xfrm["a:off"];
337
+ const ext = xfrm["a:ext"];
338
+ const chOff = xfrm["a:chOff"];
339
+ const chExt = xfrm["a:chExt"];
340
+ if (!off || !ext || !chOff || !chExt) return outer;
341
+ const offX = emuToPx(Number(off["@_x"] ?? 0));
342
+ const offY = emuToPx(Number(off["@_y"] ?? 0));
343
+ const extX = emuToPx(Number(ext["@_cx"] ?? 0)) || 1;
344
+ const extY = emuToPx(Number(ext["@_cy"] ?? 0)) || 1;
345
+ const cOffX = emuToPx(Number(chOff["@_x"] ?? 0));
346
+ const cOffY = emuToPx(Number(chOff["@_y"] ?? 0));
347
+ const cExtX = emuToPx(Number(chExt["@_cx"] ?? 0)) || extX;
348
+ const cExtY = emuToPx(Number(chExt["@_cy"] ?? 0)) || extY;
349
+ const ax = extX / cExtX;
350
+ const by = extY / cExtY;
351
+ const cx0 = offX - cOffX * ax;
352
+ const dy0 = offY - cOffY * by;
353
+ // Compose with outer: outer maps (x,y) -> (a*x+c, b*y+d). After local: (ax*x+cx0, by*y+dy0).
354
+ // Combined: outer(local(x,y)) = (a*(ax*x+cx0)+c, b*(by*y+dy0)+d)
355
+ return {
356
+ a: outer.a * ax,
357
+ b: outer.b * by,
358
+ c: outer.a * cx0 + outer.c,
359
+ d: outer.b * dy0 + outer.d,
360
+ };
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // shape / text
365
+ // ---------------------------------------------------------------------------
366
+
367
+ async function parseSpOrText(
368
+ sp: any,
369
+ ctx: ParseContext,
370
+ outer: GroupTransform
371
+ ): Promise<SlideElement | null> {
372
+ const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
373
+ const phKey = ph ? placeholderKey(ph) : null;
374
+ const layoutPh = phKey ? lookupPlaceholder(ctx.layoutPh, ph!) : undefined;
375
+ const masterPh = phKey ? lookupPlaceholder(ctx.masterPh, ph!) : undefined;
376
+
377
+ const xfrm = sp?.["p:spPr"]?.["a:xfrm"];
378
+ const geom = readGeometry(xfrm, ctx.fit, outer)
379
+ ?? placeholderGeometry(layoutPh, ctx.fit, outer)
380
+ ?? placeholderGeometry(masterPh, ctx.fit, outer);
381
+
382
+ if (!geom) {
383
+ return toUnknown(sp, "p:sp", ctx, outer);
384
+ }
385
+
386
+ const txBody = sp["p:txBody"];
387
+ const prstGeom = sp?.["p:spPr"]?.["a:prstGeom"];
388
+ const presetName = prstGeom?.["@_prst"];
389
+
390
+ // Lines are sometimes authored as <p:sp prst="line">.
391
+ if (presetName === "line" || presetName === "straightConnector1") {
392
+ const flipV = xfrm?.["@_flipV"] === "1";
393
+ return makeLineFromGeometry(
394
+ geom,
395
+ sp?.["p:spPr"]?.["a:ln"],
396
+ ctx,
397
+ flipV
398
+ );
399
+ }
400
+
401
+ const phType = ph?.["@_type"];
402
+ const isPlaceholderTextHost = !!ph && phType !== "pic";
403
+ const hasText = !!txBody && hasAnyText(txBody);
404
+ const isText =
405
+ hasText ||
406
+ (isPlaceholderTextHost && !presetName) ||
407
+ (!!txBody && (!presetName || presetName === "rect"));
408
+
409
+ if (isText) {
410
+ return makeTextElement(sp, txBody, geom, ctx, ph, layoutPh, masterPh);
411
+ }
412
+
413
+ // Fill / stroke. Use placeholder-inherited spPr if slide spPr is empty.
414
+ const spPr = sp?.["p:spPr"];
415
+ const fillColor = extractShapeFill(spPr, ctx.theme) ?? "transparent";
416
+ const lineProps = spPr?.["a:ln"];
417
+ const lineHasNoFill = lineProps?.["a:noFill"] !== undefined;
418
+ const stroke = lineHasNoFill
419
+ ? undefined
420
+ : resolveColor(lineProps?.["a:solidFill"], ctx.theme);
421
+ const strokeWidthEmu =
422
+ !lineHasNoFill && lineProps?.["@_w"]
423
+ ? Number(lineProps["@_w"])
424
+ : undefined;
425
+
426
+ const kind = mapPrstToKind(presetName);
427
+ if (!kind) {
428
+ // Fall back to a rect with the shape's fill so it remains visible at the
429
+ // correct position rather than dropping to an opaque "Imported content"
430
+ // tile.
431
+ const fallback: ShapeElement = {
432
+ id: nanoid(8),
433
+ type: "shape",
434
+ ...geom,
435
+ z: 0,
436
+ shape: "rect",
437
+ fill: fillColor === "transparent" ? "rgba(0,0,0,0)" : fillColor,
438
+ stroke,
439
+ strokeWidth: strokeWidthEmu
440
+ ? Math.max(1, Math.round(emuToPx(strokeWidthEmu) * ctx.fit.scale))
441
+ : undefined,
442
+ };
443
+ return fallback;
444
+ }
445
+
446
+ // PPTX `roundRect` carries the corner radius via <a:avLst><a:gd name="adj"
447
+ // fmla="val N"/></a:avLst>; N is in 1/100000ths of the shorter side.
448
+ let radius: number | undefined;
449
+ if (presetName === "roundRect") {
450
+ const adj = asArray(prstGeom?.["a:avLst"]?.["a:gd"]).find(
451
+ (g: any) => g?.["@_name"] === "adj"
452
+ );
453
+ const fmla: string | undefined = adj?.["@_fmla"];
454
+ const m = typeof fmla === "string" ? /val\s+(-?\d+)/.exec(fmla) : null;
455
+ const frac = m ? Number(m[1]) / 100000 : 0.16667;
456
+ radius = Math.round(Math.min(geom.w, geom.h) * frac);
457
+ }
458
+
459
+ const shape: ShapeElement = {
460
+ id: nanoid(8),
461
+ type: "shape",
462
+ ...geom,
463
+ z: 0,
464
+ shape: kind,
465
+ fill: fillColor,
466
+ stroke,
467
+ strokeWidth: strokeWidthEmu
468
+ ? Math.max(1, Math.round(emuToPx(strokeWidthEmu) * ctx.fit.scale))
469
+ : undefined,
470
+ radius,
471
+ };
472
+ return shape;
473
+ }
474
+
475
+ function makeTextElement(
476
+ _sp: any,
477
+ txBody: any,
478
+ geom: { x: number; y: number; w: number; h: number; rotation: number },
479
+ ctx: ParseContext,
480
+ ph: any,
481
+ layoutPh: PlaceholderInfo | undefined,
482
+ masterPh: PlaceholderInfo | undefined
483
+ ): TextElement {
484
+ // Try the slide's own txBody first; if it has no actual runs, fall back to
485
+ // the layout/master placeholder's stub text so titles like "Click to edit
486
+ // title" don't render but real layout-supplied titles do.
487
+ const hasRealText = txBody && hasAnyText(txBody);
488
+ const effectiveTxBody = hasRealText
489
+ ? txBody
490
+ : layoutPh?.paragraphs
491
+ ? { "a:bodyPr": layoutPh.bodyPr, "a:p": layoutPh.paragraphs }
492
+ : masterPh?.paragraphs
493
+ ? { "a:bodyPr": masterPh.bodyPr, "a:p": masterPh.paragraphs }
494
+ : txBody;
495
+
496
+ // Master defaults for the placeholder type (title vs body vs other).
497
+ const phType = ph?.["@_type"];
498
+ const masterDef =
499
+ phType === "title" || phType === "ctrTitle"
500
+ ? ctx.masterTextDefaults.title
501
+ : phType === "body" || phType === "subTitle"
502
+ ? ctx.masterTextDefaults.body
503
+ : ctx.masterTextDefaults.other;
504
+
505
+ // Accumulate inheritance: slide < layout < master < masterDefaults.
506
+ const fallbackRPr = mergeFirst(
507
+ layoutPh?.rPr,
508
+ masterPh?.rPr,
509
+ masterDef?.["a:defRPr"]
510
+ );
511
+ const fallbackPPr = mergeFirst(
512
+ layoutPh?.pPr,
513
+ masterPh?.pPr,
514
+ masterDef
515
+ );
516
+ const fallbackBodyPr = mergeFirst(layoutPh?.bodyPr, masterPh?.bodyPr);
517
+
518
+ const text = extractRuns(effectiveTxBody, ctx.theme, fallbackRPr, fallbackPPr);
519
+ const first = text.runs[0];
520
+ const align = text.align ?? readAlign(fallbackPPr) ?? "left";
521
+ const valign =
522
+ readBodyVAlign(effectiveTxBody?.["a:bodyPr"]) ??
523
+ readBodyVAlign(fallbackBodyPr) ??
524
+ "top";
525
+
526
+ const scale = ctx.fit.scale;
527
+ const fontSize = first?.fontSize
528
+ ? Math.max(6, Math.round(first.fontSize * scale))
529
+ : Math.round(defaultFontSizePx(phType, ctx) * scale);
530
+ const fontFamily =
531
+ first?.fontFamily ??
532
+ fallbackRPr?.["a:latin"]?.["@_typeface"] ??
533
+ "Inter";
534
+ const fontWeight = first?.bold ? 700 : 400;
535
+ const color =
536
+ first?.color ??
537
+ resolveColor(fallbackRPr?.["a:solidFill"], ctx.theme) ??
538
+ "#0E1330";
539
+
540
+ const runs: TextRun[] = text.runs.map((r) => ({
541
+ text: r.text,
542
+ fontFamily: r.fontFamily,
543
+ fontSize: r.fontSize ? Math.max(6, Math.round(r.fontSize * scale)) : undefined,
544
+ fontWeight: r.bold ? 700 : r.bold === false ? 400 : undefined,
545
+ italic: r.italic,
546
+ underline: r.underline,
547
+ strike: r.strike,
548
+ color: r.color,
549
+ letterSpacing: r.letterSpacing
550
+ ? Math.round(r.letterSpacing * scale)
551
+ : undefined,
552
+ }));
553
+ const hasMixedFormatting = runs.length > 1 && runs.some((r, i) => {
554
+ if (i === 0) return false;
555
+ const a = runs[0];
556
+ return (
557
+ a.color !== r.color ||
558
+ a.fontFamily !== r.fontFamily ||
559
+ a.fontSize !== r.fontSize ||
560
+ a.fontWeight !== r.fontWeight ||
561
+ a.italic !== r.italic ||
562
+ a.underline !== r.underline ||
563
+ a.strike !== r.strike
564
+ );
565
+ });
566
+
567
+ const el: TextElement = {
568
+ id: nanoid(8),
569
+ type: "text",
570
+ ...geom,
571
+ z: 0,
572
+ text: text.plain,
573
+ fontFamily,
574
+ fontSize,
575
+ fontWeight,
576
+ italic: !!first?.italic,
577
+ underline: !!first?.underline,
578
+ strike: !!first?.strike,
579
+ color,
580
+ align,
581
+ vAlign: valign,
582
+ lineHeight: text.lineHeightPct ?? 1.2,
583
+ letterSpacing: first?.letterSpacing
584
+ ? Math.round(first.letterSpacing * scale)
585
+ : 0,
586
+ ...(hasMixedFormatting ? { runs } : {}),
587
+ };
588
+ return el;
589
+ }
590
+
591
+ function defaultFontSizePx(phType: string | undefined, _ctx: ParseContext): number {
592
+ // Slidewise pixels (will be scaled by fit.scale by caller).
593
+ if (phType === "title" || phType === "ctrTitle") return pointsToPx(44);
594
+ if (phType === "body" || phType === "subTitle") return pointsToPx(24);
595
+ return pointsToPx(18);
596
+ }
597
+
598
+ async function parsePic(
599
+ pic: any,
600
+ ctx: ParseContext,
601
+ outer: GroupTransform
602
+ ): Promise<SlideElement | null> {
603
+ const xfrm = pic?.["p:spPr"]?.["a:xfrm"];
604
+ const geom = readGeometry(xfrm, ctx.fit, outer);
605
+ if (!geom) return toUnknown(pic, "p:pic", ctx, outer);
606
+
607
+ const blipRef = pic?.["p:blipFill"]?.["a:blip"]?.["@_r:embed"];
608
+ if (!blipRef) return toUnknown(pic, "p:pic", ctx, outer);
609
+
610
+ const mediaPath = ctx.slideRels.byId.get(blipRef)?.target;
611
+ if (!mediaPath) return toUnknown(pic, "p:pic", ctx, outer);
612
+
613
+ const fullPath = normalisePath(mediaPath, dirOf(ctx.slidePath));
614
+ const file = ctx.zip.file(fullPath);
615
+ if (!file) return toUnknown(pic, "p:pic", ctx, outer);
616
+
617
+ const base64 = await file.async("base64");
618
+ const ext = (fullPath.split(".").pop() || "png").toLowerCase();
619
+ const mime = mimeForExt(ext);
620
+
621
+ const blipFill = pic?.["p:blipFill"];
622
+ const hasStretch = !!blipFill?.["a:stretch"];
623
+ const fitMode: ImageElement["fit"] = hasStretch ? "fill" : "cover";
624
+
625
+ const sr = blipFill?.["a:srcRect"];
626
+ const crop = sr
627
+ ? {
628
+ l: Number(sr["@_l"] ?? 0) / 100000,
629
+ r: Number(sr["@_r"] ?? 0) / 100000,
630
+ t: Number(sr["@_t"] ?? 0) / 100000,
631
+ b: Number(sr["@_b"] ?? 0) / 100000,
632
+ }
633
+ : undefined;
634
+ const hasCrop =
635
+ crop && (crop.l > 0 || crop.r > 0 || crop.t > 0 || crop.b > 0);
636
+
637
+ const image: ImageElement = {
638
+ id: nanoid(8),
639
+ type: "image",
640
+ ...geom,
641
+ z: 0,
642
+ src: `data:${mime};base64,${base64}`,
643
+ fit: fitMode,
644
+ ...(hasCrop ? { crop } : {}),
645
+ };
646
+ return image;
647
+ }
648
+
649
+ function parseCxn(
650
+ cxn: any,
651
+ ctx: ParseContext,
652
+ outer: GroupTransform
653
+ ): SlideElement | null {
654
+ const xfrm = cxn?.["p:spPr"]?.["a:xfrm"];
655
+ const geom = readGeometry(xfrm, ctx.fit, outer);
656
+ if (!geom) return null;
657
+ const flipV = xfrm?.["@_flipV"] === "1";
658
+ return makeLineFromGeometry(geom, cxn?.["p:spPr"]?.["a:ln"], ctx, flipV);
659
+ }
660
+
661
+ function makeLineFromGeometry(
662
+ geom: { x: number; y: number; w: number; h: number; rotation: number },
663
+ lineProps: any,
664
+ ctx: ParseContext,
665
+ flipV: boolean
666
+ ): LineElement {
667
+ const stroke = resolveColor(lineProps?.["a:solidFill"], ctx.theme) ?? "#0E1330";
668
+ const strokeWidth = lineProps?.["@_w"]
669
+ ? Math.max(1, Math.round(emuToPx(Number(lineProps["@_w"])) * ctx.fit.scale))
670
+ : 4;
671
+ const dashVal = lineProps?.["a:prstDash"]?.["@_val"];
672
+ // <a:prstDash val="solid"/> is a valid explicit solid declaration. Only the
673
+ // patterned values are actually dashed; everything else (including absent
674
+ // and "solid") renders as a normal line.
675
+ const dashed =
676
+ typeof dashVal === "string" &&
677
+ dashVal !== "solid" &&
678
+ dashVal.length > 0;
679
+ // <a:headEnd type="none"/> is valid PPTX for an explicit "no arrowhead".
680
+ // Only mark as arrow when the type is one of the actual arrowhead presets.
681
+ const headType = lineProps?.["a:headEnd"]?.["@_type"];
682
+ const tailType = lineProps?.["a:tailEnd"]?.["@_type"];
683
+ const isArrowType = (t: unknown) =>
684
+ typeof t === "string" && t.length > 0 && t !== "none";
685
+ const arrow = isArrowType(headType) || isArrowType(tailType);
686
+ const rawH = flipV ? -geom.h : geom.h;
687
+ const w = geom.w === 0 ? 1 : geom.w;
688
+ const h = Math.abs(rawH) === 0 ? 1 : rawH;
689
+ const line: LineElement = {
690
+ id: nanoid(8),
691
+ type: "line",
692
+ x: geom.x,
693
+ y: geom.y,
694
+ w,
695
+ h,
696
+ rotation: geom.rotation,
697
+ z: 0,
698
+ stroke,
699
+ strokeWidth,
700
+ dashed,
701
+ arrow,
702
+ };
703
+ return line;
704
+ }
705
+
706
+ function parseGraphicFrame(
707
+ gf: any,
708
+ ctx: ParseContext,
709
+ outer: GroupTransform
710
+ ): SlideElement | null {
711
+ const tbl = gf?.["a:graphic"]?.["a:graphicData"]?.["a:tbl"];
712
+ if (tbl) {
713
+ const parsed = parseTable(gf, tbl, ctx, outer);
714
+ if (parsed) return parsed;
715
+ }
716
+ return toUnknown(gf, "p:graphicFrame", ctx, outer);
717
+ }
718
+
719
+ function parseTable(
720
+ gf: any,
721
+ tbl: any,
722
+ ctx: ParseContext,
723
+ outer: GroupTransform
724
+ ): TableElement | null {
725
+ const xfrm = gf?.["p:xfrm"] || gf?.["p:spPr"]?.["a:xfrm"];
726
+ const geom = readGeometry(xfrm, ctx.fit, outer);
727
+ if (!geom) return null;
728
+
729
+ const trs = asArray(tbl["a:tr"]);
730
+ if (!trs.length) return null;
731
+
732
+ const rows: string[][] = [];
733
+ let firstFontSizePx: number | undefined;
734
+ let firstColor: string | undefined;
735
+ let headerFill = "#0E1330";
736
+ let bodyFill = "#FFFFFF";
737
+
738
+ for (let ri = 0; ri < trs.length; ri++) {
739
+ const tr = trs[ri];
740
+ const tcs = asArray(tr["a:tc"]);
741
+ const cells: string[] = [];
742
+ for (const tc of tcs) {
743
+ if (tc?.["@_hMerge"] === "1" || tc?.["@_vMerge"] === "1") {
744
+ cells.push("");
745
+ continue;
746
+ }
747
+ const txBody = tc["a:txBody"];
748
+ const text = txBody
749
+ ? extractRuns(txBody, ctx.theme)
750
+ : { plain: "", runs: [] as RunInfo[] };
751
+ cells.push(text.plain);
752
+
753
+ const r0 = text.runs[0];
754
+ if (firstFontSizePx === undefined && r0?.fontSize) {
755
+ firstFontSizePx = Math.max(8, Math.round(r0.fontSize * ctx.fit.scale));
756
+ }
757
+ if (!firstColor && r0?.color) firstColor = r0.color;
758
+
759
+ const cellFill = resolveColor(tc?.["a:tcPr"]?.["a:solidFill"], ctx.theme);
760
+ if (cellFill) {
761
+ if (ri === 0) headerFill = cellFill;
762
+ else bodyFill = cellFill;
763
+ }
764
+ }
765
+ rows.push(cells);
766
+ }
767
+
768
+ const table: TableElement = {
769
+ id: nanoid(8),
770
+ type: "table",
771
+ ...geom,
772
+ z: 0,
773
+ rows,
774
+ headerFill,
775
+ rowFill: bodyFill,
776
+ textColor: firstColor ?? "#0E1330",
777
+ fontSize: firstFontSizePx ?? 18,
778
+ };
779
+ return table;
780
+ }
781
+
782
+ function toUnknown(
783
+ node: any,
784
+ tag: string,
785
+ ctx: ParseContext,
786
+ outer: GroupTransform
787
+ ): UnknownElement {
788
+ ctx.diagnostics.unknownElementCount++;
789
+ const xfrm =
790
+ node?.["p:spPr"]?.["a:xfrm"] ||
791
+ node?.["p:grpSpPr"]?.["a:xfrm"] ||
792
+ node?.["a:xfrm"];
793
+ const geom = readGeometry(xfrm, ctx.fit, outer) ?? {
794
+ x: 0,
795
+ y: 0,
796
+ w: 200,
797
+ h: 100,
798
+ rotation: 0,
799
+ };
800
+ return {
801
+ id: nanoid(8),
802
+ type: "unknown",
803
+ ...geom,
804
+ z: 0,
805
+ ooxmlTag: tag,
806
+ ooxmlXml: xmlBuilder.build({ [tag]: node }),
807
+ label: friendlyLabelForTag(tag),
808
+ };
809
+ }
810
+
811
+ function friendlyLabelForTag(tag: string): string {
812
+ switch (tag) {
813
+ case "p:graphicFrame":
814
+ return "Chart / table / SmartArt";
815
+ case "p:grpSp":
816
+ return "Grouped shapes";
817
+ case "p:sp":
818
+ return "Imported shape";
819
+ case "p:pic":
820
+ return "Image";
821
+ default:
822
+ return "Imported content";
823
+ }
824
+ }
825
+
826
+ // ---------------------------------------------------------------------------
827
+ // placeholders + masters
828
+ // ---------------------------------------------------------------------------
829
+
830
+ function placeholderKey(ph: any): string {
831
+ const type = ph?.["@_type"] ?? "";
832
+ const idx = ph?.["@_idx"] ?? "";
833
+ return `${type}|${idx}`;
834
+ }
835
+
836
+ function lookupPlaceholder(
837
+ map: Map<string, PlaceholderInfo>,
838
+ ph: any
839
+ ): PlaceholderInfo | undefined {
840
+ const type = ph?.["@_type"] ?? "";
841
+ const idx = ph?.["@_idx"] ?? "";
842
+ // Try exact, then by idx alone, then by type alone.
843
+ return (
844
+ map.get(`${type}|${idx}`) ??
845
+ map.get(`|${idx}`) ??
846
+ map.get(`${type}|`) ??
847
+ (type === "ctrTitle" ? map.get("title|") : undefined) ??
848
+ (type === "subTitle" ? map.get("body|") : undefined)
849
+ );
850
+ }
851
+
852
+ function placeholderGeometry(
853
+ ph: PlaceholderInfo | undefined,
854
+ fit: Fit,
855
+ outer: GroupTransform
856
+ ): { x: number; y: number; w: number; h: number; rotation: number } | null {
857
+ if (!ph || ph.rawX === undefined) return null;
858
+ return applyFit(
859
+ {
860
+ rawX: ph.rawX!,
861
+ rawY: ph.rawY!,
862
+ rawW: ph.rawW!,
863
+ rawH: ph.rawH!,
864
+ rotation: ph.rotation ?? 0,
865
+ },
866
+ fit,
867
+ outer
868
+ );
869
+ }
870
+
871
+ function extractPlaceholders(rootXml: any): Map<string, PlaceholderInfo> {
872
+ const out = new Map<string, PlaceholderInfo>();
873
+ const root =
874
+ rootXml?.["p:sldLayout"] ?? rootXml?.["p:sldMaster"] ?? rootXml;
875
+ const sps = asArray(root?.["p:cSld"]?.["p:spTree"]?.["p:sp"]);
876
+ for (const sp of sps) {
877
+ const ph = sp?.["p:nvSpPr"]?.["p:nvPr"]?.["p:ph"];
878
+ if (!ph) continue;
879
+ const xfrm = sp?.["p:spPr"]?.["a:xfrm"];
880
+ const off = xfrm?.["a:off"];
881
+ const ext = xfrm?.["a:ext"];
882
+ const txBody = sp?.["p:txBody"];
883
+ const paragraphs = asArray(txBody?.["a:p"]);
884
+ const firstP = paragraphs[0];
885
+ const firstR = asArray(firstP?.["a:r"])[0];
886
+ const info: PlaceholderInfo = {
887
+ rawX: off ? emuToPx(Number(off["@_x"] ?? 0)) : undefined,
888
+ rawY: off ? emuToPx(Number(off["@_y"] ?? 0)) : undefined,
889
+ rawW: ext ? emuToPx(Number(ext["@_cx"] ?? 0)) : undefined,
890
+ rawH: ext ? emuToPx(Number(ext["@_cy"] ?? 0)) : undefined,
891
+ rotation: xfrm?.["@_rot"] ? Number(xfrm["@_rot"]) / 60000 : 0,
892
+ rPr: firstR?.["a:rPr"] ?? firstP?.["a:pPr"]?.["a:defRPr"],
893
+ pPr: firstP?.["a:pPr"],
894
+ bodyPr: txBody?.["a:bodyPr"],
895
+ paragraphs: hasAnyText(txBody) ? paragraphs : undefined,
896
+ };
897
+ out.set(placeholderKey(ph), info);
898
+ }
899
+ return out;
900
+ }
901
+
902
+ function extractMasterTextDefaults(masterXml: any): MasterTextDefaults {
903
+ const txStyles = masterXml?.["p:sldMaster"]?.["p:txStyles"];
904
+ if (!txStyles) return {};
905
+ return {
906
+ title: txStyles?.["p:titleStyle"]?.["a:lvl1pPr"],
907
+ body: txStyles?.["p:bodyStyle"]?.["a:lvl1pPr"],
908
+ other: txStyles?.["p:otherStyle"]?.["a:lvl1pPr"],
909
+ };
910
+ }
911
+
912
+ // ---------------------------------------------------------------------------
913
+ // theme
914
+ // ---------------------------------------------------------------------------
915
+
916
+ function extractTheme(themeXml: any): ThemeColors {
917
+ const scheme =
918
+ themeXml?.["a:theme"]?.["a:themeElements"]?.["a:clrScheme"] ?? {};
919
+ const out: ThemeColors = { ...DEFAULT_THEME };
920
+ for (const key of [
921
+ "dk1",
922
+ "lt1",
923
+ "dk2",
924
+ "lt2",
925
+ "accent1",
926
+ "accent2",
927
+ "accent3",
928
+ "accent4",
929
+ "accent5",
930
+ "accent6",
931
+ "hlink",
932
+ "folHlink",
933
+ ] as const) {
934
+ const node = scheme[`a:${key}`];
935
+ const color = node ? readSchemeBaseColor(node) : undefined;
936
+ if (color) out[key] = color;
937
+ }
938
+ return out;
939
+ }
940
+
941
+ function readSchemeBaseColor(node: any): string | undefined {
942
+ const srgb = node?.["a:srgbClr"]?.["@_val"];
943
+ if (srgb) return `#${String(srgb).toUpperCase()}`;
944
+ const sys = node?.["a:sysClr"]?.["@_lastClr"];
945
+ if (sys) return `#${String(sys).toUpperCase()}`;
946
+ return undefined;
947
+ }
948
+
949
+ // ---------------------------------------------------------------------------
950
+ // color resolution
951
+ // ---------------------------------------------------------------------------
952
+
953
+ /**
954
+ * Resolve a color node into a CSS hex string. Handles solid fill envelopes
955
+ * (a:solidFill containing srgb/sys/scheme/prstClr) and bare color nodes
956
+ * (e.g. a gradient stop). Applies modifiers: lumMod, lumOff, shade, tint,
957
+ * alpha. Returns #RRGGBB or #RRGGBBAA.
958
+ */
959
+ function resolveColor(node: any, theme: ThemeColors): string | undefined {
960
+ if (!node) return undefined;
961
+ // Allow caller to pass either a:solidFill or a bare color node.
962
+ const inner = pickColorChild(node) ?? node;
963
+ if (!inner) return undefined;
964
+ let base = readBaseHex(inner, theme);
965
+ if (!base) return undefined;
966
+ let { r, g, b, a } = hexToRgba(base);
967
+ let { h, s, l } = rgbToHsl(r, g, b);
968
+
969
+ const modParent = pickColorChildEnvelope(node) ?? inner;
970
+ const lumMod = numFromVal(modParent?.["a:lumMod"]);
971
+ const lumOff = numFromVal(modParent?.["a:lumOff"]);
972
+ const shade = numFromVal(modParent?.["a:shade"]);
973
+ const tint = numFromVal(modParent?.["a:tint"]);
974
+ const alphaN = numFromVal(modParent?.["a:alpha"]);
975
+
976
+ if (lumMod !== undefined) l = clamp(l * lumMod);
977
+ if (lumOff !== undefined) l = clamp(l + lumOff);
978
+ // shade/tint: per OOXML, val=100000 is no-op. shade darkens via L; tint lightens via L.
979
+ if (shade !== undefined) l = clamp(l * shade);
980
+ if (tint !== undefined) l = clamp(l + (1 - l) * (1 - tint));
981
+
982
+ ({ r, g, b } = hslToRgb(h, s, l));
983
+ if (alphaN !== undefined) a = clamp(a * alphaN);
984
+
985
+ const hex = rgbToHex(r, g, b);
986
+ if (a >= 0.999) return hex;
987
+ const aa = Math.round(a * 255).toString(16).padStart(2, "0").toUpperCase();
988
+ return `${hex}${aa}`;
989
+ }
990
+
991
+ function pickColorChildEnvelope(node: any): any | undefined {
992
+ // Prefers the inner color child when called with a wrapping <a:solidFill>.
993
+ return (
994
+ node?.["a:srgbClr"] ??
995
+ node?.["a:sysClr"] ??
996
+ node?.["a:schemeClr"] ??
997
+ node?.["a:prstClr"] ??
998
+ undefined
999
+ );
1000
+ }
1001
+
1002
+ function pickColorChild(node: any): any | undefined {
1003
+ // Returns whichever <a:*Clr> child is present, normalising the envelope.
1004
+ if (node?.["a:srgbClr"] || node?.["a:sysClr"] || node?.["a:schemeClr"] || node?.["a:prstClr"]) {
1005
+ return node;
1006
+ }
1007
+ return undefined;
1008
+ }
1009
+
1010
+ function readBaseHex(node: any, theme: ThemeColors): string | undefined {
1011
+ const srgb = node?.["a:srgbClr"]?.["@_val"];
1012
+ if (srgb) return `#${String(srgb).toUpperCase()}`;
1013
+ const sys = node?.["a:sysClr"]?.["@_lastClr"];
1014
+ if (sys) return `#${String(sys).toUpperCase()}`;
1015
+ const scheme = node?.["a:schemeClr"]?.["@_val"];
1016
+ if (scheme) return resolveSchemeToken(scheme, theme);
1017
+ const prst = node?.["a:prstClr"]?.["@_val"];
1018
+ if (prst) return resolvePresetColor(prst);
1019
+ return undefined;
1020
+ }
1021
+
1022
+ function resolveSchemeToken(token: string, theme: ThemeColors): string {
1023
+ switch (token) {
1024
+ case "bg1":
1025
+ return theme.lt1;
1026
+ case "bg2":
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
+ }
1039
+ }
1040
+ }
1041
+
1042
+ function resolvePresetColor(name: string): string {
1043
+ // Very small subset of HTML4-like names; rarely used in modern PPTX.
1044
+ const map: Record<string, string> = {
1045
+ black: "#000000",
1046
+ white: "#FFFFFF",
1047
+ red: "#FF0000",
1048
+ green: "#008000",
1049
+ blue: "#0000FF",
1050
+ yellow: "#FFFF00",
1051
+ cyan: "#00FFFF",
1052
+ magenta: "#FF00FF",
1053
+ gray: "#808080",
1054
+ };
1055
+ return map[name.toLowerCase()] ?? "#000000";
1056
+ }
1057
+
1058
+ function numFromVal(node: any): number | undefined {
1059
+ const v = node?.["@_val"];
1060
+ if (v === undefined || v === null || v === "") return undefined;
1061
+ const n = Number(v) / 100000;
1062
+ return Number.isFinite(n) ? n : undefined;
1063
+ }
1064
+
1065
+ function clamp(n: number): number {
1066
+ return n < 0 ? 0 : n > 1 ? 1 : n;
1067
+ }
1068
+
1069
+ function hexToRgba(hex: string): { r: number; g: number; b: number; a: number } {
1070
+ const h = hex.replace("#", "");
1071
+ const r = parseInt(h.slice(0, 2), 16);
1072
+ const g = parseInt(h.slice(2, 4), 16);
1073
+ const b = parseInt(h.slice(4, 6), 16);
1074
+ const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
1075
+ return { r, g, b, a };
1076
+ }
1077
+
1078
+ function rgbToHex(r: number, g: number, b: number): string {
1079
+ const to = (n: number) =>
1080
+ Math.max(0, Math.min(255, Math.round(n)))
1081
+ .toString(16)
1082
+ .padStart(2, "0")
1083
+ .toUpperCase();
1084
+ return `#${to(r)}${to(g)}${to(b)}`;
1085
+ }
1086
+
1087
+ function rgbToHsl(r: number, g: number, b: number): { h: number; s: number; l: number } {
1088
+ const rn = r / 255;
1089
+ const gn = g / 255;
1090
+ const bn = b / 255;
1091
+ const max = Math.max(rn, gn, bn);
1092
+ const min = Math.min(rn, gn, bn);
1093
+ const l = (max + min) / 2;
1094
+ let h = 0;
1095
+ let s = 0;
1096
+ if (max !== min) {
1097
+ const d = max - min;
1098
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
1099
+ if (max === rn) h = (gn - bn) / d + (gn < bn ? 6 : 0);
1100
+ else if (max === gn) h = (bn - rn) / d + 2;
1101
+ else h = (rn - gn) / d + 4;
1102
+ h /= 6;
1103
+ }
1104
+ return { h, s, l };
1105
+ }
1106
+
1107
+ function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
1108
+ if (s === 0) {
1109
+ const v = Math.round(l * 255);
1110
+ return { r: v, g: v, b: v };
1111
+ }
1112
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
1113
+ const p = 2 * l - q;
1114
+ const hue = (t: number) => {
1115
+ let tt = t;
1116
+ if (tt < 0) tt += 1;
1117
+ if (tt > 1) tt -= 1;
1118
+ if (tt < 1 / 6) return p + (q - p) * 6 * tt;
1119
+ if (tt < 1 / 2) return q;
1120
+ if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
1121
+ return p;
1122
+ };
1123
+ return {
1124
+ r: Math.round(hue(h + 1 / 3) * 255),
1125
+ g: Math.round(hue(h) * 255),
1126
+ b: Math.round(hue(h - 1 / 3) * 255),
1127
+ };
1128
+ }
1129
+
1130
+ /**
1131
+ * Extract a CSS background string from a shape's fill spec. Theme-aware.
1132
+ */
1133
+ function extractShapeFill(spPr: any, theme: ThemeColors): string | undefined {
1134
+ if (!spPr) return undefined;
1135
+ if (spPr["a:noFill"] !== undefined) return "transparent";
1136
+ if (spPr["a:solidFill"]) {
1137
+ return resolveColor(spPr["a:solidFill"], theme);
1138
+ }
1139
+ const gf = spPr["a:gradFill"];
1140
+ if (gf) {
1141
+ const stops = asArray(gf["a:gsLst"]?.["a:gs"])
1142
+ .map((g: any) => {
1143
+ const pos = Number(g?.["@_pos"] ?? 0) / 1000;
1144
+ const color = resolveColor(g, theme) ?? "#000000";
1145
+ return { pos, color };
1146
+ })
1147
+ .sort((a, b) => a.pos - b.pos);
1148
+ if (!stops.length) return undefined;
1149
+ const allTransparent = stops.every(
1150
+ (s) => s.color.length === 9 && s.color.endsWith("00")
1151
+ );
1152
+ if (allTransparent) return "transparent";
1153
+ const angDeg = gf["a:lin"]?.["@_ang"]
1154
+ ? (Number(gf["a:lin"]["@_ang"]) / 60000 + 90) % 360
1155
+ : 90;
1156
+ const stopsCss = stops.map((s) => `${s.color} ${s.pos.toFixed(2)}%`).join(", ");
1157
+ return `linear-gradient(${angDeg}deg, ${stopsCss})`;
1158
+ }
1159
+ return undefined;
1160
+ }
1161
+
1162
+ function extractBackgroundColor(bg: any, theme: ThemeColors): string | undefined {
1163
+ return (
1164
+ resolveColor(bg?.["p:bgPr"]?.["a:solidFill"], theme) ??
1165
+ (bg?.["p:bgPr"]?.["a:noFill"] !== undefined ? "transparent" : undefined)
1166
+ );
1167
+ }
1168
+
1169
+ function readBodyVAlign(bodyPr: any): "top" | "middle" | "bottom" | undefined {
1170
+ const anchor = bodyPr?.["@_anchor"];
1171
+ if (anchor === "ctr") return "middle";
1172
+ if (anchor === "b") return "bottom";
1173
+ if (anchor === "t") return "top";
1174
+ return undefined;
1175
+ }
1176
+
1177
+ function readAlign(pPr: any): "left" | "center" | "right" | undefined {
1178
+ const a = pPr?.["@_algn"];
1179
+ if (a === "ctr") return "center";
1180
+ if (a === "r") return "right";
1181
+ if (a === "l" || a === "just") return "left";
1182
+ return undefined;
1183
+ }
1184
+
1185
+ interface RunInfo {
1186
+ text: string;
1187
+ fontFamily?: string;
1188
+ fontSize?: number;
1189
+ bold?: boolean;
1190
+ italic?: boolean;
1191
+ underline?: boolean;
1192
+ strike?: boolean;
1193
+ color?: string;
1194
+ letterSpacing?: number;
1195
+ }
1196
+
1197
+ function extractRuns(
1198
+ txBody: any,
1199
+ theme: ThemeColors,
1200
+ fallbackRPr?: any,
1201
+ fallbackPPr?: any
1202
+ ): {
1203
+ runs: RunInfo[];
1204
+ plain: string;
1205
+ align?: "left" | "center" | "right";
1206
+ lineHeightPct?: number;
1207
+ } {
1208
+ const runs: RunInfo[] = [];
1209
+ let align: "left" | "center" | "right" | undefined;
1210
+ let lineHeightPct: number | undefined;
1211
+ const paragraphs = asArray(txBody?.["a:p"]);
1212
+ const pieces: string[] = [];
1213
+
1214
+ for (let pi = 0; pi < paragraphs.length; pi++) {
1215
+ const p = paragraphs[pi];
1216
+ const pPr = p?.["a:pPr"];
1217
+ if (!align) {
1218
+ align = readAlign(pPr) ?? readAlign(fallbackPPr);
1219
+ }
1220
+ if (lineHeightPct === undefined) {
1221
+ const lnPct =
1222
+ pPr?.["a:lnSpc"]?.["a:spcPct"]?.["@_val"] ??
1223
+ fallbackPPr?.["a:lnSpc"]?.["a:spcPct"]?.["@_val"];
1224
+ if (lnPct) lineHeightPct = Number(lnPct) / 100000;
1225
+ }
1226
+ const rs = asArray(p?.["a:r"]);
1227
+ const paragraphText: string[] = [];
1228
+ for (const r of rs) {
1229
+ const t = r?.["a:t"];
1230
+ const rPr = r?.["a:rPr"] ?? {};
1231
+ const text = typeof t === "string" ? t : t?.["#text"] ?? "";
1232
+ paragraphText.push(text);
1233
+ const spcRaw = rPr?.["@_spc"] ?? fallbackRPr?.["@_spc"];
1234
+ const letterSpacing =
1235
+ spcRaw !== undefined && spcRaw !== ""
1236
+ ? pointsToPx(Number(spcRaw) / 100)
1237
+ : undefined;
1238
+ const fontSize =
1239
+ rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]
1240
+ ? pointsToPx(Number(rPr?.["@_sz"] ?? fallbackRPr?.["@_sz"]) / 100)
1241
+ : undefined;
1242
+ const fontFamily =
1243
+ rPr?.["a:latin"]?.["@_typeface"] ??
1244
+ fallbackRPr?.["a:latin"]?.["@_typeface"];
1245
+ const color =
1246
+ resolveColor(rPr?.["a:solidFill"], theme) ??
1247
+ resolveColor(fallbackRPr?.["a:solidFill"], theme);
1248
+ const boldVal = rPr?.["@_b"] ?? fallbackRPr?.["@_b"];
1249
+ const italicVal = rPr?.["@_i"] ?? fallbackRPr?.["@_i"];
1250
+ const underlineVal = rPr?.["@_u"] ?? fallbackRPr?.["@_u"];
1251
+ const strikeVal = rPr?.["@_strike"] ?? fallbackRPr?.["@_strike"];
1252
+ runs.push({
1253
+ text,
1254
+ fontFamily,
1255
+ fontSize,
1256
+ bold: boldVal === "1" || boldVal === 1,
1257
+ italic: italicVal === "1" || italicVal === 1,
1258
+ underline: underlineVal && underlineVal !== "none",
1259
+ strike: strikeVal === "sngStrike",
1260
+ color,
1261
+ letterSpacing,
1262
+ });
1263
+ }
1264
+ pieces.push(paragraphText.join(""));
1265
+ }
1266
+ return {
1267
+ runs,
1268
+ plain: pieces.join("\n"),
1269
+ align,
1270
+ lineHeightPct,
1271
+ };
1272
+ }
1273
+
1274
+ function hasAnyText(txBody: any): boolean {
1275
+ const ps = asArray(txBody?.["a:p"]);
1276
+ for (const p of ps) {
1277
+ const rs = asArray(p?.["a:r"]);
1278
+ for (const r of rs) {
1279
+ const t = r?.["a:t"];
1280
+ const text = typeof t === "string" ? t : t?.["#text"] ?? "";
1281
+ if (text && String(text).length > 0) return true;
1282
+ }
1283
+ }
1284
+ return false;
1285
+ }
1286
+
1287
+ function mergeFirst<T>(...candidates: (T | undefined)[]): T | undefined {
1288
+ for (const c of candidates) {
1289
+ if (c !== undefined && c !== null) return c;
1290
+ }
1291
+ return undefined;
1292
+ }
1293
+
1294
+ /**
1295
+ * Map an OOXML preset shape name to the closest Slidewise ShapeKind. Returns
1296
+ * null only for shapes we genuinely cannot represent at all (the caller then
1297
+ * falls back to a colored rect to preserve visibility).
1298
+ */
1299
+ function mapPrstToKind(prst?: string): ShapeKind | null {
1300
+ if (!prst) return null;
1301
+ switch (prst) {
1302
+ // Direct mappings.
1303
+ case "rect":
1304
+ case "snip1Rect":
1305
+ case "snip2SameRect":
1306
+ case "snip2DiagRect":
1307
+ case "snipRoundRect":
1308
+ case "round1Rect":
1309
+ case "round2DiagRect":
1310
+ case "round2SameRect":
1311
+ return "rect";
1312
+ case "roundRect":
1313
+ return "rounded";
1314
+ case "ellipse":
1315
+ case "circle":
1316
+ return "circle";
1317
+ case "triangle":
1318
+ case "rtTriangle":
1319
+ return "triangle";
1320
+ case "diamond":
1321
+ return "diamond";
1322
+ case "star4":
1323
+ case "star5":
1324
+ case "star6":
1325
+ case "star7":
1326
+ case "star8":
1327
+ case "star10":
1328
+ case "star12":
1329
+ case "star16":
1330
+ case "star24":
1331
+ case "star32":
1332
+ return "star";
1333
+ // Loose mappings — preserve visibility with the closest available kind.
1334
+ case "parallelogram":
1335
+ case "trapezoid":
1336
+ case "hexagon":
1337
+ case "pentagon":
1338
+ case "octagon":
1339
+ case "heptagon":
1340
+ case "decagon":
1341
+ case "dodecagon":
1342
+ case "plus":
1343
+ case "cube":
1344
+ case "can":
1345
+ case "leftArrow":
1346
+ case "rightArrow":
1347
+ case "upArrow":
1348
+ case "downArrow":
1349
+ case "leftRightArrow":
1350
+ case "upDownArrow":
1351
+ case "bentArrow":
1352
+ case "uturnArrow":
1353
+ case "callout1":
1354
+ case "callout2":
1355
+ case "callout3":
1356
+ case "wedgeRectCallout":
1357
+ case "wedgeRoundRectCallout":
1358
+ case "flowChartProcess":
1359
+ case "flowChartDecision":
1360
+ case "flowChartTerminator":
1361
+ case "flowChartConnector":
1362
+ return "rect";
1363
+ default:
1364
+ return null;
1365
+ }
1366
+ }
1367
+
1368
+ // ---------------------------------------------------------------------------
1369
+ // geometry
1370
+ // ---------------------------------------------------------------------------
1371
+
1372
+ function readGeometry(
1373
+ xfrm: any,
1374
+ fit: Fit,
1375
+ outer: GroupTransform
1376
+ ): {
1377
+ x: number;
1378
+ y: number;
1379
+ w: number;
1380
+ h: number;
1381
+ rotation: number;
1382
+ } | null {
1383
+ if (!xfrm) return null;
1384
+ const off = xfrm["a:off"];
1385
+ const ext = xfrm["a:ext"];
1386
+ if (!off || !ext) return null;
1387
+ const rawX = emuToPx(Number(off["@_x"] ?? 0));
1388
+ const rawY = emuToPx(Number(off["@_y"] ?? 0));
1389
+ const rawW = emuToPx(Number(ext["@_cx"] ?? 0));
1390
+ const rawH = emuToPx(Number(ext["@_cy"] ?? 0));
1391
+ const rot = xfrm["@_rot"] ? Number(xfrm["@_rot"]) / 60000 : 0;
1392
+ return applyFit(
1393
+ { rawX, rawY, rawW, rawH, rotation: rot },
1394
+ fit,
1395
+ outer
1396
+ );
1397
+ }
1398
+
1399
+ function applyFit(
1400
+ raw: { rawX: number; rawY: number; rawW: number; rawH: number; rotation: number },
1401
+ fit: Fit,
1402
+ outer: GroupTransform
1403
+ ): { x: number; y: number; w: number; h: number; rotation: number } {
1404
+ // Apply the group's local linear transform to map child raw coords to the
1405
+ // raw slide coordinate system, then apply the slide-to-canvas fit.
1406
+ const slideRawX = outer.a * raw.rawX + outer.c;
1407
+ const slideRawY = outer.b * raw.rawY + outer.d;
1408
+ const slideRawW = raw.rawW * outer.a;
1409
+ const slideRawH = raw.rawH * outer.b;
1410
+ return {
1411
+ x: Math.round(slideRawX * fit.scale + fit.offsetX),
1412
+ y: Math.round(slideRawY * fit.scale + fit.offsetY),
1413
+ w: Math.max(1, Math.round(slideRawW * fit.scale)),
1414
+ h: Math.max(1, Math.round(slideRawH * fit.scale)),
1415
+ rotation: Math.round(raw.rotation),
1416
+ };
1417
+ }
1418
+
1419
+ function computeFit(presentationXml: any): Fit {
1420
+ const sldSz = presentationXml?.["p:presentation"]?.["p:sldSz"];
1421
+ const cxEmu = Number(sldSz?.["@_cx"]) || 12192000;
1422
+ const cyEmu = Number(sldSz?.["@_cy"]) || 6858000;
1423
+ const sourceW = emuToPx(cxEmu);
1424
+ const sourceH = emuToPx(cyEmu);
1425
+ const scale = Math.min(SLIDE_W / sourceW, SLIDE_H / sourceH);
1426
+ const offsetX = Math.round((SLIDE_W - sourceW * scale) / 2);
1427
+ const offsetY = Math.round((SLIDE_H - sourceH * scale) / 2);
1428
+ return { scale, offsetX, offsetY };
1429
+ }
1430
+
1431
+ // ---------------------------------------------------------------------------
1432
+ // zip + xml helpers
1433
+ // ---------------------------------------------------------------------------
1434
+
1435
+ async function readXml(zip: JSZip, path: string): Promise<any | null> {
1436
+ const file = zip.file(path);
1437
+ if (!file) return null;
1438
+ const text = await file.async("string");
1439
+ return xmlParser.parse(text);
1440
+ }
1441
+
1442
+ async function readRels(zip: JSZip, path: string): Promise<Rels> {
1443
+ const byId = new Map<string, { target: string; type: string }>();
1444
+ const xml = await readXml(zip, path);
1445
+ if (!xml) return { byId };
1446
+ const rels = asArray(xml?.["Relationships"]?.["Relationship"]);
1447
+ for (const r of rels) {
1448
+ const id = r?.["@_Id"];
1449
+ const target = r?.["@_Target"];
1450
+ const type = r?.["@_Type"] ?? "";
1451
+ if (id && target) byId.set(id, { target, type });
1452
+ }
1453
+ return { byId };
1454
+ }
1455
+
1456
+ function firstByType(rels: Rels, suffix: string): string | undefined {
1457
+ for (const { target, type } of rels.byId.values()) {
1458
+ if (type.endsWith(`/${suffix}`) || type.endsWith(suffix)) return target;
1459
+ }
1460
+ return undefined;
1461
+ }
1462
+
1463
+ function relsPathFor(xmlPath: string): string {
1464
+ return xmlPath.replace(/([^/]+)\.xml$/, "_rels/$1.xml.rels");
1465
+ }
1466
+
1467
+ async function readTitle(zip: JSZip): Promise<string> {
1468
+ const file = zip.file("docProps/core.xml");
1469
+ if (!file) return "Untitled";
1470
+ const text = await file.async("string");
1471
+ const m = text.match(/<dc:title[^>]*>([^<]*)<\/dc:title>/);
1472
+ return (m?.[1] || "Untitled").trim() || "Untitled";
1473
+ }
1474
+
1475
+ function asArray<T = any>(value: T | T[] | undefined | null): T[] {
1476
+ if (value == null) return [];
1477
+ return Array.isArray(value) ? value : [value];
1478
+ }
1479
+
1480
+ function dirOf(path: string): string {
1481
+ const i = path.lastIndexOf("/");
1482
+ return i >= 0 ? path.slice(0, i) : "";
1483
+ }
1484
+
1485
+ function normalisePath(target: string, base: string): string {
1486
+ if (target.startsWith("/")) return target.slice(1);
1487
+ if (target.startsWith("../")) {
1488
+ const segments = base.split("/").filter(Boolean);
1489
+ let t = target;
1490
+ while (t.startsWith("../")) {
1491
+ segments.pop();
1492
+ t = t.slice(3);
1493
+ }
1494
+ return [...segments, t].filter(Boolean).join("/");
1495
+ }
1496
+ return base ? `${base}/${target}` : target;
1497
+ }
1498
+
1499
+ function mimeForExt(ext: string): string {
1500
+ switch (ext) {
1501
+ case "png":
1502
+ return "image/png";
1503
+ case "jpg":
1504
+ case "jpeg":
1505
+ return "image/jpeg";
1506
+ case "gif":
1507
+ return "image/gif";
1508
+ case "svg":
1509
+ return "image/svg+xml";
1510
+ case "webp":
1511
+ return "image/webp";
1512
+ default:
1513
+ return "application/octet-stream";
1514
+ }
1515
+ }