docgen-utils 1.0.14 → 1.0.15

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.
@@ -50,28 +50,74 @@ function hexToRgba(hex, alpha100k) {
50
50
  const a = Math.round((alpha100k / 100000) * 100) / 100;
51
51
  return `rgba(${r},${g},${b},${a})`;
52
52
  }
53
+ /**
54
+ * Apply tint modifier: mix the color toward white.
55
+ * tintVal is 0-100000 (e.g. 65000 = 65% tint means 65% of original + 35% white)
56
+ */
57
+ function applyTint(hex, tintVal) {
58
+ const factor = tintVal / 100000;
59
+ const r = parseInt(hex.slice(1, 3), 16);
60
+ const g = parseInt(hex.slice(3, 5), 16);
61
+ const b = parseInt(hex.slice(5, 7), 16);
62
+ const nr = Math.round(r + (255 - r) * (1 - factor));
63
+ const ng = Math.round(g + (255 - g) * (1 - factor));
64
+ const nb = Math.round(b + (255 - b) * (1 - factor));
65
+ return `#${nr.toString(16).padStart(2, "0")}${ng.toString(16).padStart(2, "0")}${nb.toString(16).padStart(2, "0")}`;
66
+ }
67
+ /**
68
+ * Apply shade modifier: mix the color toward black.
69
+ * shadeVal is 0-100000 (e.g. 50000 = 50% shade means darken by 50%)
70
+ */
71
+ function applyShade(hex, shadeVal) {
72
+ const factor = shadeVal / 100000;
73
+ const r = Math.round(parseInt(hex.slice(1, 3), 16) * factor);
74
+ const g = Math.round(parseInt(hex.slice(3, 5), 16) * factor);
75
+ const b = Math.round(parseInt(hex.slice(5, 7), 16) * factor);
76
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
77
+ }
78
+ /**
79
+ * Apply color modifiers (tint, shade, alpha) to a color element.
80
+ * Returns the modified hex color and optional alpha value.
81
+ */
82
+ function applyColorModifiers(colorEl, hex) {
83
+ let color = hex;
84
+ let alphaVal;
85
+ const tintEl = findChild(colorEl, "tint");
86
+ if (tintEl) {
87
+ const val = parseInt(tintEl.getAttribute("val") ?? "100000", 10);
88
+ color = applyTint(color, val);
89
+ }
90
+ const shadeEl = findChild(colorEl, "shade");
91
+ if (shadeEl) {
92
+ const val = parseInt(shadeEl.getAttribute("val") ?? "100000", 10);
93
+ color = applyShade(color, val);
94
+ }
95
+ const alphaEl = findChild(colorEl, "alpha");
96
+ if (alphaEl) {
97
+ alphaVal = parseInt(alphaEl.getAttribute("val") ?? "100000", 10);
98
+ }
99
+ return { color, alpha: alphaVal };
100
+ }
53
101
  function resolveColor(parent, themeColors) {
54
102
  const srgbClr = findChild(parent, "srgbClr");
55
103
  if (srgbClr) {
56
104
  const hex = "#" + (srgbClr.getAttribute("val") ?? "000000");
57
- const alphaEl = findChild(srgbClr, "alpha");
58
- if (alphaEl) {
59
- const alphaVal = parseInt(alphaEl.getAttribute("val") ?? "100000", 10);
60
- return hexToRgba(hex, alphaVal);
105
+ const { color, alpha } = applyColorModifiers(srgbClr, hex);
106
+ if (alpha !== undefined && alpha < 100000) {
107
+ return hexToRgba(color, alpha);
61
108
  }
62
- return hex;
109
+ return color;
63
110
  }
64
111
  const schemeClr = findChild(parent, "schemeClr");
65
112
  if (schemeClr) {
66
113
  const val = schemeClr.getAttribute("val");
67
114
  if (val && themeColors.has(val)) {
68
115
  const hex = themeColors.get(val);
69
- const alphaEl = findChild(schemeClr, "alpha");
70
- if (alphaEl) {
71
- const alphaVal = parseInt(alphaEl.getAttribute("val") ?? "100000", 10);
72
- return hexToRgba(hex, alphaVal);
116
+ const { color, alpha } = applyColorModifiers(schemeClr, hex);
117
+ if (alpha !== undefined && alpha < 100000) {
118
+ return hexToRgba(color, alpha);
73
119
  }
74
- return hex;
120
+ return color;
75
121
  }
76
122
  }
77
123
  return undefined;
@@ -127,6 +173,198 @@ function extractFill(spPr, themeColors) {
127
173
  return resolveColor(solidFill, themeColors);
128
174
  return extractGradientFill(spPr, themeColors);
129
175
  }
176
+ /**
177
+ * Resolve a bgRef element to a background color/gradient.
178
+ * bgRef idx=1001-1003 references bgFillStyleLst entries (idx - 1000 = 1-based position).
179
+ * The bgRef's child color element provides the "placeholder color" (phClr) for the fill style.
180
+ */
181
+ function resolveBgRef(bgRef, bgFillStyles, themeColors) {
182
+ const idx = parseInt(bgRef.getAttribute("idx") ?? "0", 10);
183
+ if (idx < 1001 || bgFillStyles.length === 0)
184
+ return undefined;
185
+ const styleIdx = idx - 1001; // 0-based index into bgFillStyleLst
186
+ if (styleIdx >= bgFillStyles.length)
187
+ return undefined;
188
+ const fillStyle = bgFillStyles[styleIdx];
189
+ // Resolve the placeholder color (phClr) from bgRef's child element
190
+ const phColor = resolveColor(bgRef, themeColors);
191
+ // Check what type of fill the style defines
192
+ if (fillStyle.localName === "solidFill") {
193
+ // If the fill uses phClr, substitute with bgRef's color
194
+ const phClr = findChild(fillStyle, "schemeClr");
195
+ if (phClr?.getAttribute("val") === "phClr" && phColor) {
196
+ return phColor;
197
+ }
198
+ return resolveColor(fillStyle, themeColors) ?? phColor;
199
+ }
200
+ if (fillStyle.localName === "gradFill") {
201
+ // Extract gradient, substituting phClr with bgRef's color
202
+ return extractGradientFill(
203
+ // Wrap in a parent element for extractGradientFill
204
+ { children: [fillStyle] }, themeColors);
205
+ }
206
+ // For blipFill or other complex fills, return the placeholder color as fallback
207
+ return phColor;
208
+ }
209
+ /**
210
+ * Extract background fill from a bg element, handling both bgPr and bgRef.
211
+ */
212
+ function extractBgFill(bgEl, bgFillStyles, themeColors) {
213
+ const bgPr = findChild(bgEl, "bgPr");
214
+ if (bgPr) {
215
+ return extractFill(bgPr, themeColors);
216
+ }
217
+ const bgRef = findChild(bgEl, "bgRef");
218
+ if (bgRef) {
219
+ return resolveBgRef(bgRef, bgFillStyles, themeColors);
220
+ }
221
+ return undefined;
222
+ }
223
+ /**
224
+ * Extract a CSS clip-path polygon from a custGeom element.
225
+ * Only supports simple paths (moveTo + lineTo, no curves).
226
+ * Returns undefined if the geometry is too complex.
227
+ */
228
+ function extractCustGeomClipPath(custGeom) {
229
+ const pathLst = findChild(custGeom, "pathLst");
230
+ if (!pathLst)
231
+ return undefined;
232
+ const pathEl = findChild(pathLst, "path");
233
+ if (!pathEl)
234
+ return undefined;
235
+ const pathW = parseInt(pathEl.getAttribute("w") ?? "0", 10);
236
+ const pathH = parseInt(pathEl.getAttribute("h") ?? "0", 10);
237
+ if (pathW <= 0 || pathH <= 0)
238
+ return undefined;
239
+ const points = [];
240
+ for (let i = 0; i < pathEl.children.length; i++) {
241
+ const cmd = pathEl.children[i];
242
+ const localName = cmd.localName;
243
+ if (localName === "moveTo" || localName === "lnTo") {
244
+ const pt = findChild(cmd, "pt");
245
+ if (pt) {
246
+ const x = parseInt(pt.getAttribute("x") ?? "0", 10);
247
+ const y = parseInt(pt.getAttribute("y") ?? "0", 10);
248
+ points.push({ x, y });
249
+ }
250
+ }
251
+ else if (localName === "close") {
252
+ // close path — don't need to add anything
253
+ }
254
+ else {
255
+ // Unsupported command (cubicBezTo, quadBezTo, arcTo, etc.)
256
+ // Can't represent as a simple polygon
257
+ return undefined;
258
+ }
259
+ }
260
+ if (points.length < 3)
261
+ return undefined;
262
+ // Convert points to percentage-based polygon
263
+ const polygonPoints = points.map(p => {
264
+ const xPct = Math.round((p.x / pathW) * 10000) / 100;
265
+ const yPct = Math.round((p.y / pathH) * 10000) / 100;
266
+ return `${xPct}% ${yPct}%`;
267
+ }).join(", ");
268
+ return `polygon(${polygonPoints})`;
269
+ }
270
+ /**
271
+ * Parse non-placeholder shapes from a spTree element (layout or master).
272
+ * Returns SlideElement[] for decorative shapes that should appear behind slide content.
273
+ */
274
+ function parseDecorativeShapes(spTree, scale, themeColors, imageMap, themeFonts) {
275
+ const elements = [];
276
+ for (let i = 0; i < spTree.children.length; i++) {
277
+ const child = spTree.children[i];
278
+ if (child.localName === "sp") {
279
+ // Skip placeholder shapes — they are content containers, not decorative
280
+ const nvSpPr = findChild(child, "nvSpPr");
281
+ if (nvSpPr) {
282
+ const phEl = findChild(findChild(nvSpPr, "nvPr") ?? nvSpPr, "ph");
283
+ if (phEl)
284
+ continue;
285
+ }
286
+ // For custom geometry shapes, try to extract a simple polygon clip-path.
287
+ // Skip only if the geometry is too complex to represent as a CSS polygon.
288
+ const spPr = findChild(child, "spPr");
289
+ if (spPr) {
290
+ const custGeom = findChild(spPr, "custGeom");
291
+ if (custGeom) {
292
+ const clipPath = extractCustGeomClipPath(custGeom);
293
+ if (!clipPath)
294
+ continue; // Too complex — skip
295
+ // Parse the shape and attach the clip-path
296
+ const shape = parseShape(child, scale, themeColors, themeFonts);
297
+ if (shape) {
298
+ shape.clipPath = clipPath;
299
+ elements.push({ kind: "shape", data: shape });
300
+ }
301
+ continue;
302
+ }
303
+ }
304
+ const shape = parseShape(child, scale, themeColors, themeFonts);
305
+ if (shape)
306
+ elements.push({ kind: "shape", data: shape });
307
+ }
308
+ else if (child.localName === "pic") {
309
+ const img = parsePicture(child, scale, imageMap);
310
+ if (img)
311
+ elements.push({ kind: "image", data: img });
312
+ }
313
+ else if (child.localName === "grpSp") {
314
+ // Recursively parse group shapes for their decorative children
315
+ const grpElements = parseGroupShape(child, scale, themeColors, imageMap, themeFonts);
316
+ elements.push(...grpElements);
317
+ }
318
+ }
319
+ return elements;
320
+ }
321
+ /**
322
+ * Parse shapes inside a group shape element.
323
+ * Group shapes have their own coordinate space defined by grpSpPr.
324
+ */
325
+ function parseGroupShape(grpSp, scale, themeColors, imageMap, themeFonts) {
326
+ const elements = [];
327
+ for (let i = 0; i < grpSp.children.length; i++) {
328
+ const child = grpSp.children[i];
329
+ if (child.localName === "sp") {
330
+ const nvSpPr = findChild(child, "nvSpPr");
331
+ if (nvSpPr) {
332
+ const phEl = findChild(findChild(nvSpPr, "nvPr") ?? nvSpPr, "ph");
333
+ if (phEl)
334
+ continue;
335
+ }
336
+ // Handle freeform shapes in groups with clip-path extraction
337
+ const spPr = findChild(child, "spPr");
338
+ if (spPr) {
339
+ const custGeom = findChild(spPr, "custGeom");
340
+ if (custGeom) {
341
+ const clipPath = extractCustGeomClipPath(custGeom);
342
+ if (!clipPath)
343
+ continue;
344
+ const shape = parseShape(child, scale, themeColors, themeFonts);
345
+ if (shape) {
346
+ shape.clipPath = clipPath;
347
+ elements.push({ kind: "shape", data: shape });
348
+ }
349
+ continue;
350
+ }
351
+ }
352
+ const shape = parseShape(child, scale, themeColors, themeFonts);
353
+ if (shape)
354
+ elements.push({ kind: "shape", data: shape });
355
+ }
356
+ else if (child.localName === "pic") {
357
+ const img = parsePicture(child, scale, imageMap);
358
+ if (img)
359
+ elements.push({ kind: "image", data: img });
360
+ }
361
+ else if (child.localName === "grpSp") {
362
+ const nested = parseGroupShape(child, scale, themeColors, imageMap, themeFonts);
363
+ elements.push(...nested);
364
+ }
365
+ }
366
+ return elements;
367
+ }
130
368
  function extractRunProps(rPr, scale, themeColors, themeFonts) {
131
369
  if (!rPr)
132
370
  return {};
@@ -140,6 +378,9 @@ function extractRunProps(rPr, scale, themeColors, themeFonts) {
140
378
  const i = rPr.getAttribute("i");
141
379
  if (i === "1" || i === "true")
142
380
  result.italic = true;
381
+ const u = rPr.getAttribute("u");
382
+ if (u && u !== "none")
383
+ result.underline = true;
143
384
  const solidFill = findChild(rPr, "solidFill");
144
385
  if (solidFill) {
145
386
  const color = resolveColor(solidFill, themeColors);
@@ -268,14 +509,36 @@ function parseShape(sp, scale, themeColors, themeFonts) {
268
509
  return null;
269
510
  const wEmu = parseInt(ext.getAttribute("cx") ?? "0", 10);
270
511
  const hEmu = parseInt(ext.getAttribute("cy") ?? "0", 10);
512
+ // Resolve fill: direct spPr fill > p:style fillRef fallback
513
+ // Only fall back to p:style if spPr has no explicit <a:noFill/>
514
+ let fill = extractFill(spPr, themeColors);
515
+ if (!fill && !findChild(spPr, "noFill")) {
516
+ const styleEl = findChild(sp, "style");
517
+ if (styleEl) {
518
+ const fillRef = findChild(styleEl, "fillRef");
519
+ if (fillRef) {
520
+ const fillIdx = parseInt(fillRef.getAttribute("idx") ?? "0", 10);
521
+ if (fillIdx > 0) {
522
+ fill = resolveColor(fillRef, themeColors) ?? undefined;
523
+ }
524
+ }
525
+ }
526
+ }
271
527
  const shape = {
272
528
  x: Math.round(emuToPx(parseInt(off.getAttribute("x") ?? "0", 10)) * scale),
273
529
  y: Math.round(emuToPx(parseInt(off.getAttribute("y") ?? "0", 10)) * scale),
274
530
  w: Math.round(emuToPx(wEmu) * scale),
275
531
  h: Math.round(emuToPx(hEmu) * scale),
276
- fill: extractFill(spPr, themeColors),
532
+ fill,
277
533
  paragraphs: [],
278
534
  };
535
+ // Extract rotation from xfrm (OOXML uses 60,000ths of a degree)
536
+ const rotAttr = xfrm.getAttribute("rot");
537
+ if (rotAttr) {
538
+ const rotDeg = parseInt(rotAttr, 10) / 60000;
539
+ if (rotDeg !== 0)
540
+ shape.rotation = rotDeg;
541
+ }
279
542
  // Border radius from prstGeom roundRect or ellipse
280
543
  const prstGeom = findChild(spPr, "prstGeom");
281
544
  const prstType = prstGeom?.getAttribute("prst");
@@ -293,6 +556,14 @@ function parseShape(sp, scale, themeColors, themeFonts) {
293
556
  // Ellipse shapes should have border-radius: 50% to render as circles
294
557
  shape.isEllipse = true;
295
558
  }
559
+ else if (prstType === "rtTriangle") {
560
+ // Right triangle: clip to triangular shape
561
+ shape.clipPath = "polygon(0 0, 0 100%, 100% 100%)";
562
+ }
563
+ else if (prstType === "triangle" || prstType === "isoTriangle") {
564
+ // Isosceles triangle: point at top center
565
+ shape.clipPath = "polygon(50% 0, 0 100%, 100% 100%)";
566
+ }
296
567
  // Border from a:ln
297
568
  const ln = findChild(spPr, "ln");
298
569
  if (ln) {
@@ -404,6 +675,11 @@ function parseShape(sp, scale, themeColors, themeFonts) {
404
675
  if (buChar) {
405
676
  para.bulletChar = buChar.getAttribute("char") ?? undefined;
406
677
  }
678
+ // Bullet font (e.g. Wingdings, Symbol)
679
+ const buFont = findChild(pPr, "buFont");
680
+ if (buFont) {
681
+ para.bulletFont = buFont.getAttribute("typeface") ?? undefined;
682
+ }
407
683
  // Bullet color
408
684
  const buClr = findChild(pPr, "buClr");
409
685
  if (buClr) {
@@ -428,6 +704,7 @@ function parseShape(sp, scale, themeColors, themeFonts) {
428
704
  text,
429
705
  bold: props.bold ?? defaults?.bold,
430
706
  italic: props.italic ?? defaults?.italic,
707
+ underline: props.underline ?? defaults?.underline,
431
708
  fontSize: props.fontSize ?? defaults?.fontSize,
432
709
  color: props.color ?? defaults?.color,
433
710
  fontFamily: props.fontFamily ?? defaults?.fontFamily,
@@ -508,16 +785,21 @@ function parsePicture(pic, scale, imageMap) {
508
785
  const nvPicPr = findChild(pic, "nvPicPr");
509
786
  const cNvPr = nvPicPr ? findChild(nvPicPr, "cNvPr") : null;
510
787
  const alt = cNvPr?.getAttribute("descr") ?? undefined;
511
- // Check for srcRect cropping - indicates the image should use object-fit:cover
788
+ // Check for srcRect cropping
512
789
  const srcRect = findChild(blipFill, "srcRect");
513
790
  let hasCrop = false;
791
+ let cropLeft = 0;
792
+ let cropTop = 0;
793
+ let cropRight = 0;
794
+ let cropBottom = 0;
514
795
  if (srcRect) {
515
- const l = parseInt(srcRect.getAttribute("l") ?? "0", 10);
516
- const r = parseInt(srcRect.getAttribute("r") ?? "0", 10);
517
- const t = parseInt(srcRect.getAttribute("t") ?? "0", 10);
518
- const b = parseInt(srcRect.getAttribute("b") ?? "0", 10);
519
- // If any cropping is applied, the original image used object-fit:cover
520
- hasCrop = l > 0 || r > 0 || t > 0 || b > 0;
796
+ // srcRect values are in 1/1000th of a percent (e.g., 49710 = 49.71%)
797
+ cropLeft = parseInt(srcRect.getAttribute("l") ?? "0", 10) / 1000;
798
+ cropRight = parseInt(srcRect.getAttribute("r") ?? "0", 10) / 1000;
799
+ cropTop = parseInt(srcRect.getAttribute("t") ?? "0", 10) / 1000;
800
+ cropBottom = parseInt(srcRect.getAttribute("b") ?? "0", 10) / 1000;
801
+ // If any cropping is applied, record the crop values
802
+ hasCrop = cropLeft > 0 || cropRight > 0 || cropTop > 0 || cropBottom > 0;
521
803
  }
522
804
  // Extract border-radius from roundRect preset geometry
523
805
  let borderRadius;
@@ -536,6 +818,14 @@ function parsePicture(pic, scale, imageMap) {
536
818
  const radiusEmu = (minDim * Math.min(adjVal, 50000)) / 100000;
537
819
  borderRadius = Math.round(emuToPx(radiusEmu) * scale);
538
820
  }
821
+ // Extract rotation from xfrm
822
+ let rotation;
823
+ const rotAttr = xfrm.getAttribute("rot");
824
+ if (rotAttr) {
825
+ const rotDeg = parseInt(rotAttr, 10) / 60000;
826
+ if (rotDeg !== 0)
827
+ rotation = rotDeg;
828
+ }
539
829
  return {
540
830
  x: Math.round(emuToPx(parseInt(off.getAttribute("x") ?? "0", 10)) * scale),
541
831
  y: Math.round(emuToPx(parseInt(off.getAttribute("y") ?? "0", 10)) * scale),
@@ -545,6 +835,11 @@ function parsePicture(pic, scale, imageMap) {
545
835
  alt,
546
836
  borderRadius,
547
837
  hasCrop,
838
+ cropLeft: hasCrop ? cropLeft : undefined,
839
+ cropTop: hasCrop ? cropTop : undefined,
840
+ cropRight: hasCrop ? cropRight : undefined,
841
+ cropBottom: hasCrop ? cropBottom : undefined,
842
+ rotation,
548
843
  };
549
844
  }
550
845
  // ============================================================================
@@ -708,6 +1003,11 @@ function parseParagraphsFromTxBody(txBody, scale, themeColors, themeFonts) {
708
1003
  if (buChar) {
709
1004
  para.bulletChar = buChar.getAttribute("char") ?? undefined;
710
1005
  }
1006
+ // Bullet font (e.g. Wingdings, Symbol)
1007
+ const buFont = findChild(pPr, "buFont");
1008
+ if (buFont) {
1009
+ para.bulletFont = buFont.getAttribute("typeface") ?? undefined;
1010
+ }
711
1011
  // Bullet color
712
1012
  const buClr = findChild(pPr, "buClr");
713
1013
  if (buClr) {
@@ -729,6 +1029,7 @@ function parseParagraphsFromTxBody(txBody, scale, themeColors, themeFonts) {
729
1029
  text,
730
1030
  bold: props.bold ?? defaults?.bold,
731
1031
  italic: props.italic ?? defaults?.italic,
1032
+ underline: props.underline ?? defaults?.underline,
732
1033
  fontSize: props.fontSize ?? defaults?.fontSize,
733
1034
  color: props.color ?? defaults?.color,
734
1035
  fontFamily: props.fontFamily ?? defaults?.fontFamily,
@@ -921,21 +1222,666 @@ function parseTable(graphicFrame, scale, themeColors, themeFonts, tableStyleMap)
921
1222
  return table;
922
1223
  }
923
1224
  // ============================================================================
1225
+ // Chart Parsing
1226
+ // ============================================================================
1227
+ /**
1228
+ * Resolve a color from a chart fill element (handles srgbClr and schemeClr).
1229
+ * Chart XML uses DrawingML (a:) namespace, same as shapes.
1230
+ */
1231
+ function resolveChartColor(parent, themeColors) {
1232
+ const solidFill = findChild(parent, "solidFill");
1233
+ if (!solidFill)
1234
+ return undefined;
1235
+ return resolveColor(solidFill, themeColors);
1236
+ }
1237
+ /**
1238
+ * Extract text from a rich text element (c:rich) commonly used for chart/axis titles.
1239
+ * Returns the concatenated text and optional font/color/size from the first run.
1240
+ */
1241
+ function parseChartRichText(richEl) {
1242
+ const paragraphs = findChildren(richEl, "p");
1243
+ let text = "";
1244
+ let font;
1245
+ let color;
1246
+ let size;
1247
+ for (const p of paragraphs) {
1248
+ const runs = findChildren(p, "r");
1249
+ for (const r of runs) {
1250
+ const tEl = findChild(r, "t");
1251
+ if (tEl?.textContent)
1252
+ text += tEl.textContent;
1253
+ // Extract formatting from first run only
1254
+ if (font === undefined) {
1255
+ const rPr = findChild(r, "rPr");
1256
+ if (rPr) {
1257
+ const szAttr = rPr.getAttribute("sz");
1258
+ if (szAttr)
1259
+ size = hptToPx(parseInt(szAttr, 10));
1260
+ const latin = findChild(rPr, "latin");
1261
+ if (latin)
1262
+ font = latin.getAttribute("typeface") ?? undefined;
1263
+ const fill = findChild(rPr, "solidFill");
1264
+ if (fill) {
1265
+ const srgb = findChild(fill, "srgbClr");
1266
+ if (srgb)
1267
+ color = "#" + (srgb.getAttribute("val") ?? "000000");
1268
+ }
1269
+ }
1270
+ }
1271
+ }
1272
+ }
1273
+ return { text, font, color, size };
1274
+ }
1275
+ /**
1276
+ * Extract axis title and label formatting from a chart axis element (c:catAx or c:valAx).
1277
+ */
1278
+ function parseChartAxis(axEl, themeColors) {
1279
+ const axis = {
1280
+ position: "b",
1281
+ };
1282
+ // Axis position
1283
+ const axPos = findChild(axEl, "axPos");
1284
+ if (axPos) {
1285
+ const val = axPos.getAttribute("val");
1286
+ if (val === "b" || val === "l" || val === "r" || val === "t") {
1287
+ axis.position = val;
1288
+ }
1289
+ }
1290
+ // Axis title
1291
+ const titleEl = findChild(axEl, "title");
1292
+ if (titleEl) {
1293
+ const tx = findChild(titleEl, "tx");
1294
+ if (tx) {
1295
+ const rich = findChild(tx, "rich");
1296
+ if (rich) {
1297
+ const parsed = parseChartRichText(rich);
1298
+ axis.title = parsed.text;
1299
+ }
1300
+ }
1301
+ }
1302
+ // Number format
1303
+ const numFmt = findChild(axEl, "numFmt");
1304
+ if (numFmt) {
1305
+ const fmt = numFmt.getAttribute("formatCode");
1306
+ if (fmt)
1307
+ axis.numFormat = fmt;
1308
+ }
1309
+ // Label formatting from txPr
1310
+ const txPr = findChild(axEl, "txPr");
1311
+ if (txPr) {
1312
+ const pEls = findChildren(txPr, "p");
1313
+ for (const p of pEls) {
1314
+ const pPr = findChild(p, "pPr");
1315
+ if (pPr) {
1316
+ const defRPr = findChild(pPr, "defRPr");
1317
+ if (defRPr) {
1318
+ const szAttr = defRPr.getAttribute("sz");
1319
+ if (szAttr)
1320
+ axis.labelSize = hptToPx(parseInt(szAttr, 10));
1321
+ const latin = findChild(defRPr, "latin");
1322
+ if (latin)
1323
+ axis.labelFont = latin.getAttribute("typeface") ?? undefined;
1324
+ const fill = findChild(defRPr, "solidFill");
1325
+ if (fill) {
1326
+ const srgb = findChild(fill, "srgbClr");
1327
+ if (srgb)
1328
+ axis.labelColor = "#" + (srgb.getAttribute("val") ?? "000000");
1329
+ }
1330
+ }
1331
+ }
1332
+ }
1333
+ }
1334
+ // Gridlines
1335
+ const gridlines = findChild(axEl, "majorGridlines");
1336
+ if (gridlines) {
1337
+ const spPr = findChild(gridlines, "spPr");
1338
+ if (spPr) {
1339
+ const ln = findChild(spPr, "ln");
1340
+ if (ln) {
1341
+ const fill = findChild(ln, "solidFill");
1342
+ if (fill) {
1343
+ const srgb = findChild(fill, "srgbClr");
1344
+ if (srgb) {
1345
+ axis.gridlineColor = "#" + (srgb.getAttribute("val") ?? "CCCCCC");
1346
+ const alphaEl = findChild(srgb, "alpha");
1347
+ if (alphaEl) {
1348
+ axis.gridlineAlpha = parseInt(alphaEl.getAttribute("val") ?? "100000", 10) / 1000;
1349
+ }
1350
+ }
1351
+ }
1352
+ }
1353
+ }
1354
+ }
1355
+ // Min value from scaling
1356
+ const scaling = findChild(axEl, "scaling");
1357
+ if (scaling) {
1358
+ const minEl = findChild(scaling, "min");
1359
+ if (minEl) {
1360
+ const val = minEl.getAttribute("val");
1361
+ if (val)
1362
+ axis.min = parseFloat(val);
1363
+ }
1364
+ }
1365
+ return axis;
1366
+ }
1367
+ /**
1368
+ * Parse a chart XML file and extract chart data for rendering.
1369
+ * Handles bar/column charts (c:barChart).
1370
+ */
1371
+ async function parseChart(graphicFrame, scale, themeColors, zip, slideRelsDoc, parser) {
1372
+ // Extract position from xfrm (same pattern as parseTable)
1373
+ const xfrm = findChild(graphicFrame, "xfrm");
1374
+ if (!xfrm)
1375
+ return null;
1376
+ const off = findChild(xfrm, "off");
1377
+ const ext = findChild(xfrm, "ext");
1378
+ if (!off || !ext)
1379
+ return null;
1380
+ // Navigate to graphicData and check URI
1381
+ const graphic = findChild(graphicFrame, "graphic");
1382
+ if (!graphic)
1383
+ return null;
1384
+ const graphicData = findChild(graphic, "graphicData");
1385
+ if (!graphicData)
1386
+ return null;
1387
+ const uri = graphicData.getAttribute("uri") ?? "";
1388
+ if (!uri.includes("chart"))
1389
+ return null;
1390
+ // Find the c:chart reference element inside graphicData
1391
+ let chartRId;
1392
+ for (let i = 0; i < graphicData.children.length; i++) {
1393
+ const child = graphicData.children[i];
1394
+ if (child.localName === "chart") {
1395
+ // r:id attribute - try both with and without namespace prefix
1396
+ chartRId = child.getAttribute("r:id")
1397
+ ?? child.getAttributeNS("http://schemas.openxmlformats.org/officeDocument/2006/relationships", "id")
1398
+ ?? undefined;
1399
+ break;
1400
+ }
1401
+ }
1402
+ if (!chartRId)
1403
+ return null;
1404
+ // Resolve the rId via slide rels to get chart XML path
1405
+ let chartTarget;
1406
+ const rels = slideRelsDoc.getElementsByTagName("Relationship");
1407
+ for (let ri = 0; ri < rels.length; ri++) {
1408
+ const rel = rels[ri];
1409
+ if (rel.getAttribute("Id") === chartRId) {
1410
+ chartTarget = rel.getAttribute("Target") ?? undefined;
1411
+ break;
1412
+ }
1413
+ }
1414
+ if (!chartTarget)
1415
+ return null;
1416
+ // Resolve relative path (e.g., ../charts/chart1.xml → ppt/charts/chart1.xml)
1417
+ const chartPath = chartTarget.startsWith("../")
1418
+ ? "ppt/" + chartTarget.slice(3)
1419
+ : chartTarget;
1420
+ // Load chart XML from ZIP
1421
+ const chartXml = await zip.file(chartPath)?.async("text");
1422
+ if (!chartXml)
1423
+ return null;
1424
+ const chartDoc = parser.parseFromString(chartXml, "application/xml");
1425
+ const chartSpace = chartDoc.documentElement; // c:chartSpace
1426
+ // Position
1427
+ const x = Math.round(emuToPx(parseInt(off.getAttribute("x") ?? "0", 10)) * scale);
1428
+ const y = Math.round(emuToPx(parseInt(off.getAttribute("y") ?? "0", 10)) * scale);
1429
+ const w = Math.round(emuToPx(parseInt(ext.getAttribute("cx") ?? "0", 10)) * scale);
1430
+ const h = Math.round(emuToPx(parseInt(ext.getAttribute("cy") ?? "0", 10)) * scale);
1431
+ // Navigate to c:chart element
1432
+ const chartEl = findChild(chartSpace, "chart");
1433
+ if (!chartEl)
1434
+ return null;
1435
+ // Parse chart title
1436
+ let title;
1437
+ let titleFont;
1438
+ let titleColor;
1439
+ let titleSize;
1440
+ const titleEl = findChild(chartEl, "title");
1441
+ if (titleEl) {
1442
+ const tx = findChild(titleEl, "tx");
1443
+ if (tx) {
1444
+ const rich = findChild(tx, "rich");
1445
+ if (rich) {
1446
+ const parsed = parseChartRichText(rich);
1447
+ title = parsed.text;
1448
+ titleFont = parsed.font;
1449
+ titleColor = parsed.color;
1450
+ titleSize = parsed.size;
1451
+ }
1452
+ }
1453
+ }
1454
+ // Parse plot area
1455
+ const plotArea = findChild(chartEl, "plotArea");
1456
+ if (!plotArea)
1457
+ return null;
1458
+ // Look for barChart
1459
+ const barChart = findChild(plotArea, "barChart");
1460
+ if (!barChart)
1461
+ return null; // Only bar charts supported for now
1462
+ // Determine chart direction
1463
+ const barDirEl = findChild(barChart, "barDir");
1464
+ const barDir = barDirEl?.getAttribute("val") ?? "col";
1465
+ const chartType = barDir === "bar" ? "bar" : "column";
1466
+ // Gap width
1467
+ const gapWidthEl = findChild(barChart, "gapWidth");
1468
+ const gapWidth = gapWidthEl
1469
+ ? parseInt(gapWidthEl.getAttribute("val") ?? "150", 10)
1470
+ : 150;
1471
+ // Rounded corners
1472
+ const roundedCornersEl = findChild(chartSpace, "roundedCorners");
1473
+ const roundedCorners = roundedCornersEl?.getAttribute("val") === "1";
1474
+ // Parse series
1475
+ const series = [];
1476
+ const serEls = findChildren(barChart, "ser");
1477
+ for (const ser of serEls) {
1478
+ const seriesData = {
1479
+ categories: [],
1480
+ values: [],
1481
+ fillColors: [],
1482
+ };
1483
+ // Series name
1484
+ const txEl = findChild(ser, "tx");
1485
+ if (txEl) {
1486
+ const strRef = findChild(txEl, "strRef");
1487
+ if (strRef) {
1488
+ const strCache = findChild(strRef, "strCache");
1489
+ if (strCache) {
1490
+ const pt = findChild(strCache, "pt");
1491
+ if (pt) {
1492
+ const v = findChild(pt, "v");
1493
+ if (v?.textContent)
1494
+ seriesData.name = v.textContent;
1495
+ }
1496
+ }
1497
+ }
1498
+ }
1499
+ // Series-level color
1500
+ const serSpPr = findChild(ser, "spPr");
1501
+ if (serSpPr) {
1502
+ const color = resolveChartColor(serSpPr, themeColors);
1503
+ if (color)
1504
+ seriesData.seriesColor = color;
1505
+ }
1506
+ // Per-data-point colors from c:dPt
1507
+ const dPtMap = new Map();
1508
+ const dPtEls = findChildren(ser, "dPt");
1509
+ for (const dPt of dPtEls) {
1510
+ const idxEl = findChild(dPt, "idx");
1511
+ const idx = idxEl ? parseInt(idxEl.getAttribute("val") ?? "0", 10) : 0;
1512
+ const spPr = findChild(dPt, "spPr");
1513
+ if (spPr) {
1514
+ const color = resolveChartColor(spPr, themeColors);
1515
+ if (color)
1516
+ dPtMap.set(idx, color);
1517
+ }
1518
+ }
1519
+ // Categories from c:cat
1520
+ const catEl = findChild(ser, "cat");
1521
+ if (catEl) {
1522
+ const strRef = findChild(catEl, "strRef");
1523
+ if (strRef) {
1524
+ const strCache = findChild(strRef, "strCache");
1525
+ if (strCache) {
1526
+ const pts = findChildren(strCache, "pt");
1527
+ for (const pt of pts) {
1528
+ const v = findChild(pt, "v");
1529
+ seriesData.categories.push(v?.textContent ?? "");
1530
+ }
1531
+ }
1532
+ }
1533
+ }
1534
+ // Values from c:val
1535
+ const valEl = findChild(ser, "val");
1536
+ if (valEl) {
1537
+ const numRef = findChild(valEl, "numRef");
1538
+ if (numRef) {
1539
+ const numCache = findChild(numRef, "numCache");
1540
+ if (numCache) {
1541
+ const pts = findChildren(numCache, "pt");
1542
+ for (const pt of pts) {
1543
+ const v = findChild(pt, "v");
1544
+ seriesData.values.push(parseFloat(v?.textContent ?? "0"));
1545
+ }
1546
+ }
1547
+ }
1548
+ }
1549
+ // Build per-point fill colors array
1550
+ for (let i = 0; i < seriesData.values.length; i++) {
1551
+ seriesData.fillColors.push(dPtMap.get(i) ?? undefined);
1552
+ }
1553
+ series.push(seriesData);
1554
+ }
1555
+ if (series.length === 0)
1556
+ return null;
1557
+ // Parse axes
1558
+ let categoryAxis;
1559
+ let valueAxis;
1560
+ const catAx = findChild(plotArea, "catAx");
1561
+ if (catAx) {
1562
+ categoryAxis = parseChartAxis(catAx, themeColors);
1563
+ }
1564
+ const valAx = findChild(plotArea, "valAx");
1565
+ if (valAx) {
1566
+ valueAxis = parseChartAxis(valAx, themeColors);
1567
+ }
1568
+ return {
1569
+ x, y, w, h,
1570
+ chartType,
1571
+ title,
1572
+ titleFont,
1573
+ titleColor,
1574
+ titleSize,
1575
+ series,
1576
+ categoryAxis,
1577
+ valueAxis,
1578
+ gapWidth,
1579
+ roundedCorners,
1580
+ };
1581
+ }
1582
+ // ============================================================================
924
1583
  // Rendering Functions
925
1584
  // ============================================================================
926
- function renderSlideHtml(elements, bgColor) {
1585
+ /**
1586
+ * Format a numeric value using an OOXML number format string.
1587
+ * Handles common patterns like "$"#,##0 → $150, #,##0.0 → 150.0, General → raw
1588
+ */
1589
+ function formatChartValue(value, numFormat) {
1590
+ if (!numFormat || numFormat === "General") {
1591
+ // Smart default: if the value is a whole number, show no decimals
1592
+ return Number.isInteger(value) ? value.toString() : value.toFixed(1);
1593
+ }
1594
+ // Extract prefix (e.g., "$" or text in quotes)
1595
+ let prefix = "";
1596
+ let suffix = "";
1597
+ let fmt = numFormat;
1598
+ // Handle quoted prefix like "$" or "€"
1599
+ const prefixMatch = fmt.match(/^"([^"]*)"(.*)$/);
1600
+ if (prefixMatch) {
1601
+ prefix = prefixMatch[1];
1602
+ fmt = prefixMatch[2];
1603
+ }
1604
+ // Handle quoted suffix
1605
+ const suffixMatch = fmt.match(/^(.*)"([^"]*)"$/);
1606
+ if (suffixMatch) {
1607
+ fmt = suffixMatch[1];
1608
+ suffix = suffixMatch[2];
1609
+ }
1610
+ // Determine decimal places from format
1611
+ const decimalMatch = fmt.match(/\.(0+)/);
1612
+ const decimals = decimalMatch ? decimalMatch[1].length : 0;
1613
+ // Format the number
1614
+ const absVal = Math.abs(value);
1615
+ let formatted;
1616
+ if (fmt.includes(",")) {
1617
+ // Thousands separator
1618
+ const parts = absVal.toFixed(decimals).split(".");
1619
+ parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
1620
+ formatted = parts.join(".");
1621
+ }
1622
+ else {
1623
+ formatted = absVal.toFixed(decimals);
1624
+ }
1625
+ const sign = value < 0 ? "-" : "";
1626
+ return sign + prefix + formatted + suffix;
1627
+ }
1628
+ /**
1629
+ * Escape text for safe embedding inside SVG <text> elements.
1630
+ */
1631
+ function escSvg(text) {
1632
+ return text
1633
+ .replace(/&/g, "&amp;")
1634
+ .replace(/</g, "&lt;")
1635
+ .replace(/>/g, "&gt;")
1636
+ .replace(/"/g, "&quot;");
1637
+ }
1638
+ /**
1639
+ * Render a chart as an inline SVG inside a positioned div.
1640
+ */
1641
+ function renderChartSvg(chart, elId) {
1642
+ const { x, y, w, h } = chart;
1643
+ // Layout constants (in px, relative to chart dimensions)
1644
+ const titleHeight = chart.title ? Math.max(24, (chart.titleSize ?? 16) + 12) : 0;
1645
+ const axisLabelHeight = 16; // space for category labels at bottom
1646
+ const axisTitleHeight = chart.categoryAxis?.title ? 18 : 0;
1647
+ const valAxisTitleWidth = chart.valueAxis?.title ? 20 : 0;
1648
+ // Calculate value axis label width based on max formatted value
1649
+ const allValues = chart.series.flatMap(s => s.values);
1650
+ const maxValue = Math.max(...allValues, 0);
1651
+ const valNumFormat = chart.valueAxis?.numFormat;
1652
+ const maxFormattedLabel = formatChartValue(maxValue, valNumFormat);
1653
+ const valLabelCharWidth = (chart.valueAxis?.labelSize ?? 11) * 0.6;
1654
+ const valLabelWidth = Math.max(30, maxFormattedLabel.length * valLabelCharWidth + 8);
1655
+ // Margins
1656
+ const marginTop = 8 + titleHeight;
1657
+ const marginBottom = 8 + axisLabelHeight + axisTitleHeight + 4;
1658
+ const marginLeft = 4 + valAxisTitleWidth + valLabelWidth;
1659
+ const marginRight = 12;
1660
+ // Plot area
1661
+ const plotX = marginLeft;
1662
+ const plotY = marginTop;
1663
+ const plotW = w - marginLeft - marginRight;
1664
+ const plotH = h - marginTop - marginBottom;
1665
+ if (plotW <= 0 || plotH <= 0)
1666
+ return "";
1667
+ // Determine value range
1668
+ const minValue = chart.valueAxis?.min ?? 0;
1669
+ const valueRange = maxValue - minValue;
1670
+ if (valueRange <= 0)
1671
+ return "";
1672
+ // Calculate nice tick intervals for gridlines
1673
+ const rawInterval = valueRange / 5;
1674
+ const magnitude = Math.pow(10, Math.floor(Math.log10(rawInterval)));
1675
+ const normalized = rawInterval / magnitude;
1676
+ let niceInterval;
1677
+ if (normalized <= 1)
1678
+ niceInterval = 1 * magnitude;
1679
+ else if (normalized <= 2)
1680
+ niceInterval = 2 * magnitude;
1681
+ else if (normalized <= 5)
1682
+ niceInterval = 5 * magnitude;
1683
+ else
1684
+ niceInterval = 10 * magnitude;
1685
+ // Generate tick values
1686
+ const ticks = [];
1687
+ let tickVal = Math.ceil(minValue / niceInterval) * niceInterval;
1688
+ while (tickVal <= maxValue) {
1689
+ ticks.push(tickVal);
1690
+ tickVal += niceInterval;
1691
+ }
1692
+ // Always include 0 if in range
1693
+ if (minValue <= 0 && !ticks.includes(0)) {
1694
+ ticks.unshift(0);
1695
+ ticks.sort((a, b) => a - b);
1696
+ }
1697
+ // Font settings
1698
+ const labelFont = chart.categoryAxis?.labelFont ?? chart.titleFont ?? "sans-serif";
1699
+ const labelColor = chart.categoryAxis?.labelColor ?? "#333";
1700
+ const labelSize = chart.categoryAxis?.labelSize ?? 11;
1701
+ const valLabelColor = chart.valueAxis?.labelColor ?? labelColor;
1702
+ const valLabelSize = chart.valueAxis?.labelSize ?? 11;
1703
+ // SVG building
1704
+ let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" style="overflow:visible">`;
1705
+ // Chart title
1706
+ if (chart.title) {
1707
+ const tFont = chart.titleFont ? `font-family:${cssFontFamily(chart.titleFont)};` : `font-family:${cssFontFamily(labelFont)};`;
1708
+ const tColor = chart.titleColor ?? "#000";
1709
+ const tSize = chart.titleSize ?? 16;
1710
+ svg += `<text x="${w / 2}" y="${8 + tSize}" text-anchor="middle" `
1711
+ + `style="${tFont}font-size:${tSize}px;font-weight:bold;fill:${tColor}">`
1712
+ + `${escSvg(chart.title)}</text>`;
1713
+ }
1714
+ // Gridlines
1715
+ if (chart.valueAxis) {
1716
+ const gColor = chart.valueAxis.gridlineColor ?? "#e0e0e0";
1717
+ const gAlpha = chart.valueAxis.gridlineAlpha !== undefined
1718
+ ? chart.valueAxis.gridlineAlpha / 100
1719
+ : 0.3;
1720
+ for (const tick of ticks) {
1721
+ const tickY = plotY + plotH - ((tick - minValue) / valueRange) * plotH;
1722
+ svg += `<line x1="${plotX}" y1="${tickY}" x2="${plotX + plotW}" y2="${tickY}" `
1723
+ + `stroke="${gColor}" stroke-opacity="${gAlpha}" stroke-width="1"/>`;
1724
+ }
1725
+ }
1726
+ // Bars (column chart: vertical bars)
1727
+ const numCategories = chart.series[0]?.categories.length ?? 0;
1728
+ const numSeries = chart.series.length;
1729
+ if (numCategories > 0) {
1730
+ const gapFraction = (chart.gapWidth ?? 150) / 100;
1731
+ const categoryWidth = plotW / numCategories;
1732
+ const barGroupWidth = categoryWidth / (1 + gapFraction);
1733
+ const gapSpace = categoryWidth - barGroupWidth;
1734
+ const barWidth = barGroupWidth / numSeries;
1735
+ const cornerRadius = chart.roundedCorners ? Math.min(barWidth * 0.15, 4) : 0;
1736
+ for (let si = 0; si < numSeries; si++) {
1737
+ const s = chart.series[si];
1738
+ for (let ci = 0; ci < s.values.length; ci++) {
1739
+ const val = s.values[ci];
1740
+ const barH = ((val - minValue) / valueRange) * plotH;
1741
+ const barX = plotX + ci * categoryWidth + gapSpace / 2 + si * barWidth;
1742
+ const barY = plotY + plotH - barH;
1743
+ // Color: per-data-point > series-level > default
1744
+ const fillColor = s.fillColors[ci] ?? s.seriesColor ?? "#4472C4";
1745
+ if (cornerRadius > 0) {
1746
+ // Rounded top corners only
1747
+ const r = Math.min(cornerRadius, barH / 2, barWidth / 2);
1748
+ svg += `<path d="M${barX},${barY + barH} `
1749
+ + `L${barX},${barY + r} `
1750
+ + `Q${barX},${barY} ${barX + r},${barY} `
1751
+ + `L${barX + barWidth - r},${barY} `
1752
+ + `Q${barX + barWidth},${barY} ${barX + barWidth},${barY + r} `
1753
+ + `L${barX + barWidth},${barY + barH} Z" `
1754
+ + `fill="${fillColor}"/>`;
1755
+ }
1756
+ else {
1757
+ svg += `<rect x="${barX}" y="${barY}" width="${barWidth}" height="${barH}" `
1758
+ + `fill="${fillColor}"/>`;
1759
+ }
1760
+ }
1761
+ }
1762
+ }
1763
+ // Axis lines
1764
+ // Bottom axis (x)
1765
+ svg += `<line x1="${plotX}" y1="${plotY + plotH}" x2="${plotX + plotW}" y2="${plotY + plotH}" `
1766
+ + `stroke="${labelColor}" stroke-width="1"/>`;
1767
+ // Left axis (y)
1768
+ svg += `<line x1="${plotX}" y1="${plotY}" x2="${plotX}" y2="${plotY + plotH}" `
1769
+ + `stroke="${valLabelColor}" stroke-width="1"/>`;
1770
+ // Category labels (bottom)
1771
+ if (numCategories > 0) {
1772
+ const categoryWidth = plotW / numCategories;
1773
+ const cats = chart.series[0]?.categories ?? [];
1774
+ for (let ci = 0; ci < cats.length; ci++) {
1775
+ const labelX = plotX + ci * categoryWidth + categoryWidth / 2;
1776
+ const labelY = plotY + plotH + 4;
1777
+ const catText = cats[ci];
1778
+ // Handle multi-line labels (split by newline)
1779
+ const lines = catText.split("\n");
1780
+ const lineH = labelSize + 2;
1781
+ for (let li = 0; li < lines.length; li++) {
1782
+ svg += `<text x="${labelX}" y="${labelY + labelSize + li * lineH}" `
1783
+ + `text-anchor="middle" `
1784
+ + `style="font-family:${cssFontFamily(labelFont)};font-size:${labelSize}px;fill:${labelColor}">`
1785
+ + `${escSvg(lines[li])}</text>`;
1786
+ }
1787
+ }
1788
+ }
1789
+ // Value axis labels (left)
1790
+ for (const tick of ticks) {
1791
+ const tickY = plotY + plotH - ((tick - minValue) / valueRange) * plotH;
1792
+ const label = formatChartValue(tick, valNumFormat);
1793
+ svg += `<text x="${plotX - 6}" y="${tickY + valLabelSize * 0.35}" `
1794
+ + `text-anchor="end" `
1795
+ + `style="font-family:${cssFontFamily(labelFont)};font-size:${valLabelSize}px;fill:${valLabelColor}">`
1796
+ + `${escSvg(label)}</text>`;
1797
+ }
1798
+ // Category axis title (bottom)
1799
+ if (chart.categoryAxis?.title) {
1800
+ const axTitleY = plotY + plotH + axisLabelHeight + axisTitleHeight + 2;
1801
+ svg += `<text x="${plotX + plotW / 2}" y="${axTitleY}" `
1802
+ + `text-anchor="middle" `
1803
+ + `style="font-family:${cssFontFamily(labelFont)};font-size:${labelSize}px;fill:${labelColor}">`
1804
+ + `${escSvg(chart.categoryAxis.title)}</text>`;
1805
+ }
1806
+ // Value axis title (left, rotated)
1807
+ if (chart.valueAxis?.title) {
1808
+ const axTitleX = 4 + (chart.titleSize ?? 12) * 0.5;
1809
+ const axTitleY = plotY + plotH / 2;
1810
+ svg += `<text x="${axTitleX}" y="${axTitleY}" `
1811
+ + `text-anchor="middle" `
1812
+ + `transform="rotate(-90, ${axTitleX}, ${axTitleY})" `
1813
+ + `style="font-family:${cssFontFamily(labelFont)};font-size:${labelSize}px;fill:${valLabelColor}">`
1814
+ + `${escSvg(chart.valueAxis.title)}</text>`;
1815
+ }
1816
+ svg += `</svg>`;
1817
+ // Wrap in positioned div
1818
+ const wrapperStyles = [
1819
+ "position:absolute",
1820
+ `left:${x}px`,
1821
+ `top:${y}px`,
1822
+ `width:${w}px`,
1823
+ `height:${h}px`,
1824
+ ];
1825
+ return `<div id="${elId}" data-elementType="chart" style="${wrapperStyles.join(";")}">${svg}</div>\n`;
1826
+ }
1827
+ function renderSlideHtml(elements, bgColor, contentOffsetX = 0) {
927
1828
  let inner = "";
928
1829
  let elementIndex = 0;
929
1830
  for (const el of elements) {
930
1831
  const elId = `el-${elementIndex++}`;
931
1832
  if (el.kind === "image") {
932
1833
  const img = el.data;
933
- // Detect images that should use object-fit:cover:
934
- // 1. Fullbleed images (covering entire slide)
935
- // 2. Images with srcRect cropping applied (hasCrop)
1834
+ const altAttr = img.alt
1835
+ ? ` alt="${img.alt.replace(/"/g, "&quot;")}"`
1836
+ : "";
1837
+ // Detect fullbleed images (covering entire slide)
936
1838
  const isFullbleed = img.x <= 10 && img.y <= 10 &&
937
1839
  img.w >= TARGET_WIDTH - 20 && img.h >= TARGET_HEIGHT - 20;
938
- const objectFit = isFullbleed || img.hasCrop ? "cover" : "contain";
1840
+ // Images with srcRect cropping: use div wrapper + overflow:hidden approach
1841
+ // This correctly reproduces PPTX srcRect behavior by:
1842
+ // 1. Computing the visible fraction of the source image
1843
+ // 2. Scaling the image so the visible region fills the element
1844
+ // 3. Offsetting to align the crop region
1845
+ // 4. Clipping with overflow:hidden
1846
+ if (img.hasCrop && img.cropLeft !== undefined && img.cropTop !== undefined &&
1847
+ img.cropRight !== undefined && img.cropBottom !== undefined) {
1848
+ const visW = (100 - img.cropLeft - img.cropRight) / 100;
1849
+ const visH = (100 - img.cropTop - img.cropBottom) / 100;
1850
+ // Guard against zero/negative visible fractions
1851
+ if (visW > 0 && visH > 0) {
1852
+ // Scale image so visible region fills the element dimensions
1853
+ const displayW = img.w / visW;
1854
+ const displayH = img.h / visH;
1855
+ // Offset to position the visible region at the element origin
1856
+ const offsetX = -(img.cropLeft / 100) * displayW;
1857
+ const offsetY = -(img.cropTop / 100) * displayH;
1858
+ // Build wrapper div styles
1859
+ const wrapperStyles = [
1860
+ "position:absolute",
1861
+ `left:${img.x}px`,
1862
+ `top:${img.y}px`,
1863
+ `width:${img.w}px`,
1864
+ `height:${img.h}px`,
1865
+ "overflow:hidden",
1866
+ ];
1867
+ // Apply rotation to wrapper if present (PPTX rotation around element center)
1868
+ if (img.rotation) {
1869
+ wrapperStyles.push(`transform:rotate(${img.rotation}deg)`);
1870
+ wrapperStyles.push("transform-origin:center center");
1871
+ }
1872
+ // Add border-radius to wrapper if present
1873
+ if (img.borderRadius && img.borderRadius > 0) {
1874
+ wrapperStyles.push(`border-radius:${img.borderRadius}px`);
1875
+ }
1876
+ const imgStyle = `position:absolute;width:${Math.round(displayW)}px;height:${Math.round(displayH)}px;left:${Math.round(offsetX)}px;top:${Math.round(offsetY)}px`;
1877
+ inner += `<div id="${elId}" data-elementType="image" style="${wrapperStyles.join(";")}">`;
1878
+ inner += `<img src="${img.dataUri}"${altAttr} style="${imgStyle}" />`;
1879
+ inner += `</div>\n`;
1880
+ continue;
1881
+ }
1882
+ }
1883
+ // Non-cropped images (or fallback): use simple img with object-fit
1884
+ const objectFit = isFullbleed ? "cover" : "contain";
939
1885
  const stylesList = [
940
1886
  "position:absolute",
941
1887
  `left:${img.x}px`,
@@ -944,14 +1890,16 @@ function renderSlideHtml(elements, bgColor) {
944
1890
  `height:${img.h}px`,
945
1891
  `object-fit:${objectFit}`,
946
1892
  ];
1893
+ // Apply rotation if present (PPTX rotation around element center)
1894
+ if (img.rotation) {
1895
+ stylesList.push(`transform:rotate(${img.rotation}deg)`);
1896
+ stylesList.push("transform-origin:center center");
1897
+ }
947
1898
  // Add border-radius if present
948
1899
  if (img.borderRadius && img.borderRadius > 0) {
949
1900
  stylesList.push(`border-radius:${img.borderRadius}px`);
950
1901
  }
951
1902
  const styles = stylesList.join(";");
952
- const altAttr = img.alt
953
- ? ` alt="${img.alt.replace(/"/g, "&quot;")}"`
954
- : "";
955
1903
  inner += `<img id="${elId}" data-elementType="image" src="${img.dataUri}"${altAttr} style="${styles}" />\n`;
956
1904
  continue;
957
1905
  }
@@ -1045,6 +1993,8 @@ function renderSlideHtml(elements, bgColor) {
1045
1993
  rStyles.push("font-weight:bold");
1046
1994
  if (run.italic)
1047
1995
  rStyles.push("font-style:italic");
1996
+ if (run.underline)
1997
+ rStyles.push("text-decoration:underline");
1048
1998
  if (run.fontFamily)
1049
1999
  rStyles.push(`font-family:${cssFontFamily(run.fontFamily)}`);
1050
2000
  if (run.textShadow)
@@ -1070,6 +2020,11 @@ function renderSlideHtml(elements, bgColor) {
1070
2020
  inner += `<div id="${elId}" data-elementType="table" style="${tableStyles.join(";")}">${tableHtml}</div>\n`;
1071
2021
  continue;
1072
2022
  }
2023
+ if (el.kind === "chart") {
2024
+ const chart = el.data;
2025
+ inner += renderChartSvg(chart, elId);
2026
+ continue;
2027
+ }
1073
2028
  const shape = el.data;
1074
2029
  const styles = [
1075
2030
  "position:absolute",
@@ -1082,6 +2037,12 @@ function renderSlideHtml(elements, bgColor) {
1082
2037
  if (shape.fill) {
1083
2038
  styles.push(`background:${shape.fill}`);
1084
2039
  }
2040
+ if (shape.clipPath) {
2041
+ styles.push(`clip-path:${shape.clipPath}`);
2042
+ }
2043
+ if (shape.rotation) {
2044
+ styles.push(`transform:rotate(${shape.rotation}deg)`);
2045
+ }
1085
2046
  if (shape.borderRadius) {
1086
2047
  styles.push(`border-radius:${shape.borderRadius}px`);
1087
2048
  }
@@ -1118,7 +2079,12 @@ function renderSlideHtml(elements, bgColor) {
1118
2079
  else if (shape.verticalAlign === "bottom")
1119
2080
  styles.push("justify-content:flex-end");
1120
2081
  }
1121
- if (shape.paragraphs.length === 0 && !shape.fill) {
2082
+ // Skip zero-dimension shapes (e.g. geometry-driven shapes like right triangles
2083
+ // where the visible area comes from path data, not bounding box)
2084
+ if (shape.w <= 0 || shape.h <= 0) {
2085
+ continue;
2086
+ }
2087
+ if (shape.paragraphs.length === 0 && !shape.fill && !shape.borderWidth && !shape.isEllipse) {
1122
2088
  continue;
1123
2089
  }
1124
2090
  const hasText = shape.paragraphs.length > 0;
@@ -1144,10 +2110,16 @@ function renderSlideHtml(elements, bgColor) {
1144
2110
  pStyles.push(`text-indent:${para.indentPx}px`);
1145
2111
  let runHtml = "";
1146
2112
  if (para.bulletChar) {
1147
- const bulletStyle = para.bulletColor
1148
- ? `margin-right:4px;color:${para.bulletColor}`
1149
- : "margin-right:4px";
1150
- runHtml += `<span style="${bulletStyle}">${para.bulletChar}</span>`;
2113
+ const bulletStyleParts = ["margin-right:4px"];
2114
+ if (para.bulletColor)
2115
+ bulletStyleParts.push(`color:${para.bulletColor}`);
2116
+ if (para.bulletFont)
2117
+ bulletStyleParts.push(`font-family:${cssFontFamily(para.bulletFont)}`);
2118
+ // Inherit font size from the first text run so bullet matches text size
2119
+ const firstRunFontSize = para.runs[0]?.fontSize;
2120
+ if (firstRunFontSize)
2121
+ bulletStyleParts.push(`font-size:${firstRunFontSize}px`);
2122
+ runHtml += `<span style="${bulletStyleParts.join(";")}">${para.bulletChar}</span>`;
1151
2123
  }
1152
2124
  for (const run of para.runs) {
1153
2125
  const rStyles = [];
@@ -1167,6 +2139,8 @@ function renderSlideHtml(elements, bgColor) {
1167
2139
  rStyles.push("font-weight:bold");
1168
2140
  if (run.italic)
1169
2141
  rStyles.push("font-style:italic");
2142
+ if (run.underline)
2143
+ rStyles.push("text-decoration:underline");
1170
2144
  if (run.fontFamily)
1171
2145
  rStyles.push(`font-family:${cssFontFamily(run.fontFamily)}`);
1172
2146
  if (run.textShadow)
@@ -1187,7 +2161,10 @@ function renderSlideHtml(elements, bgColor) {
1187
2161
  inner += `<div${idAttr} style="${styles.join(";")}">${content}</div>\n`;
1188
2162
  }
1189
2163
  const bg = bgColor ?? "#fff";
1190
- return `<div style="position:relative;width:${TARGET_WIDTH}px;height:${TARGET_HEIGHT}px;overflow:hidden;font-family:'Segoe UI',Arial,sans-serif;background:${bg}">\n${inner}</div>`;
2164
+ // For non-16:9 slides, horizontally center the content within the target container
2165
+ const offsetPx = Math.round(contentOffsetX);
2166
+ const innerWrapStyle = offsetPx > 0 ? ` style="position:absolute;left:${offsetPx}px;top:0"` : "";
2167
+ return `<div style="position:relative;width:${TARGET_WIDTH}px;height:${TARGET_HEIGHT}px;overflow:hidden;font-family:'Segoe UI',Arial,sans-serif;background:${bg}">\n<div${innerWrapStyle}>\n${inner}</div>\n</div>`;
1191
2168
  }
1192
2169
  // ============================================================================
1193
2170
  // Main Export Function
@@ -1211,6 +2188,9 @@ export default async function importPptx(arrayBuffer) {
1211
2188
  const slideWPx = emuToPx(slideWEmu);
1212
2189
  const slideHPx = emuToPx(slideHEmu);
1213
2190
  const scale = Math.min(TARGET_WIDTH / slideWPx, TARGET_HEIGHT / slideHPx);
2191
+ // For non-16:9 slides, calculate horizontal offset to center content
2192
+ const scaledContentW = slideWPx * scale;
2193
+ const contentOffsetX = (TARGET_WIDTH - scaledContentW) / 2;
1214
2194
  // Get visible slides from sldIdLst in presentation.xml
1215
2195
  // This determines which slides are actually included in the presentation
1216
2196
  const sldIdLst = presDoc.getElementsByTagName("p:sldIdLst")[0];
@@ -1271,6 +2251,8 @@ export default async function importPptx(arrayBuffer) {
1271
2251
  // Parse theme colors
1272
2252
  const themeColors = new Map();
1273
2253
  const themeXml = await zip.file("ppt/theme/theme1.xml")?.async("text");
2254
+ // Parse bgFillStyleLst from theme for bgRef resolution
2255
+ const bgFillStyles = [];
1274
2256
  if (themeXml) {
1275
2257
  const themeDoc = parser.parseFromString(themeXml, "application/xml");
1276
2258
  const clrScheme = themeDoc.getElementsByTagName("a:clrScheme")[0];
@@ -1317,6 +2299,13 @@ export default async function importPptx(arrayBuffer) {
1317
2299
  const lt2Color = themeColors.get("lt2");
1318
2300
  if (lt2Color)
1319
2301
  themeColors.set("bg2", lt2Color);
2302
+ // Parse bgFillStyleLst for resolving bgRef elements
2303
+ const bgFillStyleLst = themeDoc.getElementsByTagName("a:bgFillStyleLst")[0];
2304
+ if (bgFillStyleLst) {
2305
+ for (let i = 0; i < bgFillStyleLst.children.length; i++) {
2306
+ bgFillStyles.push(bgFillStyleLst.children[i]);
2307
+ }
2308
+ }
1320
2309
  }
1321
2310
  // Parse theme fonts
1322
2311
  let themeFonts;
@@ -1362,35 +2351,164 @@ export default async function importPptx(arrayBuffer) {
1362
2351
  fontStyleBlock = `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${fontFamilies}&display=swap">`;
1363
2352
  }
1364
2353
  const slides = [];
2354
+ // ---- Parse slide master for background and decorative shapes ----
2355
+ // Master shapes appear behind slide content when the layout has showMasterSp != "0"
2356
+ let masterBg;
2357
+ let masterDecorativeElements = [];
2358
+ const masterXml = await zip.file("ppt/slideMasters/slideMaster1.xml")?.async("text");
2359
+ if (masterXml) {
2360
+ const masterDoc = parser.parseFromString(masterXml, "application/xml");
2361
+ // Master background
2362
+ const masterBgEl = masterDoc.getElementsByTagName("p:bg")[0];
2363
+ if (masterBgEl) {
2364
+ masterBg = extractBgFill(masterBgEl, bgFillStyles, themeColors);
2365
+ }
2366
+ // Master decorative shapes (build image map from master rels)
2367
+ const masterImageMap = new Map();
2368
+ const masterRelsXml = await zip.file("ppt/slideMasters/_rels/slideMaster1.xml.rels")?.async("text");
2369
+ if (masterRelsXml) {
2370
+ const relsDoc = parser.parseFromString(masterRelsXml, "application/xml");
2371
+ const rels = relsDoc.getElementsByTagName("Relationship");
2372
+ for (let ri = 0; ri < rels.length; ri++) {
2373
+ const rel = rels[ri];
2374
+ const type = rel.getAttribute("Type") ?? "";
2375
+ if (!type.includes("/image"))
2376
+ continue;
2377
+ const rId = rel.getAttribute("Id");
2378
+ const target = rel.getAttribute("Target");
2379
+ if (!rId || !target)
2380
+ continue;
2381
+ const mediaPath = target.startsWith("../") ? "ppt/" + target.slice(3) : target;
2382
+ const imgFile = zip.file(mediaPath);
2383
+ if (!imgFile)
2384
+ continue;
2385
+ const imgData = await imgFile.async("base64");
2386
+ const ext = mediaPath.split(".").pop()?.toLowerCase() ?? "png";
2387
+ const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : `image/${ext}`;
2388
+ masterImageMap.set(rId, `data:${mime};base64,${imgData}`);
2389
+ }
2390
+ }
2391
+ const masterSpTree = masterDoc.getElementsByTagName("p:spTree")[0];
2392
+ if (masterSpTree) {
2393
+ masterDecorativeElements = parseDecorativeShapes(masterSpTree, scale, themeColors, masterImageMap, themeFonts);
2394
+ }
2395
+ }
2396
+ // ---- Cache parsed slide layouts ----
2397
+ // Maps layout file path to { bg, showMasterSp, decorativeElements }
2398
+ const layoutCache = new Map();
2399
+ async function getLayoutInfo(layoutPath) {
2400
+ if (layoutCache.has(layoutPath))
2401
+ return layoutCache.get(layoutPath);
2402
+ const result = { bg: undefined, showMasterSp: true, decorativeElements: [] };
2403
+ const layoutXml = await zip.file(layoutPath)?.async("text");
2404
+ if (layoutXml) {
2405
+ const layoutDoc = parser.parseFromString(layoutXml, "application/xml");
2406
+ // Check showMasterSp attribute on cSld
2407
+ const cSld = layoutDoc.getElementsByTagName("p:cSld")[0];
2408
+ // showMasterSp is on the root element of the layout
2409
+ const rootEl = layoutDoc.documentElement;
2410
+ const showMasterSpAttr = rootEl.getAttribute("showMasterSp");
2411
+ if (showMasterSpAttr === "0") {
2412
+ result.showMasterSp = false;
2413
+ }
2414
+ // Layout background
2415
+ const layoutBgEl = layoutDoc.getElementsByTagName("p:bg")[0];
2416
+ if (layoutBgEl) {
2417
+ result.bg = extractBgFill(layoutBgEl, bgFillStyles, themeColors);
2418
+ }
2419
+ // Layout decorative shapes (build image map from layout rels)
2420
+ const layoutImageMap = new Map();
2421
+ const layoutBaseName = layoutPath.split("/").pop();
2422
+ const layoutRelsPath = layoutPath.replace(layoutBaseName, `_rels/${layoutBaseName}.rels`);
2423
+ const layoutRelsXml = await zip.file(layoutRelsPath)?.async("text");
2424
+ if (layoutRelsXml) {
2425
+ const relsDoc = parser.parseFromString(layoutRelsXml, "application/xml");
2426
+ const rels = relsDoc.getElementsByTagName("Relationship");
2427
+ for (let ri = 0; ri < rels.length; ri++) {
2428
+ const rel = rels[ri];
2429
+ const type = rel.getAttribute("Type") ?? "";
2430
+ if (!type.includes("/image"))
2431
+ continue;
2432
+ const rId = rel.getAttribute("Id");
2433
+ const target = rel.getAttribute("Target");
2434
+ if (!rId || !target)
2435
+ continue;
2436
+ const mediaPath = target.startsWith("../") ? "ppt/" + target.slice(3) : target;
2437
+ const imgFile = zip.file(mediaPath);
2438
+ if (!imgFile)
2439
+ continue;
2440
+ const imgData = await imgFile.async("base64");
2441
+ const ext = mediaPath.split(".").pop()?.toLowerCase() ?? "png";
2442
+ const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : `image/${ext}`;
2443
+ layoutImageMap.set(rId, `data:${mime};base64,${imgData}`);
2444
+ }
2445
+ }
2446
+ const layoutSpTree = layoutDoc.getElementsByTagName("p:spTree")[0];
2447
+ if (layoutSpTree) {
2448
+ result.decorativeElements = parseDecorativeShapes(layoutSpTree, scale, themeColors, layoutImageMap, themeFonts);
2449
+ }
2450
+ }
2451
+ layoutCache.set(layoutPath, result);
2452
+ return result;
2453
+ }
1365
2454
  for (const slideFile of slideFiles) {
1366
2455
  const slideXml = await zip.file(slideFile)?.async("text");
1367
2456
  if (!slideXml)
1368
2457
  continue;
1369
2458
  const slideDoc = parser.parseFromString(slideXml, "application/xml");
1370
2459
  const spTree = slideDoc.getElementsByTagName("p:spTree")[0];
1371
- // Extract slide background
2460
+ // ---- Resolve background with inheritance chain: slide → layout → master ----
1372
2461
  let slideBg = undefined;
1373
2462
  const bgEl = slideDoc.getElementsByTagName("p:bg")[0];
1374
2463
  if (bgEl) {
1375
- const bgPr = findChild(bgEl, "bgPr");
1376
- if (bgPr) {
1377
- slideBg = extractFill(bgPr, themeColors);
1378
- }
2464
+ slideBg = extractBgFill(bgEl, bgFillStyles, themeColors);
1379
2465
  }
1380
- if (!spTree) {
1381
- slides.push(renderSlideHtml([], slideBg));
1382
- continue;
1383
- }
1384
- // Build image map from slide rels
1385
- const imageMap = new Map();
2466
+ // Determine which layout this slide uses (from slide rels)
1386
2467
  const slideBaseName = slideFile.split("/").pop();
1387
2468
  const relsPath = slideFile.replace(slideBaseName, `_rels/${slideBaseName}.rels`);
1388
2469
  const relsXml = await zip.file(relsPath)?.async("text");
2470
+ let layoutPath;
1389
2471
  if (relsXml) {
1390
2472
  const relsDoc = parser.parseFromString(relsXml, "application/xml");
1391
2473
  const rels = relsDoc.getElementsByTagName("Relationship");
1392
- for (let i = 0; i < rels.length; i++) {
1393
- const rel = rels[i];
2474
+ for (let ri = 0; ri < rels.length; ri++) {
2475
+ const rel = rels[ri];
2476
+ const type = rel.getAttribute("Type") ?? "";
2477
+ if (type.includes("/slideLayout")) {
2478
+ const target = rel.getAttribute("Target");
2479
+ if (target) {
2480
+ layoutPath = target.startsWith("../")
2481
+ ? "ppt/" + target.slice(3)
2482
+ : "ppt/slides/" + target;
2483
+ }
2484
+ break;
2485
+ }
2486
+ }
2487
+ }
2488
+ // Get layout info for background and shape inheritance
2489
+ let layoutInfo;
2490
+ if (layoutPath) {
2491
+ layoutInfo = await getLayoutInfo(layoutPath);
2492
+ }
2493
+ // Background inheritance: slide bg → layout bg → master bg
2494
+ if (!slideBg && layoutInfo?.bg) {
2495
+ slideBg = layoutInfo.bg;
2496
+ }
2497
+ if (!slideBg && masterBg) {
2498
+ slideBg = masterBg;
2499
+ }
2500
+ if (!spTree) {
2501
+ slides.push(renderSlideHtml([], slideBg, contentOffsetX));
2502
+ continue;
2503
+ }
2504
+ // Build image map from slide rels (reuse relsXml parsed above for layout lookup)
2505
+ const imageMap = new Map();
2506
+ // Parse rels document once for both image map and chart resolution
2507
+ const slideRelsDoc = relsXml ? parser.parseFromString(relsXml, "application/xml") : null;
2508
+ if (slideRelsDoc) {
2509
+ const rels = slideRelsDoc.getElementsByTagName("Relationship");
2510
+ for (let ri = 0; ri < rels.length; ri++) {
2511
+ const rel = rels[ri];
1394
2512
  const type = rel.getAttribute("Type") ?? "";
1395
2513
  if (!type.includes("/image"))
1396
2514
  continue;
@@ -1416,6 +2534,15 @@ export default async function importPptx(arrayBuffer) {
1416
2534
  }
1417
2535
  // Parse all elements
1418
2536
  const elements = [];
2537
+ if (layoutInfo?.showMasterSp !== false) {
2538
+ // Master shapes are shown unless layout explicitly hides them
2539
+ elements.push(...masterDecorativeElements);
2540
+ }
2541
+ // Layout decorative shapes appear after master shapes, before slide content
2542
+ if (layoutInfo?.decorativeElements) {
2543
+ elements.push(...layoutInfo.decorativeElements);
2544
+ }
2545
+ // Parse slide's own elements
1419
2546
  for (let i = 0; i < spTree.children.length; i++) {
1420
2547
  const child = spTree.children[i];
1421
2548
  if (child.localName === "sp") {
@@ -1430,11 +2557,17 @@ export default async function importPptx(arrayBuffer) {
1430
2557
  }
1431
2558
  else if (child.localName === "graphicFrame") {
1432
2559
  const table = parseTable(child, scale, themeColors, themeFonts, tableStyleMap);
1433
- if (table)
2560
+ if (table) {
1434
2561
  elements.push({ kind: "table", data: table });
2562
+ }
2563
+ else if (slideRelsDoc) {
2564
+ const chart = await parseChart(child, scale, themeColors, zip, slideRelsDoc, parser);
2565
+ if (chart)
2566
+ elements.push({ kind: "chart", data: chart });
2567
+ }
1435
2568
  }
1436
2569
  }
1437
- slides.push(renderSlideHtml(elements, slideBg));
2570
+ slides.push(renderSlideHtml(elements, slideBg, contentOffsetX));
1438
2571
  }
1439
2572
  // Prepend font style block to first slide so embedded fonts load via Google Fonts
1440
2573
  if (fontStyleBlock && slides.length > 0) {