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.
- package/.babelrc.cjs +15 -0
- package/.eslintignore +3 -0
- package/.eslintrc.cjs +78 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/favicon.ico +0 -0
- package/index.html +541 -0
- package/package.json +56 -0
- package/rollup.config.js +42 -0
- package/scripts/extract-pptx-structure.js +115 -0
- package/scripts/transvert.js +34 -0
- package/src/adapter/toPptxtojson.ts +46 -0
- package/src/adapter/types.ts +330 -0
- package/src/export/serializePresentation.ts +200 -0
- package/src/index.ts +27 -0
- package/src/model/Layout.ts +218 -0
- package/src/model/Master.ts +114 -0
- package/src/model/Presentation.ts +502 -0
- package/src/model/Slide.ts +386 -0
- package/src/model/Theme.ts +95 -0
- package/src/model/nodes/BaseNode.ts +169 -0
- package/src/model/nodes/ChartNode.ts +55 -0
- package/src/model/nodes/GroupNode.ts +62 -0
- package/src/model/nodes/PicNode.ts +102 -0
- package/src/model/nodes/ShapeNode.ts +289 -0
- package/src/model/nodes/TableNode.ts +135 -0
- package/src/parser/RelParser.ts +81 -0
- package/src/parser/XmlParser.ts +101 -0
- package/src/parser/ZipParser.ts +277 -0
- package/src/parser/units.ts +59 -0
- package/src/serializer/RenderContext.ts +79 -0
- package/src/serializer/StyleResolver.ts +821 -0
- package/src/serializer/backgroundSerializer.ts +143 -0
- package/src/serializer/borderMapper.ts +93 -0
- package/src/serializer/chartSerializer.ts +97 -0
- package/src/serializer/fillMapper.ts +224 -0
- package/src/serializer/groupSerializer.ts +94 -0
- package/src/serializer/imageSerializer.ts +330 -0
- package/src/serializer/index.ts +27 -0
- package/src/serializer/shapeSerializer.ts +694 -0
- package/src/serializer/slideSerializer.ts +250 -0
- package/src/serializer/tableSerializer.ts +66 -0
- package/src/serializer/textSerializer.md +70 -0
- package/src/serializer/textSerializer.ts +1019 -0
- package/src/shapes/customGeometry.ts +178 -0
- package/src/shapes/presets.ts +6587 -0
- package/src/shapes/shapeArc.ts +44 -0
- package/src/types/vendor-shims.d.ts +20 -0
- package/src/utils/color.ts +488 -0
- package/src/utils/emfParser.ts +298 -0
- package/src/utils/media.ts +73 -0
- package/src/utils/mediaWebConvert.ts +100 -0
- package/src/utils/rgbaToPng.ts +33 -0
- package/src/utils/urlSafety.ts +17 -0
- 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
|
+
}
|