pptxtojson-pro 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.babelrc.cjs +15 -0
  2. package/.eslintignore +3 -0
  3. package/.eslintrc.cjs +78 -0
  4. package/LICENSE +21 -0
  5. package/README.md +294 -0
  6. package/favicon.ico +0 -0
  7. package/index.html +541 -0
  8. package/package.json +56 -0
  9. package/rollup.config.js +42 -0
  10. package/scripts/extract-pptx-structure.js +115 -0
  11. package/scripts/transvert.js +34 -0
  12. package/src/adapter/toPptxtojson.ts +46 -0
  13. package/src/adapter/types.ts +330 -0
  14. package/src/export/serializePresentation.ts +200 -0
  15. package/src/index.ts +27 -0
  16. package/src/model/Layout.ts +218 -0
  17. package/src/model/Master.ts +114 -0
  18. package/src/model/Presentation.ts +502 -0
  19. package/src/model/Slide.ts +386 -0
  20. package/src/model/Theme.ts +95 -0
  21. package/src/model/nodes/BaseNode.ts +169 -0
  22. package/src/model/nodes/ChartNode.ts +55 -0
  23. package/src/model/nodes/GroupNode.ts +62 -0
  24. package/src/model/nodes/PicNode.ts +102 -0
  25. package/src/model/nodes/ShapeNode.ts +289 -0
  26. package/src/model/nodes/TableNode.ts +135 -0
  27. package/src/parser/RelParser.ts +81 -0
  28. package/src/parser/XmlParser.ts +101 -0
  29. package/src/parser/ZipParser.ts +277 -0
  30. package/src/parser/units.ts +59 -0
  31. package/src/serializer/RenderContext.ts +79 -0
  32. package/src/serializer/StyleResolver.ts +821 -0
  33. package/src/serializer/backgroundSerializer.ts +143 -0
  34. package/src/serializer/borderMapper.ts +93 -0
  35. package/src/serializer/chartSerializer.ts +97 -0
  36. package/src/serializer/fillMapper.ts +224 -0
  37. package/src/serializer/groupSerializer.ts +94 -0
  38. package/src/serializer/imageSerializer.ts +330 -0
  39. package/src/serializer/index.ts +27 -0
  40. package/src/serializer/shapeSerializer.ts +694 -0
  41. package/src/serializer/slideSerializer.ts +250 -0
  42. package/src/serializer/tableSerializer.ts +66 -0
  43. package/src/serializer/textSerializer.md +70 -0
  44. package/src/serializer/textSerializer.ts +1019 -0
  45. package/src/shapes/customGeometry.ts +178 -0
  46. package/src/shapes/presets.ts +6587 -0
  47. package/src/shapes/shapeArc.ts +44 -0
  48. package/src/types/vendor-shims.d.ts +20 -0
  49. package/src/utils/color.ts +488 -0
  50. package/src/utils/emfParser.ts +298 -0
  51. package/src/utils/media.ts +73 -0
  52. package/src/utils/mediaWebConvert.ts +100 -0
  53. package/src/utils/rgbaToPng.ts +33 -0
  54. package/src/utils/urlSafety.ts +17 -0
  55. package/tsconfig.json +24 -0
@@ -0,0 +1,821 @@
1
+ /**
2
+ * Style resolver — converts OOXML color and fill nodes to CSS values.
3
+ */
4
+
5
+ import { SafeXmlNode } from '../parser/XmlParser';
6
+ import { RenderContext } from './RenderContext';
7
+ import {
8
+ applyColorModifiers,
9
+ presetColorToHex,
10
+ hexToRgb,
11
+ hslToRgb,
12
+ rgbToHex,
13
+ } from '../utils/color';
14
+ import type { ColorModifier } from '../utils/color';
15
+ import { pctToDecimal, angleToDeg, emuToPx } from '../parser/units';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Color Resolution
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Build a cache key for a color node based on its tag, value, and modifiers.
23
+ */
24
+ function buildColorCacheKey(colorNode: SafeXmlNode): string {
25
+ const parts: string[] = [colorNode.localName, colorNode.attr('val') ?? ''];
26
+ for (const child of colorNode.allChildren()) {
27
+ const tag = child.localName;
28
+ const val = child.attr('val');
29
+ if (tag) parts.push(`${tag}:${val ?? ''}`);
30
+ // Include nested color children for wrapper nodes
31
+ for (const grandchild of child.allChildren()) {
32
+ const gtag = grandchild.localName;
33
+ const gval = grandchild.attr('val');
34
+ if (gtag) parts.push(`${gtag}:${gval ?? ''}`);
35
+ }
36
+ }
37
+ return parts.join('|');
38
+ }
39
+
40
+ /**
41
+ * Collect OOXML color modifier children from a color node.
42
+ * Modifiers are child elements like alpha, lumMod, lumOff, tint, shade, satMod, hueMod.
43
+ */
44
+ function collectModifiers(colorNode: SafeXmlNode): ColorModifier[] {
45
+ const modifiers: ColorModifier[] = [];
46
+ for (const child of colorNode.allChildren()) {
47
+ const name = child.localName;
48
+ const val = child.numAttr('val');
49
+ if (val !== undefined && name) {
50
+ modifiers.push({ name, val });
51
+ }
52
+ }
53
+ return modifiers;
54
+ }
55
+
56
+ /**
57
+ * Resolve a scheme color name through the master colorMap then theme colorScheme.
58
+ *
59
+ * OOXML scheme colors use logical names (e.g., "tx1", "bg1", "accent1").
60
+ * The master's colorMap remaps some of these (e.g., "tx1" -> "dk1").
61
+ * The theme's colorScheme holds the actual hex values keyed by the mapped name.
62
+ */
63
+ function resolveSchemeColor(schemeName: string, ctx: RenderContext): string {
64
+ // Apply colorMap remapping (layout override takes priority)
65
+ let mappedName = schemeName;
66
+ if (ctx.layout.colorMapOverride) {
67
+ const override = ctx.layout.colorMapOverride.get(schemeName);
68
+ if (override) mappedName = override;
69
+ }
70
+ if (mappedName === schemeName) {
71
+ const mapped = ctx.master.colorMap.get(schemeName);
72
+ if (mapped) mappedName = mapped;
73
+ }
74
+
75
+ // Look up in theme color scheme
76
+ const hex = ctx.theme.colorScheme.get(mappedName);
77
+ if (hex) return hex;
78
+
79
+ // Fallback: try the original name directly in theme
80
+ const fallback = ctx.theme.colorScheme.get(schemeName);
81
+ return fallback || '000000';
82
+ }
83
+
84
+ /**
85
+ * Resolve an OOXML color node (srgbClr, schemeClr, sysClr, prstClr, hslClr, scrgbClr)
86
+ * into a CSS-ready hex color and alpha value.
87
+ */
88
+ export function resolveColor(
89
+ colorNode: SafeXmlNode,
90
+ ctx: RenderContext,
91
+ ): { color: string; alpha: number } {
92
+ // Check cache
93
+ const cacheKey = buildColorCacheKey(colorNode);
94
+ const cached = ctx.colorCache.get(cacheKey);
95
+ if (cached) return cached;
96
+
97
+ const result = resolveColorUncached(colorNode, ctx);
98
+ ctx.colorCache.set(cacheKey, result);
99
+ return result;
100
+ }
101
+
102
+ function resolveColorUncached(
103
+ colorNode: SafeXmlNode,
104
+ ctx: RenderContext,
105
+ placeholderColorNode?: SafeXmlNode,
106
+ ): { color: string; alpha: number } {
107
+ // Iterate child elements to find the actual color type node
108
+ for (const child of colorNode.allChildren()) {
109
+ const tag = child.localName;
110
+ const modifiers = collectModifiers(child);
111
+
112
+ switch (tag) {
113
+ case 'srgbClr': {
114
+ const hex = child.attr('val') || '000000';
115
+ return applyColorModifiers(hex, modifiers);
116
+ }
117
+
118
+ case 'schemeClr': {
119
+ const scheme = child.attr('val') || 'tx1';
120
+ if (scheme.toLowerCase() === 'phclr' && placeholderColorNode?.exists()) {
121
+ const base = resolveColor(placeholderColorNode, ctx);
122
+ const baseHex = base.color.startsWith('#') ? base.color.slice(1) : base.color;
123
+ const adjusted = applyColorModifiers(baseHex, modifiers);
124
+ return { color: adjusted.color, alpha: adjusted.alpha * base.alpha };
125
+ }
126
+ const hex = resolveSchemeColor(scheme, ctx);
127
+ return applyColorModifiers(hex, modifiers);
128
+ }
129
+
130
+ case 'sysClr': {
131
+ const hex = child.attr('lastClr') || child.attr('val') || '000000';
132
+ return applyColorModifiers(hex, modifiers);
133
+ }
134
+
135
+ case 'prstClr': {
136
+ const name = child.attr('val') || 'black';
137
+ const hex = presetColorToHex(name) || '#000000';
138
+ return applyColorModifiers(hex.replace('#', ''), modifiers);
139
+ }
140
+
141
+ case 'hslClr': {
142
+ const hue = (child.numAttr('hue') ?? 0) / 60000; // 60000ths of degree -> degrees
143
+ const sat = (child.numAttr('sat') ?? 0) / 100000; // percentage
144
+ const lum = (child.numAttr('lum') ?? 0) / 100000;
145
+ const rgb = hslToRgb(hue, sat, lum);
146
+ const hex = rgbToHex(rgb.r, rgb.g, rgb.b).replace('#', '');
147
+ return applyColorModifiers(hex, modifiers);
148
+ }
149
+
150
+ case 'scrgbClr': {
151
+ // r, g, b are percentages (0-100000)
152
+ const r = Math.round(((child.numAttr('r') ?? 0) / 100000) * 255);
153
+ const g = Math.round(((child.numAttr('g') ?? 0) / 100000) * 255);
154
+ const b = Math.round(((child.numAttr('b') ?? 0) / 100000) * 255);
155
+ const hex = rgbToHex(r, g, b).replace('#', '');
156
+ return applyColorModifiers(hex, modifiers);
157
+ }
158
+
159
+ default:
160
+ // Not a recognized color child — continue looking
161
+ break;
162
+ }
163
+ }
164
+
165
+ // If the node itself is a color type (no wrapper)
166
+ const selfTag = colorNode.localName;
167
+ if (selfTag === 'srgbClr') {
168
+ const hex = colorNode.attr('val') || '000000';
169
+ return applyColorModifiers(hex, collectModifiers(colorNode));
170
+ }
171
+ if (selfTag === 'schemeClr') {
172
+ const scheme = colorNode.attr('val') || 'tx1';
173
+ if (scheme.toLowerCase() === 'phclr' && placeholderColorNode?.exists()) {
174
+ const base = resolveColor(placeholderColorNode, ctx);
175
+ const baseHex = base.color.startsWith('#') ? base.color.slice(1) : base.color;
176
+ const adjusted = applyColorModifiers(baseHex, collectModifiers(colorNode));
177
+ return { color: adjusted.color, alpha: adjusted.alpha * base.alpha };
178
+ }
179
+ const hex = resolveSchemeColor(scheme, ctx);
180
+ return applyColorModifiers(hex, collectModifiers(colorNode));
181
+ }
182
+ if (selfTag === 'sysClr') {
183
+ const hex = colorNode.attr('lastClr') || colorNode.attr('val') || '000000';
184
+ return applyColorModifiers(hex, collectModifiers(colorNode));
185
+ }
186
+ if (selfTag === 'prstClr') {
187
+ const name = colorNode.attr('val') || 'black';
188
+ const hex = presetColorToHex(name) || '#000000';
189
+ return applyColorModifiers(hex.replace('#', ''), collectModifiers(colorNode));
190
+ }
191
+
192
+ return { color: '#000000', alpha: 1 };
193
+ }
194
+
195
+ /**
196
+ * Resolve a color node and return a CSS color string.
197
+ * Convenience wrapper combining resolveColor + colorToCss.
198
+ */
199
+ export function resolveColorToCss(node: SafeXmlNode, ctx: RenderContext): string {
200
+ const { color, alpha } = resolveColor(node, ctx);
201
+ return colorToCss(color, alpha);
202
+ }
203
+
204
+ /**
205
+ * Convert a resolved color + alpha into a CSS rgba() string.
206
+ */
207
+ function colorToCss(color: string, alpha: number): string {
208
+ const hex = color.startsWith('#') ? color : `#${color}`;
209
+ const { r, g, b } = hexToRgb(hex);
210
+ if (alpha >= 1) {
211
+ return hex;
212
+ }
213
+ return `rgba(${r},${g},${b},${alpha.toFixed(3)})`;
214
+ }
215
+
216
+ function resolveColorWithPlaceholder(
217
+ colorNode: SafeXmlNode,
218
+ ctx: RenderContext,
219
+ placeholderColorNode?: SafeXmlNode,
220
+ ): { color: string; alpha: number } {
221
+ if (!placeholderColorNode?.exists()) return resolveColor(colorNode, ctx);
222
+ return resolveColorUncached(colorNode, ctx, placeholderColorNode);
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // Fill Resolution
227
+ // ---------------------------------------------------------------------------
228
+
229
+ /**
230
+ * Resolve a fill from shape properties (spPr) into a CSS background value.
231
+ *
232
+ * Returns:
233
+ * - CSS color/gradient string for solidFill/gradFill
234
+ * - 'transparent' for noFill
235
+ * - '' for blipFill (handled by ImageRenderer) or no fill found (inherit)
236
+ */
237
+ export function resolveFill(spPr: SafeXmlNode, ctx: RenderContext): string {
238
+ // solidFill
239
+ const solidFill = spPr.child('solidFill');
240
+ if (solidFill.exists()) {
241
+ const { color, alpha } = resolveColor(solidFill, ctx);
242
+ return colorToCss(color, alpha);
243
+ }
244
+
245
+ // gradFill
246
+ const gradFill = spPr.child('gradFill');
247
+ if (gradFill.exists()) {
248
+ return resolveGradient(gradFill, ctx);
249
+ }
250
+
251
+ // blipFill — handled externally by ImageRenderer
252
+ const blipFill = spPr.child('blipFill');
253
+ if (blipFill.exists()) {
254
+ return '';
255
+ }
256
+
257
+ // pattFill — pattern fill rendered as CSS repeating gradient
258
+ const pattFill = spPr.child('pattFill');
259
+ if (pattFill.exists()) {
260
+ return resolvePatternFill(pattFill, ctx);
261
+ }
262
+
263
+ // grpFill — inherit fill from parent group
264
+ const grpFill = spPr.child('grpFill');
265
+ if (grpFill.exists()) {
266
+ if (ctx.groupFillNode) {
267
+ return resolveFill(ctx.groupFillNode, ctx);
268
+ }
269
+ // No group fill context available — fall through to no fill
270
+ return '';
271
+ }
272
+
273
+ // noFill
274
+ const noFill = spPr.child('noFill');
275
+ if (noFill.exists()) {
276
+ return 'transparent';
277
+ }
278
+
279
+ // No fill found — inherit
280
+ return '';
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // Pattern Fill Resolution
285
+ // ---------------------------------------------------------------------------
286
+
287
+ /**
288
+ * Resolve `<a:pattFill>` into a CSS background value using repeating gradients.
289
+ *
290
+ * OOXML defines 40+ pattern presets. We support the most common ones and
291
+ * fall back to a simple foreground/background 50% mix for unknown patterns.
292
+ */
293
+ function resolvePatternFill(pattFill: SafeXmlNode, ctx: RenderContext): string {
294
+ const preset = pattFill.attr('prst') ?? 'solid';
295
+
296
+ // Foreground and background colors
297
+ let fg = '#000000';
298
+ let bg = '#ffffff';
299
+
300
+ const fgClr = pattFill.child('fgClr');
301
+ if (fgClr.exists()) {
302
+ const { color, alpha } = resolveColor(fgClr, ctx);
303
+ fg = colorToCss(color, alpha);
304
+ }
305
+
306
+ const bgClr = pattFill.child('bgClr');
307
+ if (bgClr.exists()) {
308
+ const { color, alpha } = resolveColor(bgClr, ctx);
309
+ bg = colorToCss(color, alpha);
310
+ }
311
+
312
+ // Size of pattern tile in px
313
+ const s = 8;
314
+
315
+ // Helper: returns CSS `background` shorthand with repeating pattern layer(s) over bg color.
316
+ // Format: "<gradient-layer> 0 0/<size>, <bg-color-layer>"
317
+ // This is a valid multi-layer CSS background shorthand.
318
+ const pat = (gradient: string): string => `${gradient} 0 0/${s}px ${s}px, ${bg}`;
319
+ const pat2 = (g1: string, g2: string): string =>
320
+ `${g1} 0 0/${s}px ${s}px, ${g2} 0 0/${s}px ${s}px, ${bg}`;
321
+
322
+ switch (preset) {
323
+ // Solid fills
324
+ case 'solid':
325
+ case 'solidDmnd':
326
+ return fg;
327
+
328
+ // Percentage fills (dots on background)
329
+ case 'pct5':
330
+ case 'pct10':
331
+ case 'pct20':
332
+ case 'pct25':
333
+ return pat(`radial-gradient(${fg} 1px, transparent 1px)`);
334
+ case 'pct30':
335
+ case 'pct40':
336
+ case 'pct50':
337
+ return pat(`radial-gradient(${fg} 1.5px, transparent 1.5px)`);
338
+ case 'pct60':
339
+ case 'pct70':
340
+ case 'pct75':
341
+ case 'pct80':
342
+ case 'pct90':
343
+ return pat(`radial-gradient(${fg} 2.5px, transparent 2.5px)`);
344
+
345
+ // Horizontal lines
346
+ case 'horz':
347
+ case 'ltHorz':
348
+ case 'narHorz':
349
+ case 'dkHorz':
350
+ return pat(
351
+ `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`,
352
+ );
353
+
354
+ // Vertical lines
355
+ case 'vert':
356
+ case 'ltVert':
357
+ case 'narVert':
358
+ case 'dkVert':
359
+ return pat(
360
+ `repeating-linear-gradient(90deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`,
361
+ );
362
+
363
+ // Diagonal lines (down-right)
364
+ case 'dnDiag':
365
+ case 'ltDnDiag':
366
+ case 'narDnDiag':
367
+ case 'dkDnDiag':
368
+ case 'wdDnDiag':
369
+ return pat(
370
+ `repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`,
371
+ );
372
+
373
+ // Diagonal lines (up-right)
374
+ case 'upDiag':
375
+ case 'ltUpDiag':
376
+ case 'narUpDiag':
377
+ case 'dkUpDiag':
378
+ case 'wdUpDiag':
379
+ return pat(
380
+ `repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`,
381
+ );
382
+
383
+ // Grid (horizontal + vertical)
384
+ case 'smGrid':
385
+ case 'lgGrid':
386
+ case 'cross':
387
+ return pat2(
388
+ `repeating-linear-gradient(0deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`,
389
+ `repeating-linear-gradient(90deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`,
390
+ );
391
+
392
+ // Diagonal cross
393
+ case 'smCheck':
394
+ case 'lgCheck':
395
+ case 'diagCross':
396
+ case 'openDmnd':
397
+ return pat2(
398
+ `repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`,
399
+ `repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 1px, transparent 1px, transparent ${s}px)`,
400
+ );
401
+
402
+ // Dot patterns
403
+ case 'dotGrid':
404
+ case 'dotDmnd':
405
+ return pat(`radial-gradient(${fg} 1px, transparent 1px)`);
406
+
407
+ // Trellis / weave
408
+ case 'trellis':
409
+ case 'weave':
410
+ return pat2(
411
+ `repeating-linear-gradient(45deg, ${fg} 0px, ${fg} 2px, transparent 2px, transparent ${s}px)`,
412
+ `repeating-linear-gradient(-45deg, ${fg} 0px, ${fg} 2px, transparent 2px, transparent ${s}px)`,
413
+ );
414
+
415
+ // Dash variants
416
+ case 'dashDnDiag':
417
+ case 'dashUpDiag':
418
+ case 'dashHorz':
419
+ case 'dashVert': {
420
+ const angle = preset.includes('Dn')
421
+ ? '45deg'
422
+ : preset.includes('Up')
423
+ ? '-45deg'
424
+ : preset.includes('Horz')
425
+ ? '0deg'
426
+ : '90deg';
427
+ return pat(
428
+ `repeating-linear-gradient(${angle}, ${fg} 0px, ${fg} 3px, transparent 3px, transparent ${s}px)`,
429
+ );
430
+ }
431
+
432
+ // Sphere / shingle — radial gradient approximation
433
+ case 'sphere':
434
+ case 'shingle':
435
+ case 'plaid':
436
+ case 'divot':
437
+ case 'zigZag':
438
+ return pat(`radial-gradient(${fg} 2px, transparent 2px)`);
439
+
440
+ default:
441
+ return bg;
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Parse a gradient fill into a CSS gradient string.
447
+ */
448
+ function resolveGradient(
449
+ gradFill: SafeXmlNode,
450
+ ctx: RenderContext,
451
+ placeholderColorNode?: SafeXmlNode,
452
+ ): string {
453
+ // Parse gradient stops
454
+ const gsLst = gradFill.child('gsLst');
455
+ const stops: { position: number; color: string }[] = [];
456
+
457
+ for (const gs of gsLst.children('gs')) {
458
+ const pos = gs.numAttr('pos') ?? 0;
459
+ const posPercent = pctToDecimal(pos) * 100;
460
+ const { color, alpha } = resolveColorWithPlaceholder(gs, ctx, placeholderColorNode);
461
+ stops.push({ position: posPercent, color: colorToCss(color, alpha) });
462
+ }
463
+
464
+ if (stops.length === 0) {
465
+ return '';
466
+ }
467
+
468
+ // Sort stops by position
469
+ stops.sort((a, b) => a.position - b.position);
470
+
471
+ const stopsStr = stops.map((s) => `${s.color} ${s.position.toFixed(1)}%`).join(', ');
472
+
473
+ // Determine gradient type
474
+ const lin = gradFill.child('lin');
475
+ if (lin.exists()) {
476
+ const angle = angleToDeg(lin.numAttr('ang') ?? 0);
477
+ // OOXML angle 0 = top-to-bottom in the gradient coordinate system
478
+ // CSS angle 0 = bottom-to-top, so we need to adjust
479
+ const cssAngle = (angle + 90) % 360;
480
+ return `linear-gradient(${cssAngle.toFixed(1)}deg, ${stopsStr})`;
481
+ }
482
+
483
+ const path = gradFill.child('path');
484
+ if (path.exists()) {
485
+ const pathType = path.attr('path');
486
+ if (pathType === 'circle' || pathType === 'shape' || pathType === 'rect') {
487
+ // OOXML path gradients: stop pos=0 = fillToRect center, pos=100000 = shape edge.
488
+ // CSS radial-gradient: 0% = center, 100% = edge.
489
+ // Conventions match — no reversal needed.
490
+
491
+ // Resolve fillToRect center point
492
+ const ftr = path.child('fillToRect');
493
+ let cx = 50;
494
+ let cy = 50;
495
+ if (ftr.exists()) {
496
+ const l = (ftr.numAttr('l') ?? 0) / 100000;
497
+ const t = (ftr.numAttr('t') ?? 0) / 100000;
498
+ const r = (ftr.numAttr('r') ?? 0) / 100000;
499
+ const b = (ftr.numAttr('b') ?? 0) / 100000;
500
+ cx = ((l + (1 - r)) / 2) * 100;
501
+ cy = ((t + (1 - b)) / 2) * 100;
502
+ }
503
+
504
+ if (pathType === 'rect') {
505
+ // Rectangular gradient (L∞ norm / Chebyshev distance): creates cross/X contour
506
+ // pattern. CSS can't do this natively; approximate by overlaying horizontal and
507
+ // vertical linear gradients with a radial gradient as fallback.
508
+ // The SVG path in ShapeRenderer uses the proper blend approach.
509
+ return `radial-gradient(closest-side at ${cx.toFixed(1)}% ${cy.toFixed(1)}%, ${stopsStr})`;
510
+ }
511
+
512
+ return `radial-gradient(ellipse at ${cx.toFixed(1)}% ${cy.toFixed(1)}%, ${stopsStr})`;
513
+ }
514
+ }
515
+
516
+ // Default to linear top-to-bottom
517
+ return `linear-gradient(180deg, ${stopsStr})`;
518
+ }
519
+
520
+ // ---------------------------------------------------------------------------
521
+ // Line Style Resolution
522
+ // ---------------------------------------------------------------------------
523
+
524
+ /**
525
+ * Resolve a line (outline) node into CSS-compatible properties.
526
+ *
527
+ * @param ln The `<a:ln>` node from spPr
528
+ * @param ctx Render context
529
+ * @param lnRef Optional `<a:lnRef>` from `<p:style>` — provides fallback color
530
+ * when `<a:ln>` has no explicit solidFill (common for connectors)
531
+ */
532
+ export function resolveLineStyle(
533
+ ln: SafeXmlNode,
534
+ ctx: RenderContext,
535
+ lnRef?: SafeXmlNode,
536
+ ): { width: number; color: string; dash: string; dashKind: string } {
537
+ // Width: a:ln@w is in EMU, convert to px
538
+ const widthEmu = ln.numAttr('w') ?? 0;
539
+ let width = emuToPx(widthEmu);
540
+
541
+ // Color from solidFill child
542
+ let color = 'transparent';
543
+ const solidFill = ln.child('solidFill');
544
+ if (solidFill.exists()) {
545
+ const phClr = solidFill.child('schemeClr');
546
+ const usesPlaceholder = phClr.exists() && (phClr.attr('val') ?? '').toLowerCase() === 'phclr';
547
+ if (usesPlaceholder && lnRef && lnRef.exists()) {
548
+ // Theme line styles often use schemeClr=phClr and expect the concrete color from lnRef.
549
+ const base = resolveColor(lnRef, ctx);
550
+ const baseHex = base.color.startsWith('#') ? base.color.slice(1) : base.color;
551
+ const adjusted = applyColorModifiers(baseHex, collectModifiers(phClr));
552
+ color = colorToCss(adjusted.color, adjusted.alpha * base.alpha);
553
+ } else {
554
+ const resolved = resolveColor(solidFill, ctx);
555
+ color = colorToCss(resolved.color, resolved.alpha);
556
+ }
557
+ } else if (lnRef && lnRef.exists() && (lnRef.numAttr('idx') ?? 0) > 0) {
558
+ const idx = lnRef.numAttr('idx') ?? 0;
559
+ // Look up theme line style for width, color, and dash
560
+ if (idx > 0 && ctx.theme.lineStyles && ctx.theme.lineStyles.length >= idx) {
561
+ const themeLn = ctx.theme.lineStyles[idx - 1];
562
+ // Get width from theme line if not set on the explicit ln node
563
+ if (width === 0) {
564
+ const themeW = themeLn.numAttr('w') ?? 0;
565
+ width = emuToPx(themeW);
566
+ }
567
+ // Get color: prefer lnRef's own color child, fall back to theme line's solidFill
568
+ const resolved = resolveColor(lnRef, ctx);
569
+ color = colorToCss(resolved.color, resolved.alpha);
570
+ } else {
571
+ // Fallback: use lnRef color directly, approximate width from idx
572
+ const resolved = resolveColor(lnRef, ctx);
573
+ color = colorToCss(resolved.color, resolved.alpha);
574
+ if (width === 0 && idx > 0) {
575
+ width = idx * 0.75; // approximate: idx 1 = ~0.75px, idx 2 = ~1.5px
576
+ }
577
+ }
578
+ }
579
+
580
+ // Width fallback should still use lnRef/theme even when explicit solidFill is present on <a:ln>.
581
+ if (width === 0 && lnRef && lnRef.exists()) {
582
+ const idx = lnRef.numAttr('idx') ?? 0;
583
+ if (idx > 0 && ctx.theme.lineStyles && ctx.theme.lineStyles.length >= idx) {
584
+ const themeLn = ctx.theme.lineStyles[idx - 1];
585
+ const themeW = themeLn.numAttr('w') ?? 0;
586
+ width = emuToPx(themeW);
587
+ } else if (idx > 0) {
588
+ width = idx * 0.75;
589
+ }
590
+ }
591
+
592
+ // Dash pattern
593
+ let dash = 'solid';
594
+ let dashKind = 'solid';
595
+ const prstDash = ln.child('prstDash');
596
+ if (prstDash.exists()) {
597
+ const val = prstDash.attr('val') || 'solid';
598
+ dashKind = val;
599
+ dash = ooxmlDashToCss(val);
600
+ }
601
+
602
+ // If no dash from explicit ln, check theme line style
603
+ if (dash === 'solid' && lnRef && lnRef.exists()) {
604
+ const idx = lnRef.numAttr('idx') ?? 0;
605
+ if (idx > 0 && ctx.theme.lineStyles && ctx.theme.lineStyles.length >= idx) {
606
+ const themeLn = ctx.theme.lineStyles[idx - 1];
607
+ const themeDash = themeLn.child('prstDash');
608
+ if (themeDash.exists()) {
609
+ dashKind = themeDash.attr('val') || 'solid';
610
+ dash = ooxmlDashToCss(dashKind);
611
+ }
612
+ }
613
+ }
614
+
615
+ return { width, color, dash, dashKind };
616
+ }
617
+
618
+ /**
619
+ * Map OOXML preset dash values to CSS border-style.
620
+ */
621
+ function ooxmlDashToCss(val: string): string {
622
+ switch (val) {
623
+ case 'solid':
624
+ return 'solid';
625
+ case 'dot':
626
+ case 'sysDot':
627
+ return 'dotted';
628
+ case 'dash':
629
+ case 'sysDash':
630
+ case 'lgDash':
631
+ return 'dashed';
632
+ case 'dashDot':
633
+ case 'lgDashDot':
634
+ case 'lgDashDotDot':
635
+ case 'sysDashDot':
636
+ case 'sysDashDotDot':
637
+ return 'dashed';
638
+ default:
639
+ return 'solid';
640
+ }
641
+ }
642
+
643
+ // ---------------------------------------------------------------------------
644
+ // Gradient Fill Resolution (structured data for SVG use)
645
+ // ---------------------------------------------------------------------------
646
+
647
+ export interface GradientFillData {
648
+ type: 'linear' | 'radial';
649
+ stops: Array<{ position: number; color: string }>;
650
+ /** SVG gradient interpolation space; OOXML gradients visually match linearRGB more closely. */
651
+ colorInterpolation?: 'linearRGB' | 'sRGB';
652
+ /** OOXML angle in degrees (0 = top-to-bottom). Only relevant for linear gradients. */
653
+ angle: number;
654
+ /** Radial gradient center X as fraction 0–1. Default 0.5. */
655
+ cx?: number;
656
+ /** Radial gradient center Y as fraction 0–1. Default 0.5. */
657
+ cy?: number;
658
+ /** OOXML path type for radial gradients: 'rect', 'circle', or 'shape'. */
659
+ pathType?: string;
660
+ }
661
+
662
+ function resolveGradientFillNode(
663
+ gradFill: SafeXmlNode,
664
+ ctx: RenderContext,
665
+ placeholderColorNode?: SafeXmlNode,
666
+ ): GradientFillData | null {
667
+ const gsLst = gradFill.child('gsLst');
668
+ const stops: Array<{ position: number; color: string }> = [];
669
+
670
+ for (const gs of gsLst.children('gs')) {
671
+ const pos = gs.numAttr('pos') ?? 0;
672
+ const posPercent = pctToDecimal(pos) * 100;
673
+ const { color, alpha } = resolveColorWithPlaceholder(gs, ctx, placeholderColorNode);
674
+ stops.push({ position: posPercent, color: colorToCss(color, alpha) });
675
+ }
676
+
677
+ if (stops.length === 0) return null;
678
+ stops.sort((a, b) => a.position - b.position);
679
+
680
+ const lin = gradFill.child('lin');
681
+ if (lin.exists()) {
682
+ const angle = angleToDeg(lin.numAttr('ang') ?? 0);
683
+ return { type: 'linear', stops, angle, colorInterpolation: 'linearRGB' };
684
+ }
685
+
686
+ const path = gradFill.child('path');
687
+ if (path.exists()) {
688
+ const pathType = path.attr('path');
689
+ if (pathType === 'circle' || pathType === 'shape' || pathType === 'rect') {
690
+ const ftr = path.child('fillToRect');
691
+ let cx = 0.5;
692
+ let cy = 0.5;
693
+ if (ftr.exists()) {
694
+ const l = (ftr.numAttr('l') ?? 0) / 100000;
695
+ const t = (ftr.numAttr('t') ?? 0) / 100000;
696
+ const r = (ftr.numAttr('r') ?? 0) / 100000;
697
+ const b = (ftr.numAttr('b') ?? 0) / 100000;
698
+ cx = (l + (1 - r)) / 2;
699
+ cy = (t + (1 - b)) / 2;
700
+ }
701
+ return {
702
+ type: 'radial',
703
+ stops,
704
+ angle: 0,
705
+ cx,
706
+ cy,
707
+ pathType: pathType,
708
+ colorInterpolation: 'linearRGB',
709
+ };
710
+ }
711
+ }
712
+
713
+ return { type: 'linear', stops, angle: 0, colorInterpolation: 'linearRGB' };
714
+ }
715
+
716
+ /**
717
+ * Resolve a gradient fill from `spPr` into structured data suitable for
718
+ * creating SVG gradient elements. Returns null if no gradient fill is present.
719
+ */
720
+ export function resolveGradientFill(
721
+ spPr: SafeXmlNode,
722
+ ctx: RenderContext,
723
+ ): GradientFillData | null {
724
+ let gradFill = spPr.child('gradFill');
725
+
726
+ // grpFill: inherit gradient from parent group's grpSpPr
727
+ if (!gradFill.exists() && spPr.child('grpFill').exists() && ctx.groupFillNode) {
728
+ gradFill = ctx.groupFillNode.child('gradFill');
729
+ }
730
+
731
+ if (!gradFill.exists()) return null;
732
+
733
+ return resolveGradientFillNode(gradFill, ctx);
734
+ }
735
+
736
+ export function resolveThemeFillReference(
737
+ fillRef: SafeXmlNode,
738
+ ctx: RenderContext,
739
+ ): { fillCss: string; gradientFillData: GradientFillData | null } {
740
+ const idx = fillRef.numAttr('idx') ?? 0;
741
+ if (idx <= 0 || (ctx.theme.fillStyles?.length ?? 0) < idx) {
742
+ return { fillCss: resolveColorToCss(fillRef, ctx), gradientFillData: null };
743
+ }
744
+
745
+ const themeFill = ctx.theme.fillStyles[idx - 1];
746
+ if (!themeFill?.exists()) {
747
+ return { fillCss: resolveColorToCss(fillRef, ctx), gradientFillData: null };
748
+ }
749
+
750
+ if (themeFill.localName === 'solidFill') {
751
+ const resolved = resolveColorWithPlaceholder(themeFill, ctx, fillRef);
752
+ return { fillCss: colorToCss(resolved.color, resolved.alpha), gradientFillData: null };
753
+ }
754
+
755
+ if (themeFill.localName === 'gradFill') {
756
+ return {
757
+ fillCss: resolveGradient(themeFill, ctx, fillRef),
758
+ gradientFillData: resolveGradientFillNode(themeFill, ctx, fillRef),
759
+ };
760
+ }
761
+
762
+ if (themeFill.localName === 'pattFill') {
763
+ return { fillCss: resolvePatternFill(themeFill, ctx), gradientFillData: null };
764
+ }
765
+
766
+ if (themeFill.localName === 'noFill') {
767
+ return { fillCss: 'transparent', gradientFillData: null };
768
+ }
769
+
770
+ return { fillCss: resolveColorToCss(fillRef, ctx), gradientFillData: null };
771
+ }
772
+
773
+ // ---------------------------------------------------------------------------
774
+ // Gradient Stroke Resolution
775
+ // ---------------------------------------------------------------------------
776
+
777
+ export interface GradientStrokeData {
778
+ stops: Array<{ position: number; color: string }>;
779
+ angle: number;
780
+ width: number;
781
+ colorInterpolation?: 'linearRGB' | 'sRGB';
782
+ }
783
+
784
+ /**
785
+ * Resolve a gradient stroke from an `<a:ln>` node that contains `<a:gradFill>`.
786
+ * Returns gradient stop data, angle, and line width — or null if no gradient fill is present.
787
+ */
788
+ export function resolveGradientStroke(
789
+ ln: SafeXmlNode,
790
+ ctx: RenderContext,
791
+ ): GradientStrokeData | null {
792
+ const gradFill = ln.child('gradFill');
793
+ if (!gradFill.exists()) return null;
794
+
795
+ const gsLst = gradFill.child('gsLst');
796
+ const stops: Array<{ position: number; color: string }> = [];
797
+
798
+ for (const gs of gsLst.children('gs')) {
799
+ const pos = gs.numAttr('pos') ?? 0;
800
+ const posPercent = pctToDecimal(pos) * 100;
801
+ const { color, alpha } = resolveColor(gs, ctx);
802
+ const cssColor = colorToCss(color, alpha);
803
+ stops.push({ position: posPercent, color: cssColor });
804
+ }
805
+
806
+ if (stops.length === 0) return null;
807
+ stops.sort((a, b) => a.position - b.position);
808
+
809
+ const lin = gradFill.child('lin');
810
+ let angle = 0;
811
+ if (lin.exists()) {
812
+ angle = angleToDeg(lin.numAttr('ang') ?? 0);
813
+ }
814
+
815
+ const widthEmu = ln.numAttr('w') ?? 0;
816
+ let width = emuToPx(widthEmu);
817
+ // OOXML default when w is omitted is typically 1 pt; avoid invisible gradient stroke
818
+ if (width <= 0) width = 1;
819
+
820
+ return { stops, angle, width, colorInterpolation: 'linearRGB' };
821
+ }