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.
- package/dist/bundle.js +948 -37
- package/dist/bundle.min.js +96 -91
- package/dist/cli.js +912 -66
- package/dist/packages/cli/commands/common.d.ts +2 -0
- package/dist/packages/cli/commands/common.d.ts.map +1 -0
- package/dist/packages/cli/commands/common.js +22 -0
- package/dist/packages/cli/commands/common.js.map +1 -0
- package/dist/packages/cli/commands/export-docs.d.ts.map +1 -1
- package/dist/packages/cli/commands/export-docs.js +2 -6
- package/dist/packages/cli/commands/export-docs.js.map +1 -1
- package/dist/packages/cli/commands/export-slides.d.ts.map +1 -1
- package/dist/packages/cli/commands/export-slides.js +1 -25
- package/dist/packages/cli/commands/export-slides.js.map +1 -1
- package/dist/packages/slides/import-pptx.d.ts.map +1 -1
- package/dist/packages/slides/import-pptx.js +1180 -47
- package/dist/packages/slides/import-pptx.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
58
|
-
if (
|
|
59
|
-
|
|
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
|
|
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
|
|
70
|
-
if (
|
|
71
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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, "&")
|
|
1634
|
+
.replace(/</g, "<")
|
|
1635
|
+
.replace(/>/g, ">")
|
|
1636
|
+
.replace(/"/g, """);
|
|
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
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1834
|
+
const altAttr = img.alt
|
|
1835
|
+
? ` alt="${img.alt.replace(/"/g, """)}"`
|
|
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
|
-
|
|
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, """)}"`
|
|
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
|
-
|
|
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
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1376
|
-
if (bgPr) {
|
|
1377
|
-
slideBg = extractFill(bgPr, themeColors);
|
|
1378
|
-
}
|
|
2464
|
+
slideBg = extractBgFill(bgEl, bgFillStyles, themeColors);
|
|
1379
2465
|
}
|
|
1380
|
-
|
|
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
|
|
1393
|
-
const rel = rels[
|
|
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) {
|