pptx-browser 4.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/src/colors.js ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * colors.js — OOXML colour resolution: all 6 colour types, all transforms.
3
+ */
4
+
5
+ import { attrInt, clamp } from './utils.js';
6
+
7
+ // ── Hex / RGB / HLS conversion ──────────────────────────────────────────────
8
+
9
+ export function hexToRgb(hex) {
10
+ const h = hex.replace('#', '');
11
+ return {
12
+ r: parseInt(h.slice(0, 2), 16),
13
+ g: parseInt(h.slice(2, 4), 16),
14
+ b: parseInt(h.slice(4, 6), 16),
15
+ };
16
+ }
17
+
18
+ export function rgbToHex(r, g, b) {
19
+ return '#' + [r, g, b]
20
+ .map(v => clamp(Math.round(v), 0, 255).toString(16).padStart(2, '0'))
21
+ .join('');
22
+ }
23
+
24
+ export function rgbToHls(r, g, b) {
25
+ r /= 255; g /= 255; b /= 255;
26
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
27
+ let h = 0, s = 0;
28
+ const l = (max + min) / 2;
29
+ if (max !== min) {
30
+ const d = max - min;
31
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
32
+ switch (max) {
33
+ case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
34
+ case g: h = ((b - r) / d + 2) / 6; break;
35
+ case b: h = ((r - g) / d + 4) / 6; break;
36
+ }
37
+ }
38
+ return { h, l, s };
39
+ }
40
+
41
+ export function hlsToRgb(h, l, s) {
42
+ if (s === 0) {
43
+ const v = Math.round(l * 255);
44
+ return { r: v, g: v, b: v };
45
+ }
46
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
47
+ const p = 2 * l - q;
48
+ const hue2rgb = (p, q, t) => {
49
+ if (t < 0) t += 1;
50
+ if (t > 1) t -= 1;
51
+ if (t < 1/6) return p + (q - p) * 6 * t;
52
+ if (t < 1/2) return q;
53
+ if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
54
+ return p;
55
+ };
56
+ return {
57
+ r: Math.round(hue2rgb(p, q, h + 1/3) * 255),
58
+ g: Math.round(hue2rgb(p, q, h) * 255),
59
+ b: Math.round(hue2rgb(p, q, h - 1/3) * 255),
60
+ };
61
+ }
62
+
63
+ // ── Preset colours (CSS colour names → hex) ─────────────────────────────────
64
+
65
+ const PRESET_COLORS = {
66
+ black:'000000', white:'FFFFFF', red:'FF0000', green:'008000', blue:'0000FF',
67
+ yellow:'FFFF00', cyan:'00FFFF', magenta:'FF00FF', orange:'FFA500',
68
+ purple:'800080', pink:'FFC0CB', brown:'A52A2A', gray:'808080', grey:'808080',
69
+ navy:'000080', teal:'008080', maroon:'800000', olive:'808000', lime:'00FF00',
70
+ aqua:'00FFFF', fuchsia:'FF00FF', silver:'C0C0C0', coral:'FF7F50',
71
+ salmon:'FA8072', gold:'FFD700', khaki:'F0E68C', lavender:'E6E6FA',
72
+ beige:'F5F5DC', ivory:'FFFFF0', mintcream:'F5FFFA', azure:'F0FFFF',
73
+ aliceblue:'F0F8FF', ghostwhite:'F8F8FF', darkred:'8B0000',
74
+ darkgreen:'006400', darkblue:'00008B', darkcyan:'008B8B',
75
+ darkmagenta:'8B008B', darkorange:'FF8C00', darkgray:'A9A9A9',
76
+ darkgrey:'A9A9A9', lightgray:'D3D3D3', lightgrey:'D3D3D3',
77
+ lightblue:'ADD8E6', lightgreen:'90EE90', lightpink:'FFB6C1',
78
+ lightyellow:'FFFFE0', lightcyan:'E0FFFF', deepskyblue:'00BFFF',
79
+ royalblue:'4169E1', steelblue:'4682B4', skyblue:'87CEEB',
80
+ dodgerblue:'1E90FF', cornflowerblue:'6495ED', mediumblue:'0000CD',
81
+ midnightblue:'191970', indigo:'4B0082', slateblue:'6A5ACD',
82
+ blueviolet:'8A2BE2', mediumpurple:'9370DB', orchid:'DA70D6',
83
+ violet:'EE82EE', plum:'DDA0DD', thistle:'D8BFD8', hotpink:'FF69B4',
84
+ deeppink:'FF1493', crimson:'DC143C', firebrick:'B22222', tomato:'FF6347',
85
+ orangered:'FF4500', darkorange2:'FF8C00', chocolate:'D2691E',
86
+ saddlebrown:'8B4513', sienna:'A0522D', tan:'D2B48C', burlywood:'DEB887',
87
+ wheat:'F5DEB3', moccasin:'FFE4B5', peachpuff:'FFDAB9', papayawhip:'FFEFD5',
88
+ mistyrose:'FFE4E1', linen:'FAF0E6', oldlace:'FDF5E6', floralwhite:'FFFAF0',
89
+ antiquewhite:'FAEBD7', bisque:'FFE4C4', blanchedalmond:'FFEBCD',
90
+ cornsilk:'FFF8DC', lemonchiffon:'FFFACD', honeydew:'F0FFF0',
91
+ palegreen:'98FB98', lightseagreen:'20B2AA', mediumseagreen:'3CB371',
92
+ seagreen:'2E8B57', forestgreen:'228B22', yellowgreen:'9ACD32',
93
+ olivedrab:'6B8E23', greenyellow:'ADFF2F', chartreuse:'7FFF00',
94
+ springgreen:'00FF7F', mediumspringgreen:'00FA9A', aquamarine:'7FFFD4',
95
+ turquoise:'40E0D0', mediumturquoise:'48D1CC', paleturquoise:'AFEEEE',
96
+ cadetblue:'5F9EA0', powderblue:'B0E0E6', lightsteelblue:'B0C4DE',
97
+ slategray:'708090', slategrey:'708090', dimgray:'696969', dimgrey:'696969',
98
+ snow:'FFFAFA', seashell:'FFF5EE', whitesmoke:'F5F5F5', gainsboro:'DCDCDC',
99
+ };
100
+
101
+ // ── Colour transforms ────────────────────────────────────────────────────────
102
+
103
+ /**
104
+ * Apply all OOXML colour transforms in document order.
105
+ * `c` = { r, g, b, a }; returns the same shape.
106
+ */
107
+ export function applyColorTransforms(c, transformEl) {
108
+ if (!transformEl) return c;
109
+ let { r, g, b, a = 1 } = c;
110
+
111
+ for (const child of transformEl.children) {
112
+ const ln = child.localName;
113
+ const val = parseInt(child.getAttribute('val') ?? '0', 10);
114
+
115
+ switch (ln) {
116
+ case 'lumMod': {
117
+ // val in 1/1000th of a percent (100000 = 100%)
118
+ const f = val / 100000;
119
+ const hls = rgbToHls(r, g, b);
120
+ const rgb = hlsToRgb(hls.h, clamp(hls.l * f, 0, 1), hls.s);
121
+ r = rgb.r; g = rgb.g; b = rgb.b;
122
+ break;
123
+ }
124
+ case 'lumOff': {
125
+ const f = val / 100000;
126
+ const hls = rgbToHls(r, g, b);
127
+ const rgb = hlsToRgb(hls.h, clamp(hls.l + f, 0, 1), hls.s);
128
+ r = rgb.r; g = rgb.g; b = rgb.b;
129
+ break;
130
+ }
131
+ case 'tint': {
132
+ // Blend toward white
133
+ const f = val / 100000;
134
+ r = Math.round(r + (255 - r) * (1 - f));
135
+ g = Math.round(g + (255 - g) * (1 - f));
136
+ b = Math.round(b + (255 - b) * (1 - f));
137
+ break;
138
+ }
139
+ case 'shade': {
140
+ // Blend toward black
141
+ const f = val / 100000;
142
+ r = Math.round(r * f);
143
+ g = Math.round(g * f);
144
+ b = Math.round(b * f);
145
+ break;
146
+ }
147
+ case 'satMod': {
148
+ const f = val / 100000;
149
+ const hls = rgbToHls(r, g, b);
150
+ const rgb = hlsToRgb(hls.h, hls.l, clamp(hls.s * f, 0, 1));
151
+ r = rgb.r; g = rgb.g; b = rgb.b;
152
+ break;
153
+ }
154
+ case 'satOff': {
155
+ const f = val / 100000;
156
+ const hls = rgbToHls(r, g, b);
157
+ const rgb = hlsToRgb(hls.h, hls.l, clamp(hls.s + f, 0, 1));
158
+ r = rgb.r; g = rgb.g; b = rgb.b;
159
+ break;
160
+ }
161
+ case 'hueMod': {
162
+ const f = val / 100000;
163
+ const hls = rgbToHls(r, g, b);
164
+ const rgb = hlsToRgb((hls.h * f) % 1, hls.l, hls.s);
165
+ r = rgb.r; g = rgb.g; b = rgb.b;
166
+ break;
167
+ }
168
+ case 'hueOff': {
169
+ const f = val / 21600000; // 60000ths of a degree, full circle = 21600000
170
+ const hls = rgbToHls(r, g, b);
171
+ const rgb = hlsToRgb(((hls.h + f) % 1 + 1) % 1, hls.l, hls.s);
172
+ r = rgb.r; g = rgb.g; b = rgb.b;
173
+ break;
174
+ }
175
+ case 'alpha': {
176
+ a = val / 100000;
177
+ break;
178
+ }
179
+ case 'alphaOff': {
180
+ a = clamp(a + val / 100000, 0, 1);
181
+ break;
182
+ }
183
+ case 'alphaMod': {
184
+ a = clamp(a * val / 100000, 0, 1);
185
+ break;
186
+ }
187
+ case 'inv': {
188
+ r = 255 - r; g = 255 - g; b = 255 - b;
189
+ break;
190
+ }
191
+ case 'gray': {
192
+ const lum = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
193
+ r = g = b = lum;
194
+ break;
195
+ }
196
+ case 'comp': {
197
+ const hls = rgbToHls(r, g, b);
198
+ const rgb = hlsToRgb((hls.h + 0.5) % 1, hls.l, hls.s);
199
+ r = rgb.r; g = rgb.g; b = rgb.b;
200
+ break;
201
+ }
202
+ }
203
+ }
204
+
205
+ return { r: clamp(r, 0, 255), g: clamp(g, 0, 255), b: clamp(b, 0, 255), a };
206
+ }
207
+
208
+ /** Resolve an OOXML colour element to { r, g, b, a } */
209
+ export function resolveColorElement(colorEl, themeColors) {
210
+ if (!colorEl) return null;
211
+ const ln = colorEl.localName;
212
+
213
+ let rgb = null;
214
+ let a = 1;
215
+
216
+ if (ln === 'srgbClr') {
217
+ const val = colorEl.getAttribute('val') || '';
218
+ if (val.length >= 6) rgb = hexToRgb(val);
219
+ } else if (ln === 'schemeClr') {
220
+ const schemeVal = colorEl.getAttribute('val') || '';
221
+ // Map scheme names to base colours via themeColors
222
+ const key = schemeVal; // e.g. "dk1", "accent1", "tx1", "bg1" …
223
+ const hex = themeColors?.[key];
224
+ if (hex) rgb = hexToRgb(hex);
225
+ else rgb = { r: 0, g: 0, b: 0 }; // fallback black
226
+ } else if (ln === 'prstClr') {
227
+ const prstVal = (colorEl.getAttribute('val') || '').toLowerCase();
228
+ const hex = PRESET_COLORS[prstVal];
229
+ if (hex) rgb = hexToRgb(hex);
230
+ } else if (ln === 'sysClr') {
231
+ // System colour — use lastClr if available, else fallback
232
+ const lastClr = colorEl.getAttribute('lastClr');
233
+ if (lastClr?.length >= 6) rgb = hexToRgb(lastClr);
234
+ else rgb = { r: 0, g: 0, b: 0 };
235
+ } else if (ln === 'hslClr') {
236
+ const h = attrInt(colorEl, 'hue', 0) / 21600000;
237
+ const s = attrInt(colorEl, 'sat', 0) / 100000;
238
+ const l = attrInt(colorEl, 'lum', 0) / 100000;
239
+ rgb = hlsToRgb(h, l, s);
240
+ } else if (ln === 'scRgbClr') {
241
+ // Linear-light (0–100000)
242
+ const r2 = attrInt(colorEl, 'r', 0) / 100000;
243
+ const g2 = attrInt(colorEl, 'g', 0) / 100000;
244
+ const b2 = attrInt(colorEl, 'b', 0) / 100000;
245
+ rgb = {
246
+ r: Math.round(Math.pow(r2, 1/2.2) * 255),
247
+ g: Math.round(Math.pow(g2, 1/2.2) * 255),
248
+ b: Math.round(Math.pow(b2, 1/2.2) * 255),
249
+ };
250
+ }
251
+
252
+ if (!rgb) return null;
253
+
254
+ // Apply any transforms (children of the colour element)
255
+ return applyColorTransforms({ ...rgb, a }, colorEl);
256
+ }
257
+
258
+ /**
259
+ * Return a CSS colour string from { r, g, b, a }.
260
+ * @param {{r:number,g:number,b:number,a?:number}} c
261
+ * @param {number} [alphaOverride] Optional alpha (0–1) that overrides c.a
262
+ */
263
+ export function colorToCss(c, alphaOverride) {
264
+ if (!c) return 'transparent';
265
+ const a = alphaOverride !== undefined ? alphaOverride : (c.a ?? 1);
266
+ return a < 1
267
+ ? `rgba(${c.r},${c.g},${c.b},${a.toFixed(3)})`
268
+ : `rgb(${c.r},${c.g},${c.b})`;
269
+ }
270
+
271
+ /** Find the first recognised colour child element. */
272
+ export function findFirstColorChild(el) {
273
+ if (!el) return null;
274
+ const tags = ['srgbClr','schemeClr','prstClr','sysClr','hslClr','scRgbClr'];
275
+ for (const tag of tags) {
276
+ const child = g1(el, tag);
277
+ if (child) return child;
278
+ }
279
+ return null;
280
+ }
281
+
282
+ /** Convenience: resolve a run's text colour, returning a CSS string or null. */
283
+ export function getRunColor(rPr, themeColors) {
284
+ if (!rPr) return null;
285
+ const solidFill = g1(rPr, 'solidFill');
286
+ if (!solidFill) return null;
287
+ const colorChild = findFirstColorChild(solidFill);
288
+ const c = resolveColorElement(colorChild, themeColors);
289
+ return c ? colorToCss(c) : null;
290
+ }
291
+
292
+ /** Resolve run color with paraDefRPr fallback. */
293
+ export function getRunColorInherited(rPr, paraDefRPr, themeColors) {
294
+ const c1 = getRunColor(rPr, themeColors);
295
+ if (c1) return c1;
296
+ return getRunColor(paraDefRPr, themeColors);
297
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * effects3d.js — OOXML 3D effect renderer.
3
+ *
4
+ * Implements sp3d (shape-level 3D) and scene3d (scene-level) effects
5
+ * using canvas 2D gradient tricks to simulate:
6
+ *
7
+ * • Bevel: top/bottom/left/right gradient edges
8
+ * • Extrusion: depth offset shadow-box
9
+ * • Contour: outline with 3D colour
10
+ * • Camera: perspective warp (affine approximation via ctx.transform)
11
+ * • Lighting: diffuse + specular gradient overlay
12
+ *
13
+ * Usage:
14
+ * const cleanup = apply3DEffects(ctx, spPr, themeColors, x, y, w, h, scale);
15
+ * // ... draw shape path ...
16
+ * // fill the shape
17
+ * draw3DOverlay(ctx, sp3d, scene3d, themeColors, x, y, w, h, scale);
18
+ * cleanup();
19
+ */
20
+
21
+ import { g1, attrInt, attr } from './utils.js';
22
+ import { resolveColorElement, findFirstColorChild, colorToCss } from './colors.js';
23
+
24
+ // ── Helpers ──────────────────────────────────────────────────────────────────
25
+
26
+ /** Parse an EMU-valued attribute to canvas pixels. */
27
+ function emu(el, name, def = 0, scale = 1) {
28
+ const v = el ? parseInt(el.getAttribute(name) || def, 10) : def;
29
+ return v * scale;
30
+ }
31
+
32
+ /** Lum mod: darken/lighten a CSS hex colour by a factor (0–2). */
33
+ function lumMod(hex, factor) {
34
+ if (!hex || !hex.startsWith('#')) return hex;
35
+ let r = parseInt(hex.slice(1, 3), 16);
36
+ let g = parseInt(hex.slice(3, 5), 16);
37
+ let b = parseInt(hex.slice(5, 7), 16);
38
+ r = Math.round(Math.min(255, r * factor));
39
+ g = Math.round(Math.min(255, g * factor));
40
+ b = Math.round(Math.min(255, b * factor));
41
+ return `rgb(${r},${g},${b})`;
42
+ }
43
+
44
+ function hexToCss(hex) {
45
+ return hex ? '#' + hex : null;
46
+ }
47
+
48
+ /** Read a colour element group from sp3d sub-elements like bevelT / extrusionClr. */
49
+ function read3dColor(parentEl, themeColors) {
50
+ if (!parentEl) return null;
51
+ const colorChild = findFirstColorChild(parentEl);
52
+ if (!colorChild) return null;
53
+ const c = resolveColorElement(colorChild, themeColors);
54
+ return c ? colorToCss(c) : null;
55
+ }
56
+
57
+ // ── Bevel ─────────────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Draw a bevel effect (raised or inset edges) over a filled shape.
61
+ *
62
+ * Reads: sp3d > bevelT, bevelB (top and bottom bevel)
63
+ * Simulated with thin gradient strips along each edge.
64
+ */
65
+ function drawBevel(ctx, sp3d, x, y, w, h, scale) {
66
+ const bevelT = g1(sp3d, 'bevelT');
67
+ const bevelB = g1(sp3d, 'bevelB');
68
+ const bevel = bevelT || bevelB;
69
+ if (!bevel) return;
70
+
71
+ const bw = Math.max(2 * scale, emu(bevel, 'w', 76200, scale));
72
+ const bh = Math.max(2 * scale, emu(bevel, 'h', 76200, scale));
73
+ const prst = attr(bevel, 'prst', 'circle');
74
+ const inset = prst.toLowerCase().includes('in');
75
+
76
+ // Clamp bevel to half shape size
77
+ const bwx = Math.min(bw, w * 0.3);
78
+ const bhy = Math.min(bh, h * 0.3);
79
+
80
+ const lightColor = inset ? 'rgba(0,0,0,0.18)' : 'rgba(255,255,255,0.35)';
81
+ const shadowColor = inset ? 'rgba(255,255,255,0.20)' : 'rgba(0,0,0,0.20)';
82
+
83
+ // Top edge
84
+ const gTop = ctx.createLinearGradient(x, y, x, y + bhy);
85
+ gTop.addColorStop(0, lightColor);
86
+ gTop.addColorStop(1, 'rgba(255,255,255,0)');
87
+ ctx.save();
88
+ ctx.fillStyle = gTop;
89
+ ctx.fillRect(x, y, w, bhy);
90
+
91
+ // Bottom edge
92
+ const gBot = ctx.createLinearGradient(x, y + h - bhy, x, y + h);
93
+ gBot.addColorStop(0, 'rgba(0,0,0,0)');
94
+ gBot.addColorStop(1, shadowColor);
95
+ ctx.fillStyle = gBot;
96
+ ctx.fillRect(x, y + h - bhy, w, bhy);
97
+
98
+ // Left edge
99
+ const gLeft = ctx.createLinearGradient(x, y, x + bwx, y);
100
+ gLeft.addColorStop(0, lightColor);
101
+ gLeft.addColorStop(1, 'rgba(255,255,255,0)');
102
+ ctx.fillStyle = gLeft;
103
+ ctx.fillRect(x, y, bwx, h);
104
+
105
+ // Right edge
106
+ const gRight = ctx.createLinearGradient(x + w - bwx, y, x + w, y);
107
+ gRight.addColorStop(0, 'rgba(0,0,0,0)');
108
+ gRight.addColorStop(1, shadowColor);
109
+ ctx.fillStyle = gRight;
110
+ ctx.fillRect(x + w - bwx, y, bwx, h);
111
+
112
+ ctx.restore();
113
+ }
114
+
115
+ // ── Extrusion ─────────────────────────────────────────────────────────────────
116
+
117
+ /**
118
+ * Draw a 3D extrusion effect — a solid depth offset below/behind the shape.
119
+ *
120
+ * Reads: sp3d extrusionH, extrusionClr
121
+ */
122
+ function drawExtrusion(ctx, sp3d, themeColors, x, y, w, h, scale) {
123
+ const extH = emu(sp3d, 'extrusionH', 0, scale);
124
+ if (extH < 1 * scale) return;
125
+
126
+ const clrEl = g1(sp3d, 'extrusionClr') || g1(sp3d, 'contourClr');
127
+ const color = read3dColor(clrEl, themeColors) || 'rgba(0,0,0,0.3)';
128
+
129
+ // Depth offset — project slightly down-right
130
+ const depth = Math.min(extH / 914400 * 72 * scale, Math.min(w, h) * 0.15);
131
+ const dx = depth * 0.7;
132
+ const dy = depth * 0.7;
133
+
134
+ ctx.save();
135
+ ctx.fillStyle = color;
136
+ // Right face
137
+ ctx.beginPath();
138
+ ctx.moveTo(x + w, y);
139
+ ctx.lineTo(x + w + dx, y + dy);
140
+ ctx.lineTo(x + w + dx, y + h + dy);
141
+ ctx.lineTo(x + w, y + h);
142
+ ctx.closePath();
143
+ ctx.fill();
144
+ // Bottom face
145
+ ctx.beginPath();
146
+ ctx.moveTo(x, y + h);
147
+ ctx.lineTo(x + dx, y + h + dy);
148
+ ctx.lineTo(x + w + dx, y + h + dy);
149
+ ctx.lineTo(x + w, y + h);
150
+ ctx.closePath();
151
+ ctx.fill();
152
+ ctx.restore();
153
+ }
154
+
155
+ // ── Contour ───────────────────────────────────────────────────────────────────
156
+
157
+ function drawContour(ctx, sp3d, themeColors, x, y, w, h, scale) {
158
+ const contourW = emu(sp3d, 'contourW', 0, scale);
159
+ if (contourW < 0.5) return;
160
+ const clrEl = g1(sp3d, 'contourClr');
161
+ const color = read3dColor(clrEl, themeColors) || '#888888';
162
+ const cw = Math.max(0.5, contourW / 914400 * 72 * scale);
163
+
164
+ ctx.save();
165
+ ctx.strokeStyle = color;
166
+ ctx.lineWidth = cw;
167
+ ctx.strokeRect(x, y, w, h);
168
+ ctx.restore();
169
+ }
170
+
171
+ // ── Camera / scene3d ──────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * Apply a camera perspective transform to the context.
175
+ *
176
+ * scene3d > camera: reads rot (latitude, longitude) and fov
177
+ * Approximated as a simple skew/scale affine transform.
178
+ *
179
+ * Returns a cleanup function that restores the context.
180
+ */
181
+ export function applyCamera(ctx, scene3d, x, y, w, h) {
182
+ if (!scene3d) return () => {};
183
+
184
+ const camera = g1(scene3d, 'camera');
185
+ if (!camera) return () => {};
186
+
187
+ const prst = attr(camera, 'prst', 'orthographicFront');
188
+ const rot = g1(camera, 'rot');
189
+
190
+ // Only apply non-trivial camera presets
191
+ const isOrtho = prst.toLowerCase().includes('orthographic');
192
+ if (isOrtho && !rot) return () => {};
193
+
194
+ // lat/lon in 60000ths of a degree
195
+ const lat = rot ? attrInt(rot, 'lat', 0) / 60000 : 0;
196
+ const lon = rot ? attrInt(rot, 'lon', 0) / 60000 : 0;
197
+ const rev = rot ? attrInt(rot, 'rev', 0) / 60000 : 0;
198
+
199
+ // Convert to radians for gentle perspective skew
200
+ const latR = (lat * Math.PI) / 180;
201
+ const lonR = (lon * Math.PI) / 180;
202
+
203
+ // Max skew ±15% of dimension to keep it legible
204
+ const skewX = Math.sin(lonR) * 0.15;
205
+ const skewY = Math.sin(latR) * 0.08;
206
+
207
+ ctx.save();
208
+ ctx.transform(
209
+ 1, skewY,
210
+ skewX, 1,
211
+ x * -skewX, y * -skewY // pivot at shape origin
212
+ );
213
+
214
+ return () => ctx.restore();
215
+ }
216
+
217
+ // ── Lighting overlay ──────────────────────────────────────────────────────────
218
+
219
+ /**
220
+ * Apply a lighting/specular overlay on top of a filled shape.
221
+ *
222
+ * scene3d > lightRig: reads dir, rig
223
+ * Approximated as a directional radial gradient.
224
+ */
225
+ function drawLighting(ctx, scene3d, x, y, w, h) {
226
+ if (!scene3d) return;
227
+
228
+ const lightRig = g1(scene3d, 'lightRig');
229
+ if (!lightRig) return;
230
+
231
+ const dir = attr(lightRig, 'dir', 't');
232
+ const rig = attr(lightRig, 'rig', 'balanced');
233
+
234
+ // Map light direction to gradient origin
235
+ const dirMap = {
236
+ t: { gx: x + w / 2, gy: y },
237
+ b: { gx: x + w / 2, gy: y + h },
238
+ l: { gx: x, gy: y + h / 2 },
239
+ r: { gx: x + w, gy: y + h / 2 },
240
+ tl: { gx: x, gy: y },
241
+ tr: { gx: x + w, gy: y },
242
+ bl: { gx: x, gy: y + h },
243
+ br: { gx: x + w, gy: y + h },
244
+ };
245
+ const { gx, gy } = dirMap[dir] || dirMap.t;
246
+
247
+ const intensity = rig === 'flat' ? 0.08
248
+ : rig === 'balanced' ? 0.14
249
+ : rig === 'sunrise' ? 0.20
250
+ : rig === 'harsh' ? 0.28
251
+ : 0.12;
252
+
253
+ const radius = Math.sqrt(w * w + h * h);
254
+ const grad = ctx.createRadialGradient(gx, gy, 0, gx, gy, radius);
255
+ grad.addColorStop(0, `rgba(255,255,255,${intensity})`);
256
+ grad.addColorStop(0.5, 'rgba(255,255,255,0)');
257
+ grad.addColorStop(1, `rgba(0,0,0,${intensity * 0.5})`);
258
+
259
+ ctx.save();
260
+ ctx.fillStyle = grad;
261
+ ctx.fillRect(x, y, w, h);
262
+ ctx.restore();
263
+ }
264
+
265
+ // ── Main API ─────────────────────────────────────────────────────────────────
266
+
267
+ /**
268
+ * Apply 3D extrusion *before* drawing the shape (so it appears behind).
269
+ *
270
+ * @param {CanvasRenderingContext2D} ctx
271
+ * @param {Element} spPr — shape properties element (or null)
272
+ * @param {object} themeColors
273
+ * @param {number} x, y, w, h — shape bounding box in canvas pixels
274
+ * @param {number} scale
275
+ * @returns {{ applyCamera: function, overlay: function }} — call overlay() after filling the shape
276
+ */
277
+ export function setup3D(ctx, spPr, themeColors, x, y, w, h, scale) {
278
+ if (!spPr) return { applyCamera: () => () => {}, overlay: () => {} };
279
+
280
+ const sp3d = g1(spPr, 'sp3d') || g1(spPr, 'scene3d')?.parentNode && null;
281
+ const scene3d = g1(spPr, 'scene3d');
282
+
283
+ // Draw extrusion first (behind the shape)
284
+ if (sp3d) {
285
+ drawExtrusion(ctx, sp3d, themeColors, x, y, w, h, scale);
286
+ }
287
+
288
+ const cleanupCamera = applyCamera(ctx, scene3d, x, y, w, h);
289
+
290
+ return {
291
+ /** Call this *after* filling the shape to draw bevel + lighting on top. */
292
+ overlay() {
293
+ if (sp3d) {
294
+ drawBevel(ctx, sp3d, x, y, w, h, scale);
295
+ drawContour(ctx, sp3d, themeColors, x, y, w, h, scale);
296
+ }
297
+ if (scene3d) {
298
+ drawLighting(ctx, scene3d, x, y, w, h);
299
+ }
300
+ },
301
+ /** Call this when done to restore canvas state. */
302
+ cleanup: cleanupCamera,
303
+ };
304
+ }
305
+
306
+ /**
307
+ * Simpler function: returns true if spPr contains any 3D directives.
308
+ */
309
+ export function has3D(spPr) {
310
+ if (!spPr) return false;
311
+ return !!(g1(spPr, 'sp3d') || g1(spPr, 'scene3d'));
312
+ }