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/render.js ADDED
@@ -0,0 +1,1964 @@
1
+ /**
2
+ * render.js — Core OOXML rendering: fills, outlines, effects, images,
3
+ * text, shapes, tables, groups, placeholders, background, and the
4
+ * main renderSpTree / renderSlide pipeline.
5
+ *
6
+ * All rendering is done onto a Canvas 2D context.
7
+ */
8
+
9
+ import { g1, gtn, attr, attrInt, EMU_PER_PT } from './utils.js';
10
+ import { resolveColorElement, findFirstColorChild, colorToCss, getRunColorInherited } from './colors.js';
11
+ import { drawPresetGeom } from './shapes.js';
12
+ import { buildFontInherited } from './fonts.js';
13
+ import { renderChart } from './charts.js';
14
+ import { setup3D, has3D } from './effects3d.js';
15
+
16
+ // ═══════════════════════════════════════════════════════════════════════════════
17
+ // FILL HELPERS
18
+ // ═══════════════════════════════════════════════════════════════════════════════
19
+
20
+ // Apply a fill to the current path on ctx (assumes path is already set)
21
+ export async function applyFill(ctx, fillEl, x, y, w, h, scale, themeColors, imageCache) {
22
+ if (!fillEl) return false;
23
+ const name = fillEl.localName;
24
+
25
+ if (name === 'noFill') {
26
+ return false; // no fill
27
+ }
28
+
29
+ if (name === 'solidFill') {
30
+ const colorChild = findFirstColorChild(fillEl);
31
+ const c = resolveColorElement(colorChild, themeColors);
32
+ if (c) {
33
+ ctx.fillStyle = colorToCss(c);
34
+ ctx.fill();
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+
40
+ if (name === 'gradFill') {
41
+ const gsLst = g1(fillEl, 'gsLst');
42
+ if (!gsLst) return false;
43
+ const stops = gtn(gsLst, 'gs').map(gs => {
44
+ const pos = attrInt(gs, 'pos', 0) / 100000;
45
+ const colorChild = findFirstColorChild(gs);
46
+ const c = resolveColorElement(colorChild, themeColors);
47
+ return { pos, color: c };
48
+ }).sort((a, b) => a.pos - b.pos);
49
+
50
+ if (stops.length < 2) return false;
51
+
52
+ const linEl = g1(fillEl, 'lin');
53
+ const pathEl = g1(fillEl, 'path');
54
+
55
+ // cx/cy for this fill region (not the global shape centre — must be defined here)
56
+ const gcx = x + w / 2, gcy = y + h / 2;
57
+
58
+ let gradient;
59
+ if (linEl || (!linEl && !pathEl)) {
60
+ // Linear gradient
61
+ // OOXML ang: 0 = top→bottom (north), increases clockwise, units = 60000ths of degree
62
+ const angRaw = attrInt(linEl, 'ang', 0);
63
+ const angDeg = angRaw / 60000;
64
+ // Convert: OOXML 0° = pointing up (north on canvas = -PI/2), clockwise
65
+ const angRad = (angDeg - 90) * Math.PI / 180;
66
+ const cosA = Math.cos(angRad);
67
+ const sinA = Math.sin(angRad);
68
+ // Length of gradient line that covers the full bounding box
69
+ const len = Math.abs(w * cosA) + Math.abs(h * sinA);
70
+ const x1 = gcx - len / 2 * cosA;
71
+ const y1 = gcy - len / 2 * sinA;
72
+ const x2 = gcx + len / 2 * cosA;
73
+ const y2 = gcy + len / 2 * sinA;
74
+ gradient = ctx.createLinearGradient(x1, y1, x2, y2);
75
+ } else {
76
+ // Radial / path gradient
77
+ const fillToRect = g1(pathEl, 'fillToRect');
78
+ // focusL/T/R/B: percentage offsets of the focus rectangle from the shape's edges
79
+ const focusL = attrInt(fillToRect, 'l', 50000) / 100000;
80
+ const focusT = attrInt(fillToRect, 't', 50000) / 100000;
81
+ const focusR = attrInt(fillToRect, 'r', 50000) / 100000;
82
+ const focusB = attrInt(fillToRect, 'b', 50000) / 100000;
83
+ // Focus point = centre of the focus rectangle
84
+ const fx = x + w * (focusL + (1 - focusL - focusR) / 2);
85
+ const fy = y + h * (focusT + (1 - focusT - focusB) / 2);
86
+ // Outer radius: enough to cover corners
87
+ const outerR = Math.sqrt(w * w + h * h) / 2;
88
+ gradient = ctx.createRadialGradient(fx, fy, 0, gcx, gcy, outerR);
89
+ }
90
+
91
+ for (const stop of stops) {
92
+ if (stop.color) {
93
+ gradient.addColorStop(stop.pos, colorToCss(stop.color));
94
+ }
95
+ }
96
+ ctx.fillStyle = gradient;
97
+ ctx.fill();
98
+ return true;
99
+ }
100
+
101
+ if (name === 'blipFill') {
102
+ const blip = g1(fillEl, 'blip');
103
+ const rEmbed = blip ? (blip.getAttribute('r:embed') || blip.getAttribute('embed')) : null;
104
+ if (rEmbed && imageCache && imageCache[rEmbed]) {
105
+ const img = imageCache[rEmbed];
106
+ const stretch = g1(fillEl, 'stretch');
107
+ const fillRect = stretch ? g1(stretch, 'fillRect') : null;
108
+ let ix = x, iy = y, iw = w, ih = h;
109
+ if (fillRect) {
110
+ const l = attrInt(fillRect, 'l', 0) / 100000;
111
+ const t = attrInt(fillRect, 't', 0) / 100000;
112
+ const r = attrInt(fillRect, 'r', 0) / 100000;
113
+ const b = attrInt(fillRect, 'b', 0) / 100000;
114
+ ix = x + w * l;
115
+ iy = y + h * t;
116
+ iw = w - w * l - w * r;
117
+ ih = h - h * t - h * b;
118
+ }
119
+ // Check for tile
120
+ const tile = g1(fillEl, 'tile');
121
+ if (tile) {
122
+ const pattern = ctx.createPattern(img, 'repeat');
123
+ if (pattern) {
124
+ ctx.fillStyle = pattern;
125
+ ctx.fill();
126
+ return true;
127
+ }
128
+ }
129
+ // Check for alpha/transparency on the blip element
130
+ const alphaMod = g1(fillEl, 'alphaModFix') || g1(blip, 'alphaModFix');
131
+ const alphaVal = alphaMod ? (attrInt(alphaMod, 'amt', 100000) / 100000) : 1;
132
+
133
+ ctx.save();
134
+ ctx.clip();
135
+ if (alphaVal < 1) ctx.globalAlpha = alphaVal;
136
+ ctx.drawImage(img, ix, iy, iw, ih);
137
+ ctx.restore();
138
+ return true;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ if (name === 'pattFill') {
144
+ // Resolve fg and bg colours
145
+ const fgClrEl = g1(fillEl, 'fgClr');
146
+ const bgClrEl = g1(fillEl, 'bgClr');
147
+ const fgC = fgClrEl ? resolveColorElement(findFirstColorChild(fgClrEl), themeColors) : { r:0, g:0, b:0, a:1 };
148
+ const bgC = bgClrEl ? resolveColorElement(findFirstColorChild(bgClrEl), themeColors) : { r:255,g:255,b:255,a:1 };
149
+ const fgCss = colorToCss(fgC);
150
+ const bgCss = colorToCss(bgC);
151
+ const prst = attr(fillEl, 'prst', 'dotGrid');
152
+
153
+ // Build a 4×4 or 8×8 tile on an offscreen canvas
154
+ // Create offscreen tile for pattern — falls back to solid colour in non-browser envs
155
+ let tc = null, tile = null;
156
+ try {
157
+ tile = typeof OffscreenCanvas !== 'undefined'
158
+ ? new OffscreenCanvas(8, 8)
159
+ : document.createElement('canvas');
160
+ const N = 8;
161
+ tile.width = N; tile.height = N;
162
+ tc = tile.getContext('2d');
163
+ } catch (_) {}
164
+ const N = 8;
165
+ if (!tc) {
166
+ // No canvas available — fall back to solid foreground
167
+ ctx.fillStyle = colorToCss(fgC);
168
+ ctx.fill();
169
+ return true;
170
+ }
171
+
172
+ // Fill background
173
+ tc.fillStyle = bgCss;
174
+ tc.fillRect(0, 0, N, N);
175
+
176
+ // Draw foreground pattern
177
+ tc.fillStyle = fgCss;
178
+ switch (prst) {
179
+ case 'smGrid':
180
+ case 'dotGrid':
181
+ case 'dotDmnd':
182
+ for (let i = 0; i < N; i += 2) {
183
+ for (let j = 0; j < N; j += 2) tc.fillRect(i, j, 1, 1);
184
+ }
185
+ break;
186
+ case 'lgGrid':
187
+ case 'cross':
188
+ tc.fillRect(0, 0, N, 1); tc.fillRect(0, 0, 1, N); // top and left lines
189
+ break;
190
+ case 'diagBd':
191
+ case 'fwdDiag':
192
+ for (let d = 0; d < N * 2; d++) tc.fillRect(d % N, Math.floor(d / N) * 2, 1, 1);
193
+ break;
194
+ case 'bkDiag':
195
+ case 'ltDnDiag':
196
+ for (let d = 0; d < N * 2; d++) tc.fillRect(N - 1 - (d % N), Math.floor(d / N) * 2, 1, 1);
197
+ break;
198
+ case 'horzBrick':
199
+ case 'horz':
200
+ tc.fillRect(0, N / 2, N, 1);
201
+ break;
202
+ case 'vert':
203
+ case 'vertBrick':
204
+ tc.fillRect(N / 2, 0, 1, N);
205
+ break;
206
+ case 'smCheck':
207
+ case 'lgCheck':
208
+ for (let r = 0; r < N; r++) {
209
+ for (let c2 = 0; c2 < N; c2++) {
210
+ if ((r + c2) % 2 === 0) tc.fillRect(c2, r, 1, 1);
211
+ }
212
+ }
213
+ break;
214
+ default:
215
+ // Generic: draw a sparse dot grid
216
+ for (let i = 0; i < N; i += 4) tc.fillRect(i, i, 2, 2);
217
+ }
218
+
219
+ const pattern = ctx.createPattern(tile, 'repeat');
220
+ if (pattern) {
221
+ ctx.fillStyle = pattern;
222
+ ctx.fill();
223
+ return true;
224
+ }
225
+ // Fallback: solid foreground colour at reduced opacity
226
+ ctx.fillStyle = colorToCss(fgC, (fgC.a ?? 1) * 0.4);
227
+ ctx.fill();
228
+ return true;
229
+ }
230
+
231
+ if (name === 'grpFill') {
232
+ // Group fill - inherits from group, just fill transparent for now
233
+ return false;
234
+ }
235
+
236
+ return false;
237
+ }
238
+ // ═══════════════════════════════════════════════════════════════════════════════
239
+ // RELATIONSHIP RESOLUTION
240
+ // ═══════════════════════════════════════════════════════════════════════════════
241
+
242
+ export async function getRels(files, partPath) {
243
+ // ppt/slides/slide1.xml → ppt/slides/_rels/slide1.xml.rels
244
+ const parts = partPath.split('/');
245
+ const filename = parts.pop();
246
+ const relsPath = [...parts, '_rels', filename + '.rels'].join('/');
247
+ const rawData = files[relsPath];
248
+ if (!rawData) return {};
249
+ const content = new TextDecoder().decode(rawData);
250
+ const doc = parseXml(content);
251
+ const rels = {};
252
+ for (const rel of Array.from(doc.getElementsByTagName('Relationship'))) {
253
+ const id = rel.getAttribute('Id');
254
+ const target = rel.getAttribute('Target');
255
+ const type = rel.getAttribute('Type') || '';
256
+ const mode = rel.getAttribute('TargetMode') || 'Internal';
257
+ let fullPath = target;
258
+ if (mode !== 'External') {
259
+ if (target.startsWith('/')) {
260
+ fullPath = target.slice(1);
261
+ } else {
262
+ // Resolve relative to the directory of partPath
263
+ const baseParts = partPath.split('/');
264
+ baseParts.pop();
265
+ const targetParts = target.split('/');
266
+ for (const part of targetParts) {
267
+ if (part === '..') baseParts.pop();
268
+ else if (part !== '.') baseParts.push(part);
269
+ }
270
+ fullPath = baseParts.join('/');
271
+ }
272
+ }
273
+ rels[id] = { target, fullPath, type, external: mode === 'External' };
274
+ }
275
+ return rels;
276
+ }
277
+
278
+ // ═══════════════════════════════════════════════════════════════════════════════
279
+ // IMAGE CACHE
280
+ // ═══════════════════════════════════════════════════════════════════════════════
281
+
282
+ export async function loadImages(files, rels) {
283
+ const cache = {};
284
+ const imgExts = new Set(['png','jpg','jpeg','gif','webp','bmp','tiff','tif','svg']);
285
+ for (const [rId, rel] of Object.entries(rels)) {
286
+ if (rel.external) continue;
287
+ const ext = rel.fullPath.split('.').pop().toLowerCase();
288
+ if (!imgExts.has(ext)) continue;
289
+ try {
290
+ const data = files[rel.fullPath];
291
+ if (!data) continue;
292
+ const mimeMap = {
293
+ png:'image/png',jpg:'image/jpeg',jpeg:'image/jpeg',
294
+ gif:'image/gif',webp:'image/webp',bmp:'image/bmp',
295
+ tiff:'image/tiff',tif:'image/tiff',svg:'image/svg+xml'
296
+ };
297
+ const mime = mimeMap[ext] || 'image/png';
298
+ const blob = new Blob([data], { type: mime });
299
+ const url = URL.createObjectURL(blob);
300
+ const img = await new Promise((resolve, reject) => {
301
+ const image = new Image();
302
+ image.onload = () => resolve(image);
303
+ image.onerror = () => resolve(null);
304
+ image.src = url;
305
+ });
306
+ cache[rId] = img;
307
+ } catch(e) {}
308
+ }
309
+ return cache;
310
+ }
311
+
312
+ // ═══════════════════════════════════════════════════════════════════════════════
313
+ // TEXT RENDERING
314
+ // ═══════════════════════════════════════════════════════════════════════════════
315
+
316
+ // Compute line height from lnSpc element.
317
+ // baseSzPx = font size in canvas pixels; scale = EMU → px conversion factor.
318
+ export function computeLineHeight(lnSpcEl, baseSzPx, scale) {
319
+ if (!lnSpcEl) return baseSzPx * 1.2;
320
+ const spcPct = g1(lnSpcEl, 'spcPct');
321
+ const spcPts = g1(lnSpcEl, 'spcPts');
322
+ if (spcPct) {
323
+ return baseSzPx * (attrInt(spcPct, 'val', 100000) / 100000);
324
+ } else if (spcPts) {
325
+ // val is in 100ths of a point. 1 pt = 12700 EMU.
326
+ // px = val/100 * 12700 * scale
327
+ const val = attrInt(spcPts, 'val', 0);
328
+ return (val / 100) * 12700 * (scale || 1);
329
+ }
330
+ return baseSzPx * 1.2;
331
+ }
332
+ // Word-wrap text into lines
333
+ /**
334
+ * Wrap text to fit within maxWidth pixels given the current ctx.font.
335
+ * Handles:
336
+ * - Long words (breaks mid-word if no spaces)
337
+ * - CJK characters (can wrap between any two characters)
338
+ * - Trailing spaces preserved per line
339
+ */
340
+ export function wrapText(ctx, text, maxWidth) {
341
+ if (maxWidth <= 0 || !text) return text ? [text] : [];
342
+
343
+ const CJK_RE = /[\u3000-\u9fff\uac00-\ud7af\uf900-\ufaff\ufe30-\ufeff]/;
344
+ const lines = [];
345
+ let line = '';
346
+
347
+ // CJK text: every character is a potential break point, so skip word-splitting
348
+ if (CJK_RE.test(text)) {
349
+ for (const ch of text) {
350
+ const test = line + ch;
351
+ if (ctx.measureText(test).width <= maxWidth) {
352
+ line = test;
353
+ } else {
354
+ if (line) lines.push(line);
355
+ line = ch;
356
+ }
357
+ }
358
+ if (line) lines.push(line);
359
+ return lines.length ? lines : [''];
360
+ }
361
+
362
+ const words = text.split(/(\s+)/); // keep whitespace tokens
363
+ for (const token of words) {
364
+ const test = line + token;
365
+ if (ctx.measureText(test).width <= maxWidth) {
366
+ line = test;
367
+ } else if (!line.trim()) {
368
+ // Single long token wider than maxWidth — break by character
369
+ for (const ch of token) {
370
+ const t2 = line + ch;
371
+ if (ctx.measureText(t2).width > maxWidth && line) {
372
+ lines.push(line);
373
+ line = ch;
374
+ } else {
375
+ line = t2;
376
+ }
377
+ }
378
+ } else {
379
+ lines.push(line.trimEnd());
380
+ line = token.trimStart(); // drop leading space at line break
381
+ }
382
+ }
383
+ if (line.trim()) lines.push(line);
384
+ return lines.length ? lines : [''];
385
+ }
386
+
387
+ // Render text body to canvas
388
+ // ── BULLET / LIST HELPERS ────────────────────────────────────────────────────
389
+
390
+ /**
391
+ * Parse bullet properties from a paragraph's <a:pPr> element.
392
+ * Returns null if no bullet, or a bullet descriptor object.
393
+ */
394
+ function parseBullet(pPr, defRPr, themeColors, themeData) {
395
+ if (!pPr) return null;
396
+
397
+ // Explicit no-bullet
398
+ if (g1(pPr, 'buNone')) return null;
399
+
400
+ const buChar = g1(pPr, 'buChar');
401
+ const buAutoNum = g1(pPr, 'buAutoNum');
402
+
403
+ // No bullet element = no bullet (unless inherited, which we skip for now)
404
+ if (!buChar && !buAutoNum) return null;
405
+
406
+ // Bullet colour
407
+ let color = null;
408
+ const buClr = g1(pPr, 'buClr');
409
+ if (buClr) {
410
+ const colorChild = findFirstColorChild(buClr);
411
+ color = resolveColorElement(colorChild, themeColors);
412
+ }
413
+
414
+ // Bullet size (percentage of run font size)
415
+ const buSzPct = g1(pPr, 'buSzPct');
416
+ const buSzPts = g1(pPr, 'buSzPts');
417
+ let sizePct = 1.0;
418
+ if (buSzPct) sizePct = attrInt(buSzPct, 'val', 100000) / 100000;
419
+ // buSzPts is an absolute size — store as pts for later conversion
420
+ const sizePts = buSzPts ? attrInt(buSzPts, 'val', 0) / 100 : null;
421
+
422
+ // Bullet font
423
+ let fontFamily = null;
424
+ const buFont = g1(pPr, 'buFont');
425
+ if (buFont) {
426
+ const tf = buFont.getAttribute('typeface');
427
+ if (tf) fontFamily = tf;
428
+ }
429
+
430
+ if (buChar) {
431
+ return {
432
+ type: 'char',
433
+ char: buChar.getAttribute('char') || '•',
434
+ color,
435
+ sizePct,
436
+ sizePts,
437
+ fontFamily,
438
+ };
439
+ }
440
+
441
+ if (buAutoNum) {
442
+ return {
443
+ type: 'autoNum',
444
+ numType: buAutoNum.getAttribute('type') || 'arabicPeriod',
445
+ startAt: attrInt(buAutoNum, 'startAt', 1),
446
+ color,
447
+ sizePct,
448
+ sizePts,
449
+ fontFamily,
450
+ };
451
+ }
452
+
453
+ return null;
454
+ }
455
+
456
+ /** Auto-number type → formatted string. */
457
+ function formatAutoNum(type, n) {
458
+ switch (type) {
459
+ case 'arabicPeriod': return n + '.';
460
+ case 'arabicParenR': return n + ')';
461
+ case 'arabicParenBoth': return '(' + n + ')';
462
+ case 'romanLcPeriod': return toRoman(n).toLowerCase() + '.';
463
+ case 'romanUcPeriod': return toRoman(n) + '.';
464
+ case 'alphaLcParenR': return String.fromCharCode(96 + n) + ')';
465
+ case 'alphaUcParenR': return String.fromCharCode(64 + n) + ')';
466
+ case 'alphaLcPeriod': return String.fromCharCode(96 + n) + '.';
467
+ case 'alphaUcPeriod': return String.fromCharCode(64 + n) + '.';
468
+ default: return n + '.';
469
+ }
470
+ }
471
+
472
+ function toRoman(n) {
473
+ const vals = [1000,900,500,400,100,90,50,40,10,9,5,4,1];
474
+ const syms = ['M','CM','D','CD','C','XC','L','XL','X','IX','V','IV','I'];
475
+ let result = '';
476
+ for (let i = 0; i < vals.length; i++) {
477
+ while (n >= vals[i]) { result += syms[i]; n -= vals[i]; }
478
+ }
479
+ return result;
480
+ }
481
+
482
+ /**
483
+ * Draw a bullet marker at the given position.
484
+ * autoNumCounters: shared map from (numType+startAt) → current count
485
+ */
486
+ function drawBullet(ctx, bullet, x, baseline, autoNumCounters) {
487
+ if (!bullet) return;
488
+
489
+ // Derive the font size from the current ctx.font (set by the line's first run)
490
+ // We read szPx from ctx.font using a rough parse
491
+ const fontMatch = ctx.font.match(/(d+(?:.d+)?)px/);
492
+ const baseSzPx = fontMatch ? parseFloat(fontMatch[1]) : 16;
493
+
494
+ const szPx = bullet.sizePts != null
495
+ ? bullet.sizePts * (baseSzPx / 12) // rough approximation
496
+ : baseSzPx * bullet.sizePct;
497
+
498
+ // Save canvas state so bullet doesn't pollute run rendering
499
+ ctx.save();
500
+
501
+ // Bullet color (falls back to current fillStyle)
502
+ if (bullet.color) {
503
+ ctx.fillStyle = colorToCss(bullet.color);
504
+ ctx.strokeStyle = ctx.fillStyle;
505
+ }
506
+
507
+ // Bullet font
508
+ const family = bullet.fontFamily ? '"' + bullet.fontFamily + '", sans-serif' : ctx.font.split(/\d+px\s+/)[1] || 'sans-serif';
509
+ ctx.font = szPx + 'px ' + family;
510
+
511
+ if (bullet.type === 'char') {
512
+ ctx.fillText(bullet.char, x, baseline);
513
+ } else if (bullet.type === 'autoNum') {
514
+ const key = bullet.numType + ':' + bullet.startAt;
515
+ if (autoNumCounters[key] === undefined) autoNumCounters[key] = bullet.startAt;
516
+ const label = formatAutoNum(bullet.numType, autoNumCounters[key]);
517
+ autoNumCounters[key]++;
518
+ ctx.fillText(label, x, baseline);
519
+ }
520
+
521
+ ctx.restore();
522
+ }
523
+
524
+ export async function renderTextBody(ctx, txBody, bx, by, bw, bh, scale, themeColors, themeData, defaultFontSz = 1800) {
525
+ if (!txBody) return;
526
+
527
+ const bodyPr = g1(txBody, 'bodyPr');
528
+ const anchor = attr(bodyPr, 'anchor', 't'); // t, ctr, b
529
+ const wrap = attr(bodyPr, 'wrap', 'square');
530
+ const vert = attr(bodyPr, 'vert', 'horz'); // horz, vert, vert270, eaVert
531
+
532
+ // Text insets (EMU) - OOXML defaults: l=91440, t=45720, r=91440, b=45720
533
+ const lIns = attrInt(bodyPr, 'lIns', 91440) * scale;
534
+ const tIns = attrInt(bodyPr, 'tIns', 45720) * scale;
535
+ const rIns = attrInt(bodyPr, 'rIns', 91440) * scale;
536
+ const bIns = attrInt(bodyPr, 'bIns', 45720) * scale;
537
+
538
+ const tx = bx + lIns;
539
+ const ty = by + tIns;
540
+ const tw = bw - lIns - rIns;
541
+ const th = bh - tIns - bIns;
542
+
543
+ const doWrap = wrap !== 'none';
544
+ const isVert = vert === 'vert' || vert === 'vert270' || vert === 'eaVert';
545
+
546
+ // For vertical text, apply canvas rotation up-front and swap box dimensions
547
+ // so the layout/wrap pass uses the correct axis.
548
+ if (isVert) {
549
+ ctx.save();
550
+ if (vert === 'vert270') {
551
+ ctx.translate(bx + bw, by);
552
+ ctx.rotate(Math.PI / 2);
553
+ } else {
554
+ ctx.translate(bx, by + bh);
555
+ ctx.rotate(-Math.PI / 2);
556
+ }
557
+ // After rotation, height runs along x-axis and width along y-axis — swap them
558
+ // so wrap/layout work in the rotated coordinate space.
559
+ // bx, by, bw, bh are now mapped: new bx=0-relative, bw=original bh, bh=original bw
560
+ }
561
+
562
+ // normAutoFit: auto-shrink text to fit. fontScale attr stores the computed scale (0-100000).
563
+ // If no fontScale attr is present, we attempt our own shrink after layout (see second pass).
564
+ const normAutoFit = g1(bodyPr, 'normAutoFit') || g1(txBody, 'normAutoFit');
565
+ const spAutoFit = g1(bodyPr, 'spAutoFit') || g1(txBody, 'spAutoFit');
566
+ // spAutoFit: box grows to fit text (we can't resize the canvas clip region,
567
+ // but we disable clipping so text is at least visible)
568
+ const explicitFontScale = normAutoFit ? normAutoFit.getAttribute('fontScale') : null;
569
+ let fontScaleAttr = explicitFontScale ? parseInt(explicitFontScale, 10) / 100000 : 1;
570
+
571
+ // lstStyle default run properties (lowest priority baseline)
572
+ const lstStyle = g1(txBody, 'lstStyle');
573
+ const lstDefRPr = lstStyle ? g1(lstStyle, 'defRPr') : null;
574
+
575
+ // Build a merged "default rPr" helper: returns value from rPr → paraDefRPr → lstDefRPr → fallback
576
+ function resolveRPrAttr(rPr, paraDefRPr, attrName, fallback) {
577
+ const v1 = rPr ? rPr.getAttribute(attrName) : null;
578
+ if (v1 !== null && v1 !== '') return v1;
579
+ const v2 = paraDefRPr ? paraDefRPr.getAttribute(attrName) : null;
580
+ if (v2 !== null && v2 !== '') return v2;
581
+ const v3 = lstDefRPr ? lstDefRPr.getAttribute(attrName) : null;
582
+ if (v3 !== null && v3 !== '') return v3;
583
+ return fallback;
584
+ }
585
+
586
+ const paragraphs = gtn(txBody, 'p');
587
+
588
+ // First pass: build layout (lines, positions) so we can apply vertical alignment
589
+ const paraLayouts = [];
590
+ let totalHeight = 0;
591
+
592
+ for (const para of paragraphs) {
593
+ const pPr = g1(para, 'pPr');
594
+ const algn = attr(pPr, 'algn', 'l'); // l, ctr, r, just, dist
595
+ const lvl = attrInt(pPr, 'lvl', 0);
596
+ const marL = attrInt(pPr, 'marL', 0) * scale;
597
+ const indent = attrInt(pPr, 'indent', 0) * scale;
598
+
599
+ // ── Bullet / list marker ─────────────────────────────────────────────────
600
+ const bullet = pPr ? parseBullet(pPr, defRPr, themeColors, themeData) : null;
601
+
602
+ // Spacing
603
+ const spcBef = g1(pPr, 'spcBef');
604
+ const spcAft = g1(pPr, 'spcAft');
605
+ const lnSpc = g1(pPr, 'lnSpc');
606
+ const defRPr = g1(pPr, 'defRPr');
607
+
608
+ // Default font size: lstStyle (lowest) → pPr defRPr (higher) → run rPr (highest)
609
+ let paraDefSz = defaultFontSz;
610
+ if (lstDefRPr) {
611
+ const sz = lstDefRPr.getAttribute('sz');
612
+ if (sz) paraDefSz = parseInt(sz, 10);
613
+ }
614
+ if (defRPr) {
615
+ const sz = defRPr.getAttribute('sz');
616
+ if (sz) paraDefSz = parseInt(sz, 10);
617
+ }
618
+
619
+ // Get space before/after in px
620
+ let spaceBefore = 0, spaceAfter = 0;
621
+ if (spcBef) {
622
+ const sp = g1(spcBef, 'spcPct');
623
+ const spp = g1(spcBef, 'spcPts');
624
+ if (sp) spaceBefore = (paraDefSz * 127 * scale) * (attrInt(sp, 'val', 0) / 100000);
625
+ else if (spp) spaceBefore = attrInt(spp, 'val', 0) * EMU_PER_PT * scale / 100;
626
+ }
627
+ if (spcAft) {
628
+ const sp = g1(spcAft, 'spcPct');
629
+ const spp = g1(spcAft, 'spcPts');
630
+ if (sp) spaceAfter = (paraDefSz * 127 * scale) * (attrInt(sp, 'val', 0) / 100000);
631
+ else if (spp) spaceAfter = attrInt(spp, 'val', 0) * EMU_PER_PT * scale / 100;
632
+ }
633
+
634
+ // Collect runs (a:r, a:br)
635
+ const runEls = [];
636
+ for (const child of para.children) {
637
+ const ln = child.localName;
638
+ if (ln === 'r' || ln === 'br' || ln === 'fld') runEls.push(child);
639
+ }
640
+
641
+ // Check if paragraph is empty
642
+ if (runEls.length === 0) {
643
+ const endParaRPr = g1(para, 'endParaRPr');
644
+ const sz = attrInt(endParaRPr || defRPr, 'sz', paraDefSz);
645
+ const szPx = sz * 127 * scale * fontScaleAttr;
646
+ paraLayouts.push({ lines: [''], algn, marL, spaceBefore, spaceAfter, szPx, lnSpc, runs: [], emptyPara: true, bullet });
647
+ totalHeight += spaceBefore + szPx * 1.2 + spaceAfter;
648
+ continue;
649
+ }
650
+
651
+ // Build text lines by processing runs
652
+ // Each line item: [{ text, rPr, font, color }, ...]
653
+ let paraLines = [];
654
+ let currentLine = [];
655
+ let maxSzPx = 0;
656
+
657
+ for (const runEl of runEls) {
658
+ if (runEl.localName === 'br') {
659
+ // Line break
660
+ paraLines.push({ runs: currentLine, maxSzPx: Math.max(maxSzPx, paraDefSz * 127 * scale) });
661
+ currentLine = [];
662
+ maxSzPx = 0;
663
+ continue;
664
+ }
665
+
666
+ const rPr = g1(runEl, 'rPr');
667
+ const tEl = g1(runEl, 't');
668
+ let text = tEl ? tEl.textContent : '';
669
+
670
+ // Build font using full inheritance chain: rPr → pPr defRPr → lstStyle defRPr
671
+ const fontInfo = buildFontInherited(rPr, defRPr, scale * fontScaleAttr, themeData, paraDefSz, lstDefRPr);
672
+ ctx.font = fontInfo.fontStr;
673
+ const szPx = fontInfo.szPx;
674
+ if (szPx > maxSzPx) maxSzPx = szPx;
675
+
676
+ const color = getRunColorInherited(rPr, defRPr, themeColors);
677
+ const underline = resolveRPrAttr(rPr, defRPr, 'u', 'none') !== 'none';
678
+ const strikethrough = resolveRPrAttr(rPr, defRPr, 'strike', 'noStrike') !== 'noStrike';
679
+ const baseline = parseInt(resolveRPrAttr(rPr, defRPr, 'baseline', '0'), 10);
680
+
681
+ if (doWrap) {
682
+ // Need to wrap this run's text within remaining line space
683
+ const words = text.split(' ');
684
+ for (let wi = 0; wi < words.length; wi++) {
685
+ const word = words[wi];
686
+ const testRun = { text: word, rPr, fontInfo, color, underline, strikethrough, baseline };
687
+ // Compute current line width
688
+ let lineW = indent + marL;
689
+ for (const run of currentLine) {
690
+ ctx.font = run.fontInfo.fontStr;
691
+ lineW += ctx.measureText(run.text).width;
692
+ }
693
+ ctx.font = fontInfo.fontStr;
694
+ const wordW = ctx.measureText(word).width;
695
+ const sep = currentLine.length ? ctx.measureText(' ').width : 0;
696
+
697
+ if (lineW + sep + wordW > tw && currentLine.length > 0) {
698
+ paraLines.push({ runs: currentLine, maxSzPx: Math.max(maxSzPx, szPx) });
699
+ currentLine = [{ text: word, rPr, fontInfo, color, underline, strikethrough, baseline }];
700
+ maxSzPx = szPx;
701
+ } else {
702
+ if (currentLine.length > 0) {
703
+ // Append space to previous run or add space run
704
+ const spaceRun = { text: ' ', rPr, fontInfo, color, underline: false, strikethrough: false, baseline };
705
+ currentLine.push(spaceRun);
706
+ }
707
+ currentLine.push({ text: word, rPr, fontInfo, color, underline, strikethrough, baseline });
708
+ }
709
+ }
710
+ } else {
711
+ currentLine.push({ text, rPr, fontInfo, color, underline, strikethrough, baseline });
712
+ if (szPx > maxSzPx) maxSzPx = szPx;
713
+ }
714
+ }
715
+
716
+ if (currentLine.length > 0) {
717
+ paraLines.push({ runs: currentLine, maxSzPx: Math.max(maxSzPx, paraDefSz * 127 * scale) });
718
+ }
719
+
720
+ const lnSpcPx = lnSpc ? computeLineHeight(lnSpc, paraDefSz * 127 * scale * fontScaleAttr, scale) : null;
721
+ paraLayouts.push({ lines: paraLines, algn, marL, indent, spaceBefore, spaceAfter, lnSpcPx, emptyPara: false, bullet });
722
+
723
+ for (const line of paraLines) {
724
+ totalHeight += spaceBefore + (lnSpcPx || line.maxSzPx * 1.2) + spaceAfter;
725
+ }
726
+ }
727
+
728
+ // Auto-shrink: if normAutoFit is present without explicit fontScale,
729
+ // and text overflows the box, iteratively reduce fontScaleAttr until it fits.
730
+ if (normAutoFit && !explicitFontScale && totalHeight > th && th > 0) {
731
+ // Binary-search for a scale that fits (max 8 iterations)
732
+ let lo = 0.3, hi = 1.0;
733
+ for (let iter = 0; iter < 8; iter++) {
734
+ const mid = (lo + hi) / 2;
735
+ // Recompute totalHeight with this scale
736
+ let testH = 0;
737
+ for (const para of paragraphs) {
738
+ const pPr2 = g1(para, 'pPr');
739
+ const defRPr2 = pPr2 ? g1(pPr2, 'defRPr') : null;
740
+ let pSz = defaultFontSz;
741
+ if (lstDefRPr) { const v = lstDefRPr.getAttribute('sz'); if (v) pSz = parseInt(v, 10); }
742
+ if (defRPr2) { const v = defRPr2.getAttribute('sz'); if (v) pSz = parseInt(v, 10); }
743
+ const runEls2 = Array.from(para.children).filter(c => ['r','br','fld'].includes(c.localName));
744
+ const szPx = pSz * 127 * scale * mid;
745
+ if (runEls2.length === 0) { testH += szPx * 1.2; continue; }
746
+ // Estimate: total text length / avg chars per line at this font size
747
+ const totalText = runEls2.reduce((s, e) => {
748
+ const t = g1(e, 't');
749
+ return s + (t ? t.textContent.length : 0);
750
+ }, 0);
751
+ const effectiveTw = tw > 0 ? tw : bw; // fallback to full width if insets eat everything
752
+ // Measure average char width using a representative sample of the actual text
753
+ const sampleText = totalText > 0
754
+ ? runEls2.reduce((s, e) => { const t = g1(e, 't'); return s + (t ? t.textContent : ''); }, '').slice(0, 20)
755
+ : 'W';
756
+ ctx.font = `${szPx}px sans-serif`;
757
+ const avgCharW = sampleText.length > 0 ? ctx.measureText(sampleText).width / sampleText.length : szPx * 0.6;
758
+ const charsPerLine = Math.max(1, Math.floor(effectiveTw / avgCharW));
759
+ const estLines = Math.max(1, Math.ceil(totalText / charsPerLine));
760
+ testH += estLines * szPx * 1.2;
761
+ }
762
+ // Rough estimate: if testH fits, go bigger, else go smaller
763
+ if (testH <= th) lo = mid; else hi = mid;
764
+ }
765
+ fontScaleAttr = (lo + hi) / 2;
766
+ }
767
+
768
+ // Vertical alignment
769
+ let startY = ty;
770
+ if (anchor === 'ctr') {
771
+ startY = ty + (th - totalHeight) / 2;
772
+ } else if (anchor === 'b') {
773
+ startY = ty + th - totalHeight;
774
+ }
775
+
776
+
777
+
778
+ // Second pass: render
779
+ let curY = startY;
780
+
781
+ // Track auto-numbering counters per bullet type+startAt
782
+ const autoNumCounters = {};
783
+
784
+ for (const paraLayout of paraLayouts) {
785
+ const { lines, algn, marL, indent, spaceBefore, spaceAfter, lnSpcPx, emptyPara, bullet } = paraLayout;
786
+ curY += spaceBefore;
787
+
788
+ if (emptyPara) {
789
+ curY += lines[0] ? (lnSpcPx || paraLayout.szPx * 1.2) : 12 * scale;
790
+ curY += spaceAfter;
791
+ continue;
792
+ }
793
+
794
+ for (const lineObj of lines) {
795
+ const { runs, maxSzPx } = lineObj;
796
+ const lineH = lnSpcPx || maxSzPx * 1.2;
797
+ const baseline = curY + maxSzPx * 0.85; // approximate baseline within line height
798
+
799
+ // Calculate total line width for alignment
800
+ let lineW = 0;
801
+ for (const run of runs) {
802
+ ctx.font = run.fontInfo.fontStr;
803
+ lineW += ctx.measureText(run.text).width;
804
+ }
805
+
806
+ // ── X-axis start position ─────────────────────────────────────────────
807
+ let runX = tx + marL;
808
+ if (algn === 'ctr') {
809
+ runX = tx + (tw - lineW) / 2;
810
+ } else if (algn === 'r') {
811
+ runX = tx + tw - lineW;
812
+ }
813
+
814
+ // ── Bullet / list marker (only on first line of paragraph) ────────────
815
+ const isFirstLineOfPara = lineObj === lines[0];
816
+ if (bullet && isFirstLineOfPara) {
817
+ const bulletX = tx + marL + indent; // indent is typically negative (hanging)
818
+ drawBullet(ctx, bullet, bulletX, baseline, autoNumCounters);
819
+ }
820
+
821
+ // ── Justified text: distribute extra space across word gaps ───────────
822
+ let justWordGap = 0;
823
+ if (algn === 'just') {
824
+ const isLastLine = lineObj === lines[lines.length - 1];
825
+ if (!isLastLine) {
826
+ // Count spaces across all runs in this line
827
+ let spaceCount = 0;
828
+ for (const run of runs) {
829
+ ctx.font = run.fontInfo.fontStr;
830
+ spaceCount += (run.text.match(/ /g) || []).length;
831
+ }
832
+ const slack = (tw - marL) - lineW;
833
+ if (spaceCount > 0 && slack > 0) {
834
+ justWordGap = slack / spaceCount;
835
+ }
836
+ }
837
+ }
838
+
839
+ // ── Draw a single run at (rx, drawY) — shared by normal + justified paths
840
+ const drawRunSegment = (text, rx, drawY, fi, underline, strike) => {
841
+ // Build font string for this segment (supscript/subscript already handled by caller)
842
+ ctx.font = fi.fontStr;
843
+ const sw = ctx.measureText(text).width;
844
+ ctx.fillText(text, rx, drawY);
845
+ const lw = Math.max(0.5, fi.szPx * 0.07);
846
+ if (underline) {
847
+ ctx.save();
848
+ ctx.strokeStyle = ctx.fillStyle;
849
+ ctx.lineWidth = lw;
850
+ ctx.beginPath();
851
+ ctx.moveTo(rx, drawY + lw * 1.5);
852
+ ctx.lineTo(rx + sw, drawY + lw * 1.5);
853
+ ctx.stroke();
854
+ ctx.restore();
855
+ }
856
+ if (strike) {
857
+ ctx.save();
858
+ ctx.strokeStyle = ctx.fillStyle;
859
+ ctx.lineWidth = lw;
860
+ ctx.beginPath();
861
+ ctx.moveTo(rx, drawY - fi.szPx * 0.3);
862
+ ctx.lineTo(rx + sw, drawY - fi.szPx * 0.3);
863
+ ctx.stroke();
864
+ ctx.restore();
865
+ }
866
+ return sw;
867
+ };
868
+
869
+ // ── Draw each run ──────────────────────────────────────────────────────
870
+ for (const run of runs) {
871
+ const c = run.color;
872
+ ctx.fillStyle = c ? colorToCss(c) : '#000000';
873
+
874
+ // Superscript / subscript: smaller font + vertical offset
875
+ let drawY = baseline;
876
+ let fi = run.fontInfo;
877
+ if (run.baseline !== 0) {
878
+ const subSz = fi.szPx * 0.65;
879
+ const subFont = `${fi.italic ? 'italic ' : ''}${fi.bold ? 'bold ' : ''}${subSz}px "${fi.family}", sans-serif`;
880
+ fi = { ...fi, szPx: subSz, fontStr: subFont };
881
+ if (run.baseline > 0) drawY = baseline - run.fontInfo.szPx * 0.38; // superscript
882
+ else drawY = baseline + run.fontInfo.szPx * 0.12; // subscript
883
+ }
884
+
885
+ if (justWordGap > 0 && run.text.includes(' ')) {
886
+ // Justified: render word-by-word with expanded spaces
887
+ ctx.font = fi.fontStr;
888
+ const parts = run.text.split(' ');
889
+ for (let pi = 0; pi < parts.length; pi++) {
890
+ const pw = drawRunSegment(parts[pi], runX, drawY, fi, run.underline, run.strikethrough);
891
+ runX += pw;
892
+ if (pi < parts.length - 1) {
893
+ ctx.font = fi.fontStr;
894
+ runX += ctx.measureText(' ').width + justWordGap;
895
+ }
896
+ }
897
+ } else {
898
+ ctx.font = fi.fontStr;
899
+ const rw = ctx.measureText(run.text).width;
900
+ drawRunSegment(run.text, runX, drawY, fi, run.underline, run.strikethrough);
901
+ runX += rw;
902
+ }
903
+ }
904
+
905
+ curY += lineH;
906
+ }
907
+ curY += spaceAfter;
908
+ }
909
+
910
+ if (isVert) ctx.restore();
911
+ }
912
+
913
+
914
+ // Apply shadow effects from effectLst to the canvas context.
915
+ // Must be called BEFORE drawing the shape (sets ctx.shadow*).
916
+ // Returns a cleanup function that resets shadow state.
917
+ export function applyEffects(ctx, spPr, themeColors, scale) {
918
+ const effectLst = g1(spPr, 'effectLst');
919
+ if (!effectLst) return () => {};
920
+
921
+ const outerShdw = g1(effectLst, 'outerShdw');
922
+ const innerShdw = g1(effectLst, 'innerShdw');
923
+ const shadow = outerShdw || innerShdw;
924
+
925
+ if (shadow) {
926
+ // blurRad: EMU → pixels
927
+ const blurRad = attrInt(shadow, 'blurRad', 38100) * scale;
928
+ // dist: distance from shape in EMU
929
+ const dist = attrInt(shadow, 'dist', 38100) * scale;
930
+ // dir: angle in 60000ths of degree, clockwise from east
931
+ const dirRaw = attrInt(shadow, 'dir', 2700000);
932
+ const dirRad = dirRaw / 60000 * Math.PI / 180;
933
+ const offsetX = dist * Math.cos(dirRad);
934
+ const offsetY = dist * Math.sin(dirRad);
935
+
936
+ // Shadow color
937
+ const colorChild = findFirstColorChild(shadow);
938
+ const c = resolveColorElement(colorChild, themeColors);
939
+ const shadowColor = c ? colorToCss(c) : 'rgba(0,0,0,0.35)';
940
+
941
+ ctx.shadowBlur = Math.min(blurRad, 40); // canvas limit ~40px looks good
942
+ ctx.shadowOffsetX = offsetX;
943
+ ctx.shadowOffsetY = offsetY;
944
+ ctx.shadowColor = shadowColor;
945
+ }
946
+
947
+ // Glow effect
948
+ const glow = g1(effectLst, 'glow');
949
+ if (glow) {
950
+ const rad = attrInt(glow, 'rad', 0) * scale;
951
+ const colorChild = findFirstColorChild(glow);
952
+ const c = resolveColorElement(colorChild, themeColors);
953
+ if (c) {
954
+ ctx.shadowBlur = Math.min(rad, 30);
955
+ ctx.shadowOffsetX = 0;
956
+ ctx.shadowOffsetY = 0;
957
+ ctx.shadowColor = colorToCss(c);
958
+ }
959
+ }
960
+
961
+ // Return cleanup
962
+ return () => {
963
+ ctx.shadowBlur = 0;
964
+ ctx.shadowOffsetX = 0;
965
+ ctx.shadowOffsetY = 0;
966
+ ctx.shadowColor = 'transparent';
967
+ };
968
+ }
969
+
970
+ export async function renderShape(ctx, spEl, rels, imageCache, themeColors, themeData, scale, parentGroup = null, placeholderMap = null) {
971
+ const spPr = g1(spEl, 'spPr');
972
+ const xfrm = g1(spPr, 'xfrm');
973
+
974
+ // Get bounding box from xfrm
975
+ let x = 0, y = 0, w = 0, h = 0;
976
+ let rot = 0;
977
+ let flipH = false, flipV = false;
978
+
979
+ if (xfrm) {
980
+ const off = g1(xfrm, 'off');
981
+ const ext = g1(xfrm, 'ext');
982
+ if (off) {
983
+ x = attrInt(off, 'x', 0) * scale;
984
+ y = attrInt(off, 'y', 0) * scale;
985
+ }
986
+ if (ext) {
987
+ w = attrInt(ext, 'cx', 0) * scale;
988
+ h = attrInt(ext, 'cy', 0) * scale;
989
+ }
990
+ rot = attrInt(xfrm, 'rot', 0) / 60000; // degrees
991
+ flipH = attr(xfrm, 'flipH', '0') === '1';
992
+ flipV = attr(xfrm, 'flipV', '0') === '1';
993
+ } else {
994
+ // No xfrm on the shape — try to resolve from layout/master placeholder map
995
+ const phData = resolvePlaceholderXfrm(spEl, placeholderMap);
996
+ if (phData) {
997
+ x = phData.x * scale;
998
+ y = phData.y * scale;
999
+ w = phData.w * scale;
1000
+ h = phData.h * scale;
1001
+ } else {
1002
+ return; // Can't determine position — skip
1003
+ }
1004
+ }
1005
+
1006
+ if (w <= 0 || h <= 0) return;
1007
+
1008
+ // Apply group transform adjustment if inside a group
1009
+ if (parentGroup) {
1010
+ const { grpOff, grpExt, chOff, chExt } = parentGroup;
1011
+ const scaleX = grpExt.cx / chExt.cx;
1012
+ const scaleY = grpExt.cy / chExt.cy;
1013
+ x = grpOff.x + (x / scale - chOff.x) * scaleX * scale;
1014
+ y = grpOff.y + (y / scale - chOff.y) * scaleY * scale;
1015
+ w = w * scaleX;
1016
+ h = h * scaleY;
1017
+ }
1018
+
1019
+ const cx = x + w / 2, cy = y + h / 2;
1020
+
1021
+ // Begin drawing with rotation
1022
+ ctx.save();
1023
+ if (rot !== 0 || flipH || flipV) {
1024
+ ctx.translate(cx, cy);
1025
+ if (rot !== 0) ctx.rotate(rot * Math.PI / 180);
1026
+ if (flipH) ctx.scale(-1, 1);
1027
+ if (flipV) ctx.scale(1, -1);
1028
+ ctx.translate(-cx, -cy);
1029
+ }
1030
+
1031
+ // Get geometry
1032
+ const prstGeom = g1(spPr, 'prstGeom');
1033
+ const custGeom = g1(spPr, 'custGeom');
1034
+ const prst = prstGeom ? attr(prstGeom, 'prst', 'rect') : 'rect';
1035
+
1036
+ // Parse adjustment values
1037
+ const adjValues = {};
1038
+ if (prstGeom) {
1039
+ const avLst = g1(prstGeom, 'avLst');
1040
+ if (avLst) {
1041
+ let idx = 0;
1042
+ for (const gd of gtn(avLst, 'gd')) {
1043
+ const fmla = attr(gd, 'fmla', '');
1044
+ const m = fmla.match(/val\s+(-?\d+)/);
1045
+ if (m) adjValues[idx] = parseInt(m[1]);
1046
+ idx++;
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ // Draw the path for fill / outline
1052
+ const getFill = () => {
1053
+ // 1. spPr explicit fill (highest priority)
1054
+ const fillNames = ['noFill', 'solidFill', 'gradFill', 'blipFill', 'pattFill', 'grpFill'];
1055
+ for (const fn of fillNames) {
1056
+ const el = g1(spPr, fn);
1057
+ if (el) return el;
1058
+ }
1059
+ // 2. style.fillRef — theme-based fill colour for this shape
1060
+ const styleEl = g1(spEl, 'style');
1061
+ if (styleEl) {
1062
+ const fillRef = g1(styleEl, 'fillRef');
1063
+ if (fillRef) {
1064
+ // fillRef idx=0 means no fill; idx≥1 means use the colour child directly
1065
+ const idx = attrInt(fillRef, 'idx', 1);
1066
+ if (idx === 0) return parseXml('<noFill/>').documentElement;
1067
+ const colorChild = findFirstColorChild(fillRef);
1068
+ if (colorChild) {
1069
+ // Build a synthetic <solidFill> element in memory using DOMParser
1070
+ const ns = 'http://schemas.openxmlformats.org/drawingml/2006/main';
1071
+ const doc = parseXml('<solidFill xmlns="' + ns + '">' + colorChild.outerHTML + '</solidFill>');
1072
+ return doc.documentElement;
1073
+ }
1074
+ }
1075
+ }
1076
+ return null;
1077
+ };
1078
+
1079
+ const getOutline = () => {
1080
+ // spPr explicit line (highest priority)
1081
+ const ln = g1(spPr, 'ln');
1082
+ if (ln) return ln;
1083
+ // style.lnRef — theme-based line for this shape
1084
+ const styleEl = g1(spEl, 'style');
1085
+ if (styleEl) {
1086
+ const lnRef = g1(styleEl, 'lnRef');
1087
+ if (lnRef) {
1088
+ const idx = attrInt(lnRef, 'idx', 1);
1089
+ if (idx === 0) return null; // explicit no-line
1090
+ const colorChild = findFirstColorChild(lnRef);
1091
+ if (colorChild) {
1092
+ const ns = 'http://schemas.openxmlformats.org/drawingml/2006/main';
1093
+ const doc = parseXml('<ln xmlns="' + ns + '"><solidFill>' + colorChild.outerHTML + '</solidFill></ln>');
1094
+ return doc.documentElement;
1095
+ }
1096
+ }
1097
+ }
1098
+ return null;
1099
+ };
1100
+
1101
+ // Handle custom geometry (custGeom) - draw path from pathLst
1102
+ if (custGeom) {
1103
+ const pathLst = g1(custGeom, 'pathLst');
1104
+ if (pathLst) {
1105
+ for (const pathEl of gtn(pathLst, 'path')) {
1106
+ const pw = attrInt(pathEl, 'w', 1) || 1;
1107
+ const ph = attrInt(pathEl, 'h', 1) || 1;
1108
+ const sx = w / pw, sy = h / ph;
1109
+ ctx.beginPath();
1110
+ let cx0 = x, cy0 = y;
1111
+ for (const cmd of pathEl.children) {
1112
+ switch (cmd.localName) {
1113
+ case 'moveTo': {
1114
+ const pt = g1(cmd, 'pt');
1115
+ if (pt) ctx.moveTo(x + attrInt(pt,'x',0)*sx, y + attrInt(pt,'y',0)*sy);
1116
+ break;
1117
+ }
1118
+ case 'lnTo': {
1119
+ const pt = g1(cmd, 'pt');
1120
+ if (pt) ctx.lineTo(x + attrInt(pt,'x',0)*sx, y + attrInt(pt,'y',0)*sy);
1121
+ break;
1122
+ }
1123
+ case 'cubicBezTo': {
1124
+ const pts = gtn(cmd, 'pt');
1125
+ if (pts.length >= 3) {
1126
+ ctx.bezierCurveTo(
1127
+ x+attrInt(pts[0],'x',0)*sx, y+attrInt(pts[0],'y',0)*sy,
1128
+ x+attrInt(pts[1],'x',0)*sx, y+attrInt(pts[1],'y',0)*sy,
1129
+ x+attrInt(pts[2],'x',0)*sx, y+attrInt(pts[2],'y',0)*sy
1130
+ );
1131
+ }
1132
+ break;
1133
+ }
1134
+ case 'quadBezTo': {
1135
+ const pts = gtn(cmd, 'pt');
1136
+ if (pts.length >= 2) {
1137
+ ctx.quadraticCurveTo(
1138
+ x+attrInt(pts[0],'x',0)*sx, y+attrInt(pts[0],'y',0)*sy,
1139
+ x+attrInt(pts[1],'x',0)*sx, y+attrInt(pts[1],'y',0)*sy
1140
+ );
1141
+ }
1142
+ break;
1143
+ }
1144
+ case 'arcTo': {
1145
+ const wR = attrInt(cmd, 'wR', 0)*sx;
1146
+ const hR = attrInt(cmd, 'hR', 0)*sy;
1147
+ const stAng = attrInt(cmd, 'stAng', 0) / 60000 * Math.PI / 180;
1148
+ const swAng = attrInt(cmd, 'swAng', 0) / 60000 * Math.PI / 180;
1149
+ // Approximate with canvas arc
1150
+ const lastX = ctx._lastX || x;
1151
+ const lastY = ctx._lastY || y;
1152
+ const ecx = lastX - wR * Math.cos(stAng);
1153
+ const ecy = lastY - hR * Math.sin(stAng);
1154
+ if (wR === hR) {
1155
+ ctx.arc(ecx, ecy, wR, stAng, stAng + swAng, swAng < 0);
1156
+ } else {
1157
+ ctx.ellipse(ecx, ecy, wR, hR, 0, stAng, stAng + swAng, swAng < 0);
1158
+ }
1159
+ break;
1160
+ }
1161
+ case 'close':
1162
+ ctx.closePath();
1163
+ break;
1164
+ }
1165
+ }
1166
+ // Apply fill/stroke to this custom path
1167
+ const fillEl = getFill();
1168
+ if (fillEl) await applyFill(ctx, fillEl, x, y, w, h, scale, themeColors, imageCache);
1169
+ const lnEl = getOutline();
1170
+ if (lnEl) applyOutline(ctx, lnEl, themeColors, scale);
1171
+ }
1172
+ }
1173
+ ctx.restore();
1174
+ // Still render text
1175
+ const txBody = g1(spEl, 'txBody');
1176
+ if (txBody) {
1177
+ ctx.save();
1178
+ ctx.beginPath();
1179
+ ctx.rect(x, y, w, h);
1180
+ ctx.clip();
1181
+ const defSz = getDefaultFontSize(spEl, themeData);
1182
+ await renderTextBody(ctx, txBody, x, y, w, h, scale, themeColors, themeData, defSz);
1183
+ ctx.restore();
1184
+ }
1185
+ return;
1186
+ }
1187
+
1188
+ // Draw preset or default rect path
1189
+ ctx.beginPath();
1190
+ const pathDrawn = drawPresetGeom(ctx, prst, x, y, w, h, adjValues);
1191
+
1192
+ // Apply shadow/glow effects before fill (canvas compositing requires this order)
1193
+ const cleanupEffects = applyEffects(ctx, spPr, themeColors, scale);
1194
+
1195
+ // 3D: extrusion is drawn before fill (setup3D handles this)
1196
+ const fx3d = x, fy3d = y, fw3d = w, fh3d = h;
1197
+ const effects3d = has3D(spPr) ? setup3D(ctx, spPr, themeColors, fx3d, fy3d, fw3d, fh3d, scale) : null;
1198
+ // camera transform is applied inside setup3D
1199
+
1200
+ try {
1201
+ // Apply fill
1202
+ const fillEl = getFill();
1203
+ let filled = false;
1204
+
1205
+ if (fillEl) {
1206
+ filled = await applyFill(ctx, fillEl, x, y, w, h, scale, themeColors, imageCache);
1207
+ }
1208
+
1209
+ // 3D overlay (bevel + lighting) goes on top of fill, before outline
1210
+ if (effects3d) {
1211
+ effects3d.overlay();
1212
+ }
1213
+
1214
+ // Apply outline
1215
+ const lnEl = getOutline();
1216
+ if (lnEl) {
1217
+ applyOutline(ctx, lnEl, themeColors, scale);
1218
+ } else if (!filled) {
1219
+ // No explicit fill or outline — draw a default stroke for line shapes
1220
+ if (prst === 'line' || prst === 'straightConnector1') {
1221
+ ctx.strokeStyle = '#000';
1222
+ ctx.lineWidth = 1;
1223
+ ctx.stroke();
1224
+ }
1225
+ }
1226
+ } finally {
1227
+ // ALWAYS reset shadow/glow state — guards against early returns and errors
1228
+ cleanupEffects();
1229
+ if (effects3d?.cleanup) effects3d.cleanup();
1230
+ }
1231
+
1232
+ ctx.restore();
1233
+
1234
+ // Render text body (outside rotation - text rotates with shape via ctx transform)
1235
+ const txBody = g1(spEl, 'txBody');
1236
+ if (txBody) {
1237
+ ctx.save();
1238
+ if (rot !== 0 || flipH || flipV) {
1239
+ ctx.translate(cx, cy);
1240
+ if (rot !== 0) ctx.rotate(rot * Math.PI / 180);
1241
+ if (flipH) ctx.scale(-1, 1);
1242
+ if (flipV) ctx.scale(1, -1);
1243
+ ctx.translate(-cx, -cy);
1244
+ }
1245
+ // Clip to shape bounds
1246
+ ctx.beginPath();
1247
+ ctx.rect(x, y, w, h);
1248
+ ctx.clip();
1249
+ const defSz = getDefaultFontSize(spEl, themeData);
1250
+ await renderTextBody(ctx, txBody, x, y, w, h, scale, themeColors, themeData, defSz);
1251
+ ctx.restore();
1252
+ }
1253
+ }
1254
+
1255
+ export function getDefaultFontSize(spEl, themeData) {
1256
+ // Try to get font size hint from placeholder type
1257
+ const nvSpPr = g1(spEl, 'nvSpPr');
1258
+ const nvPr = nvSpPr ? g1(nvSpPr, 'nvPr') : null;
1259
+ const ph = nvPr ? g1(nvPr, 'ph') : null;
1260
+ if (ph) {
1261
+ const phType = attr(ph, 'type', 'body');
1262
+ if (phType === 'title' || phType === 'ctrTitle') return 4400; // 44pt
1263
+ if (phType === 'subTitle' || phType === 'body') return 2800; // 28pt
1264
+ }
1265
+ return 1800; // 18pt default
1266
+ }
1267
+
1268
+ export function applyOutline(ctx, lnEl, themeColors, scale) {
1269
+ if (!lnEl) return;
1270
+ const noFill = g1(lnEl, 'noFill');
1271
+ if (noFill) return;
1272
+
1273
+ const solidFill = g1(lnEl, 'solidFill');
1274
+ const gradFill = g1(lnEl, 'gradFill');
1275
+ const w = attrInt(lnEl, 'w', 12700); // EMU, default 1pt
1276
+ const lineW = Math.max(0.5, w * scale);
1277
+
1278
+ let strokeColor = '#000000';
1279
+ if (solidFill) {
1280
+ const colorChild = findFirstColorChild(solidFill);
1281
+ const c = resolveColorElement(colorChild, themeColors);
1282
+ if (c) strokeColor = colorToCss(c);
1283
+ } else if (gradFill) {
1284
+ strokeColor = '#888888';
1285
+ }
1286
+
1287
+ const prstDash = g1(lnEl, 'prstDash');
1288
+ const dashType = prstDash ? attr(prstDash, 'val', 'solid') : 'solid';
1289
+ const capType = attr(lnEl, 'cap', 'flat');
1290
+ const joinType = attr(lnEl, 'cmpd', 'sng');
1291
+
1292
+ ctx.strokeStyle = strokeColor;
1293
+ ctx.lineWidth = lineW;
1294
+ ctx.lineCap = capType === 'rnd' ? 'round' : capType === 'sq' ? 'square' : 'butt';
1295
+ ctx.lineJoin = 'round';
1296
+
1297
+ switch (dashType) {
1298
+ case 'dash': ctx.setLineDash([lineW * 4, lineW * 2]); break;
1299
+ case 'dot': ctx.setLineDash([lineW, lineW * 2]); break;
1300
+ case 'dashDot': ctx.setLineDash([lineW*4, lineW*2, lineW, lineW*2]); break;
1301
+ case 'lgDash': ctx.setLineDash([lineW*8, lineW*3]); break;
1302
+ case 'lgDashDot': ctx.setLineDash([lineW*8, lineW*3, lineW, lineW*3]); break;
1303
+ default: ctx.setLineDash([]);
1304
+ }
1305
+
1306
+ ctx.stroke();
1307
+ ctx.setLineDash([]);
1308
+ }
1309
+
1310
+ // ═══════════════════════════════════════════════════════════════════════════════
1311
+ // PICTURE RENDERING
1312
+ // ═══════════════════════════════════════════════════════════════════════════════
1313
+
1314
+ export async function renderPicture(ctx, picEl, rels, imageCache, themeColors, scale) {
1315
+ const spPr = g1(picEl, 'spPr');
1316
+ const xfrm = g1(spPr, 'xfrm');
1317
+ if (!xfrm) return;
1318
+
1319
+ const off = g1(xfrm, 'off');
1320
+ const ext = g1(xfrm, 'ext');
1321
+ if (!off || !ext) return;
1322
+
1323
+ const x = attrInt(off, 'x', 0) * scale;
1324
+ const y = attrInt(off, 'y', 0) * scale;
1325
+ const w = attrInt(ext, 'cx', 0) * scale;
1326
+ const h = attrInt(ext, 'cy', 0) * scale;
1327
+ const rot = attrInt(xfrm, 'rot', 0) / 60000;
1328
+ const flipH = attr(xfrm, 'flipH', '0') === '1';
1329
+ const flipV = attr(xfrm, 'flipV', '0') === '1';
1330
+
1331
+ if (w <= 0 || h <= 0) return;
1332
+
1333
+ // Get image reference
1334
+ const blipFill = g1(picEl, 'blipFill');
1335
+ const blip = blipFill ? g1(blipFill, 'blip') : null;
1336
+ const rEmbed = blip ? (blip.getAttribute('r:embed') || blip.getAttribute('embed')) : null;
1337
+
1338
+ const cx = x + w / 2, cy = y + h / 2;
1339
+
1340
+ ctx.save();
1341
+
1342
+ // Apply rotation
1343
+ if (rot !== 0 || flipH || flipV) {
1344
+ ctx.translate(cx, cy);
1345
+ if (rot !== 0) ctx.rotate(rot * Math.PI / 180);
1346
+ if (flipH) ctx.scale(-1, 1);
1347
+ if (flipV) ctx.scale(1, -1);
1348
+ ctx.translate(-cx, -cy);
1349
+ }
1350
+
1351
+ if (rEmbed && imageCache[rEmbed]) {
1352
+ const img = imageCache[rEmbed];
1353
+
1354
+ // Clipping
1355
+ const prstGeom = g1(spPr, 'prstGeom');
1356
+ const prst = prstGeom ? attr(prstGeom, 'prst', 'rect') : 'rect';
1357
+ const adjValues = {};
1358
+ if (prstGeom) {
1359
+ const avLst = g1(prstGeom, 'avLst');
1360
+ if (avLst) {
1361
+ let idx = 0;
1362
+ for (const gd of gtn(avLst, 'gd')) {
1363
+ const m = (attr(gd, 'fmla', '') || '').match(/val\s+(-?\d+)/);
1364
+ if (m) adjValues[idx] = parseInt(m[1]);
1365
+ idx++;
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ // Clip to shape geometry
1371
+ ctx.beginPath();
1372
+ drawPresetGeom(ctx, prst, x, y, w, h, adjValues);
1373
+ ctx.clip();
1374
+
1375
+ // Determine source crop
1376
+ const srcRect = blipFill ? g1(blipFill, 'srcRect') : null;
1377
+ if (srcRect) {
1378
+ const l = attrInt(srcRect, 'l', 0) / 100000;
1379
+ const t = attrInt(srcRect, 't', 0) / 100000;
1380
+ const r = attrInt(srcRect, 'r', 0) / 100000;
1381
+ const b = attrInt(srcRect, 'b', 0) / 100000;
1382
+ const sw = img.naturalWidth * (1 - l - r);
1383
+ const sh = img.naturalHeight * (1 - t - b);
1384
+ ctx.drawImage(img,
1385
+ img.naturalWidth * l, img.naturalHeight * t, sw, sh,
1386
+ x, y, w, h);
1387
+ } else {
1388
+ ctx.drawImage(img, x, y, w, h);
1389
+ }
1390
+
1391
+ // Apply outline if any
1392
+ const lnEl = g1(spPr, 'ln');
1393
+ if (lnEl) {
1394
+ ctx.beginPath();
1395
+ drawPresetGeom(ctx, prst, x, y, w, h, adjValues);
1396
+ applyOutline(ctx, lnEl, themeColors, scale);
1397
+ }
1398
+ } else {
1399
+ // Fallback: draw placeholder rectangle
1400
+ ctx.fillStyle = '#e0e0e0';
1401
+ ctx.fillRect(x, y, w, h);
1402
+ ctx.strokeStyle = '#aaa';
1403
+ ctx.lineWidth = 1;
1404
+ ctx.strokeRect(x, y, w, h);
1405
+ // Draw X
1406
+ ctx.beginPath();
1407
+ ctx.moveTo(x, y); ctx.lineTo(x+w, y+h);
1408
+ ctx.moveTo(x+w, y); ctx.lineTo(x, y+h);
1409
+ ctx.stroke();
1410
+ }
1411
+
1412
+ ctx.restore();
1413
+ }
1414
+
1415
+ // ═══════════════════════════════════════════════════════════════════════════════
1416
+ // TABLE RENDERING
1417
+ // ═══════════════════════════════════════════════════════════════════════════════
1418
+
1419
+ export async function renderTable(ctx, graphicFrame, themeColors, themeData, scale) {
1420
+ const xfrm = g1(graphicFrame, 'xfrm');
1421
+ if (!xfrm) return;
1422
+
1423
+ const off = g1(xfrm, 'off');
1424
+ const ext = g1(xfrm, 'ext');
1425
+ if (!off || !ext) return;
1426
+
1427
+ const fx = attrInt(off, 'x', 0) * scale;
1428
+ const fy = attrInt(off, 'y', 0) * scale;
1429
+ const fw = attrInt(ext, 'cx', 0) * scale;
1430
+ const fh = attrInt(ext, 'cy', 0) * scale;
1431
+
1432
+ const graphic = g1(graphicFrame, 'graphic');
1433
+ const graphicData = graphic ? g1(graphic, 'graphicData') : null;
1434
+ const tbl = graphicData ? g1(graphicData, 'tbl') : null;
1435
+ if (!tbl) return;
1436
+
1437
+ const tblPr = g1(tbl, 'tblPr');
1438
+ // Band style flags from tblPr
1439
+ const bandRow = tblPr ? attr(tblPr, 'bandRow', '0') === '1' : false;
1440
+ const bandCol = tblPr ? attr(tblPr, 'bandCol', '0') === '1' : false;
1441
+ const firstRow = tblPr ? attr(tblPr, 'firstRow', '0') === '1' : false;
1442
+ const lastRow = tblPr ? attr(tblPr, 'lastRow', '0') === '1' : false;
1443
+ const firstCol = tblPr ? attr(tblPr, 'firstCol', '0') === '1' : false;
1444
+ const lastCol = tblPr ? attr(tblPr, 'lastCol', '0') === '1' : false;
1445
+
1446
+ const tblGrid = g1(tbl, 'tblGrid');
1447
+ const colWidths = gtn(tblGrid, 'gridCol').map(gc => attrInt(gc, 'w', 0) * scale);
1448
+
1449
+ const rows = gtn(tbl, 'tr');
1450
+ let curY = fy;
1451
+
1452
+ for (let ri = 0; ri < rows.length; ri++) {
1453
+ const row = rows[ri];
1454
+ const rowH = attrInt(row, 'h', 457200) * scale;
1455
+ const cells = gtn(row, 'tc');
1456
+ let curX = fx;
1457
+ const isFirstRow = ri === 0;
1458
+ const isLastRow = ri === rows.length - 1;
1459
+ const isOddRow = ri % 2 === 1;
1460
+
1461
+ for (let ci = 0; ci < cells.length; ci++) {
1462
+ const cell = cells[ci];
1463
+ const gridSpan = attrInt(cell, 'gridSpan', 1);
1464
+ const vMerge = attr(cell, 'vMerge', '0') === '1';
1465
+
1466
+ let cellW = 0;
1467
+ for (let gs = 0; gs < gridSpan; gs++) {
1468
+ cellW += (colWidths[ci + gs] || 0);
1469
+ }
1470
+
1471
+ const tcPr = g1(cell, 'tcPr');
1472
+ // Cell fill
1473
+ const fillEl = tcPr ? (['noFill','solidFill','gradFill','blipFill','pattFill'].map(n=>g1(tcPr,n)).find(Boolean)) : null;
1474
+
1475
+ ctx.save();
1476
+ ctx.beginPath();
1477
+ ctx.rect(curX, curY, cellW, rowH);
1478
+
1479
+ if (fillEl) {
1480
+ await applyFill(ctx, fillEl, curX, curY, cellW, rowH, scale, themeColors, null);
1481
+ } else {
1482
+ // Apply band/header coloring from table style flags
1483
+ let bandFill = null;
1484
+ if (firstRow && isFirstRow) {
1485
+ bandFill = themeColors.accent1
1486
+ ? '#' + themeColors.accent1.toLowerCase()
1487
+ : '#4472C4'; // theme-style header row
1488
+ } else if (lastRow && isLastRow) {
1489
+ bandFill = '#e0e0e0';
1490
+ } else if (bandRow && isOddRow) {
1491
+ // Alternating row band — very light tint
1492
+ bandFill = 'rgba(0,0,0,0.06)';
1493
+ }
1494
+ ctx.fillStyle = bandFill || 'transparent';
1495
+ if (bandFill) ctx.fill();
1496
+ }
1497
+
1498
+ // Cell borders
1499
+ const borderProps = [
1500
+ { el: g1(tcPr, 'lnL'), x1: curX, y1: curY, x2: curX, y2: curY + rowH },
1501
+ { el: g1(tcPr, 'lnR'), x1: curX+cellW, y1: curY, x2: curX+cellW, y2: curY+rowH },
1502
+ { el: g1(tcPr, 'lnT'), x1: curX, y1: curY, x2: curX+cellW, y2: curY },
1503
+ { el: g1(tcPr, 'lnB'), x1: curX, y1: curY+rowH, x2: curX+cellW, y2: curY+rowH },
1504
+ ];
1505
+
1506
+ for (const border of borderProps) {
1507
+ if (!border.el) {
1508
+ // Default thin border
1509
+ ctx.beginPath();
1510
+ ctx.strokeStyle = '#cccccc';
1511
+ ctx.lineWidth = 0.5;
1512
+ ctx.moveTo(border.x1, border.y1);
1513
+ ctx.lineTo(border.x2, border.y2);
1514
+ ctx.stroke();
1515
+ } else {
1516
+ const noFill2 = g1(border.el, 'noFill');
1517
+ if (!noFill2) {
1518
+ ctx.beginPath();
1519
+ ctx.moveTo(border.x1, border.y1);
1520
+ ctx.lineTo(border.x2, border.y2);
1521
+ applyOutline(ctx, border.el, themeColors, scale);
1522
+ }
1523
+ }
1524
+ }
1525
+
1526
+ ctx.restore();
1527
+
1528
+ // Cell text
1529
+ const txBody = g1(cell, 'txBody');
1530
+ if (txBody) {
1531
+ ctx.save();
1532
+ ctx.beginPath();
1533
+ ctx.rect(curX, curY, cellW, rowH);
1534
+ ctx.clip();
1535
+ await renderTextBody(ctx, txBody, curX, curY, cellW, rowH, scale, themeColors, themeData, 1400);
1536
+ ctx.restore();
1537
+ }
1538
+
1539
+ curX += cellW;
1540
+ }
1541
+ curY += rowH;
1542
+ }
1543
+ }
1544
+
1545
+ // ═══════════════════════════════════════════════════════════════════════════════
1546
+ // GROUP SHAPE RENDERING
1547
+ // ═══════════════════════════════════════════════════════════════════════════════
1548
+
1549
+ export async function renderGroupShape(ctx, grpSpEl, rels, imageCache, themeColors, themeData, scale) {
1550
+ const grpSpPr = g1(grpSpEl, 'grpSpPr');
1551
+ const xfrm = g1(grpSpPr, 'xfrm');
1552
+ if (!xfrm) return;
1553
+
1554
+ const off = g1(xfrm, 'off');
1555
+ const ext = g1(xfrm, 'ext');
1556
+ const chOff = g1(xfrm, 'chOff');
1557
+ const chExt = g1(xfrm, 'chExt');
1558
+ if (!off || !ext || !chOff || !chExt) return;
1559
+
1560
+ const rot = attrInt(xfrm, 'rot', 0) / 60000;
1561
+ const flipH = attr(xfrm, 'flipH', '0') === '1';
1562
+ const flipV = attr(xfrm, 'flipV', '0') === '1';
1563
+
1564
+ const parentGroup = {
1565
+ grpOff: { x: attrInt(off, 'x', 0) * scale, y: attrInt(off, 'y', 0) * scale },
1566
+ grpExt: { cx: attrInt(ext, 'cx', 0) * scale, cy: attrInt(ext, 'cy', 0) * scale },
1567
+ chOff: { x: attrInt(chOff, 'x', 0), y: attrInt(chOff, 'y', 0) },
1568
+ chExt: { cx: attrInt(chExt, 'cx', 1), cy: attrInt(chExt, 'cy', 1) }
1569
+ };
1570
+
1571
+ const grpCx = parentGroup.grpOff.x + parentGroup.grpExt.cx / 2;
1572
+ const grpCy = parentGroup.grpOff.y + parentGroup.grpExt.cy / 2;
1573
+
1574
+ ctx.save();
1575
+ if (rot !== 0 || flipH || flipV) {
1576
+ ctx.translate(grpCx, grpCy);
1577
+ if (rot !== 0) ctx.rotate(rot * Math.PI / 180);
1578
+ if (flipH) ctx.scale(-1, 1);
1579
+ if (flipV) ctx.scale(1, -1);
1580
+ ctx.translate(-grpCx, -grpCy);
1581
+ }
1582
+
1583
+ for (const child of grpSpEl.children) {
1584
+ const ln = child.localName;
1585
+ if (ln === 'sp') await renderShape(ctx, child, rels, imageCache, themeColors, themeData, scale, parentGroup);
1586
+ else if (ln === 'pic') await renderPicture(ctx, child, rels, imageCache, themeColors, scale);
1587
+ else if (ln === 'grpSp') await renderGroupShape(ctx, child, rels, imageCache, themeColors, themeData, scale);
1588
+ else if (ln === 'graphicFrame') await renderGraphicFrame(ctx, child, themeColors, themeData, scale, files, rels);
1589
+ else if (ln === 'cxnSp') await renderConnector(ctx, child, themeColors, scale);
1590
+ }
1591
+ ctx.restore();
1592
+ }
1593
+
1594
+ // ═══════════════════════════════════════════════════════════════════════════════
1595
+ // CONNECTOR RENDERING
1596
+ // ═══════════════════════════════════════════════════════════════════════════════
1597
+
1598
+ /**
1599
+ * Draw an arrowhead or line-end decoration.
1600
+ * @param ctx canvas context
1601
+ * @param lnEl <a:ln> element
1602
+ * @param endName 'headEnd' or 'tailEnd'
1603
+ * @param tipX,tipY the pointed tip of the arrow
1604
+ * @param fromX,fromY the other end of the line (defines direction)
1605
+ */
1606
+ function drawArrowEnd(ctx, lnEl, endName, tipX, tipY, fromX, fromY, themeColors, scale) {
1607
+ const endEl = g1(lnEl, endName);
1608
+ if (!endEl) return;
1609
+ const type = endEl.getAttribute('type') || 'none';
1610
+ if (type === 'none') return;
1611
+
1612
+ // Arrow size from 'w' and 'len' attributes: sm=3, med=6, lg=9 (in lineWidths)
1613
+ const sizeMap = { sm: 3, med: 6, lg: 9 };
1614
+ const lineW = Math.max(0.5, attrInt(lnEl, 'w', 12700) * scale);
1615
+ const aw = lineW * (sizeMap[endEl.getAttribute('w') || 'med'] ?? 6);
1616
+ const al = lineW * (sizeMap[endEl.getAttribute('len') || 'med'] ?? 6);
1617
+
1618
+ // Direction angle from tip back toward line
1619
+ const angle = Math.atan2(fromY - tipY, fromX - tipX);
1620
+
1621
+ ctx.save();
1622
+ ctx.fillStyle = ctx.strokeStyle; // match line colour
1623
+ ctx.strokeStyle = ctx.strokeStyle;
1624
+
1625
+ switch (type) {
1626
+ case 'triangle':
1627
+ case 'arrow':
1628
+ case 'stealth': {
1629
+ const open = type === 'arrow';
1630
+ const indent = type === 'stealth' ? al * 0.5 : 0;
1631
+ ctx.beginPath();
1632
+ ctx.moveTo(tipX, tipY);
1633
+ ctx.lineTo(
1634
+ tipX + al * Math.cos(angle) + aw / 2 * Math.sin(angle),
1635
+ tipY + al * Math.sin(angle) - aw / 2 * Math.cos(angle)
1636
+ );
1637
+ if (!open) ctx.lineTo(tipX + indent * Math.cos(angle), tipY + indent * Math.sin(angle));
1638
+ ctx.lineTo(
1639
+ tipX + al * Math.cos(angle) - aw / 2 * Math.sin(angle),
1640
+ tipY + al * Math.sin(angle) + aw / 2 * Math.cos(angle)
1641
+ );
1642
+ ctx.closePath();
1643
+ if (open) ctx.stroke(); else ctx.fill();
1644
+ break;
1645
+ }
1646
+ case 'diamond': {
1647
+ const mid = al / 2;
1648
+ ctx.beginPath();
1649
+ ctx.moveTo(tipX, tipY);
1650
+ ctx.lineTo(tipX + mid * Math.cos(angle) + aw/2 * Math.sin(angle),
1651
+ tipY + mid * Math.sin(angle) - aw/2 * Math.cos(angle));
1652
+ ctx.lineTo(tipX + al * Math.cos(angle), tipY + al * Math.sin(angle));
1653
+ ctx.lineTo(tipX + mid * Math.cos(angle) - aw/2 * Math.sin(angle),
1654
+ tipY + mid * Math.sin(angle) + aw/2 * Math.cos(angle));
1655
+ ctx.closePath();
1656
+ ctx.fill();
1657
+ break;
1658
+ }
1659
+ case 'oval': {
1660
+ const rx = al / 2, ry = aw / 2;
1661
+ const cx = tipX + rx * Math.cos(angle), cy = tipY + rx * Math.sin(angle);
1662
+ ctx.beginPath();
1663
+ ctx.save();
1664
+ ctx.translate(cx, cy);
1665
+ ctx.rotate(angle);
1666
+ ctx.scale(1, ry / rx);
1667
+ ctx.arc(0, 0, rx, 0, Math.PI * 2);
1668
+ ctx.restore();
1669
+ ctx.fill();
1670
+ break;
1671
+ }
1672
+ }
1673
+ ctx.restore();
1674
+ }
1675
+
1676
+ export async function renderConnector(ctx, cxnSpEl, themeColors, scale) {
1677
+ const spPr = g1(cxnSpEl, 'spPr');
1678
+ const xfrm = g1(spPr, 'xfrm');
1679
+ if (!xfrm) return;
1680
+
1681
+ const off = g1(xfrm, 'off');
1682
+ const ext = g1(xfrm, 'ext');
1683
+ if (!off || !ext) return;
1684
+
1685
+ const x = attrInt(off, 'x', 0) * scale;
1686
+ const y = attrInt(off, 'y', 0) * scale;
1687
+ const w = attrInt(ext, 'cx', 0) * scale;
1688
+ const h = attrInt(ext, 'cy', 0) * scale;
1689
+ const rot = attrInt(xfrm, 'rot', 0) / 60000;
1690
+ const flipH = attr(xfrm, 'flipH', '0') === '1';
1691
+ const flipV = attr(xfrm, 'flipV', '0') === '1';
1692
+ const cx = x + w/2, cy = y + h/2;
1693
+
1694
+ const lnEl = g1(spPr, 'ln');
1695
+
1696
+ ctx.save();
1697
+ if (rot !== 0 || flipH || flipV) {
1698
+ ctx.translate(cx, cy);
1699
+ if (rot !== 0) ctx.rotate(rot * Math.PI / 180);
1700
+ if (flipH) ctx.scale(-1, 1);
1701
+ if (flipV) ctx.scale(1, -1);
1702
+ ctx.translate(-cx, -cy);
1703
+ }
1704
+
1705
+ const prstGeom = g1(spPr, 'prstGeom');
1706
+ const prst = prstGeom ? attr(prstGeom, 'prst', 'line') : 'line';
1707
+
1708
+ // For line connectors, draw the actual line and arrowheads explicitly
1709
+ // (avoids canvas path coordinate limitations)
1710
+ const isLine = prst === 'line' || prst === 'straightConnector1';
1711
+
1712
+ ctx.beginPath();
1713
+ if (isLine) {
1714
+ const x2 = flipH ? x : x + w;
1715
+ const y2 = flipV ? y : y + h;
1716
+ const x1 = flipH ? x + w : x;
1717
+ const y1 = flipV ? y + h : y;
1718
+ ctx.moveTo(x1, y1);
1719
+ ctx.lineTo(x2, y2);
1720
+ } else {
1721
+ drawPresetGeom(ctx, prst, x, y, w, h, {});
1722
+ }
1723
+
1724
+ if (lnEl) {
1725
+ applyOutline(ctx, lnEl, themeColors, scale);
1726
+ // Draw arrowheads for straight lines
1727
+ if (isLine) {
1728
+ const x1r = flipH ? x + w : x, y1r = flipV ? y + h : y;
1729
+ const x2r = flipH ? x : x + w, y2r = flipV ? y : y + h;
1730
+ drawArrowEnd(ctx, lnEl, 'headEnd', x2r, y2r, x1r, y1r, themeColors, scale);
1731
+ drawArrowEnd(ctx, lnEl, 'tailEnd', x1r, y1r, x2r, y2r, themeColors, scale);
1732
+ }
1733
+ } else {
1734
+ ctx.strokeStyle = '#000';
1735
+ ctx.lineWidth = 1;
1736
+ ctx.stroke();
1737
+ }
1738
+
1739
+ ctx.restore();
1740
+ }
1741
+
1742
+ // ═══════════════════════════════════════════════════════════════════════════════
1743
+ // BACKGROUND RENDERING
1744
+ // ═══════════════════════════════════════════════════════════════════════════════
1745
+
1746
+ export async function renderBackground(ctx, slideDoc, masterDoc, layoutDoc, rels, masterRels, imageCache, themeColors, scale, slideW, slideH) {
1747
+ const canvasW = slideW * scale;
1748
+ const canvasH = slideH * scale;
1749
+
1750
+ // Try to get background from slide, then layout, then master
1751
+ const getBg = (doc) => {
1752
+ const cSld = g1(doc, 'cSld');
1753
+ if (!cSld) return null;
1754
+ const bg = g1(cSld, 'bg');
1755
+ if (!bg) return null;
1756
+ const bgPr = g1(bg, 'bgPr');
1757
+ const bgRef = g1(bg, 'bgRef');
1758
+ return { bgPr, bgRef };
1759
+ };
1760
+
1761
+ const slideBg = slideDoc ? getBg(slideDoc) : null;
1762
+ const layoutBg = layoutDoc ? getBg(layoutDoc) : null;
1763
+ const masterBg = masterDoc ? getBg(masterDoc) : null;
1764
+
1765
+ const bgData = slideBg || layoutBg || masterBg;
1766
+
1767
+ let rendered = false;
1768
+ if (bgData) {
1769
+ const { bgPr, bgRef } = bgData;
1770
+ if (bgPr) {
1771
+ const fills = ['noFill','solidFill','gradFill','blipFill','pattFill'];
1772
+ for (const fn of fills) {
1773
+ const fillEl = g1(bgPr, fn);
1774
+ if (fillEl) {
1775
+ ctx.beginPath();
1776
+ ctx.rect(0, 0, canvasW, canvasH);
1777
+ const useCache = bgData === masterBg ? Object.assign({}, imageCache) : imageCache;
1778
+ const ok = await applyFill(ctx, fillEl, 0, 0, canvasW, canvasH, 1, themeColors, useCache);
1779
+ if (ok) rendered = true;
1780
+ break;
1781
+ }
1782
+ }
1783
+ } else if (bgRef) {
1784
+ const idx = attrInt(bgRef, 'idx', 0);
1785
+ const colorChild = findFirstColorChild(bgRef);
1786
+ const c = resolveColorElement(colorChild, themeColors);
1787
+ if (c) {
1788
+ ctx.fillStyle = colorToCss(c);
1789
+ ctx.fillRect(0, 0, canvasW, canvasH);
1790
+ rendered = true;
1791
+ }
1792
+ }
1793
+ }
1794
+
1795
+ if (!rendered) {
1796
+ // Default white background
1797
+ ctx.fillStyle = '#ffffff';
1798
+ ctx.fillRect(0, 0, canvasW, canvasH);
1799
+ }
1800
+ }
1801
+
1802
+ // ═══════════════════════════════════════════════════════════════════════════════
1803
+ // PLACEHOLDER INHERITANCE
1804
+ // Build a map of ph type/idx → { x, y, w, h, txBody } from layout & master.
1805
+ // Used when a slide's placeholder shape has no xfrm of its own.
1806
+ // ═══════════════════════════════════════════════════════════════════════════════
1807
+
1808
+ export function buildPlaceholderMap(docs) {
1809
+ // docs: [layoutDoc, masterDoc] — layout takes priority
1810
+ const map = {};
1811
+ for (const doc of docs) {
1812
+ if (!doc) continue;
1813
+ const cSld = g1(doc, 'cSld');
1814
+ const spTree = cSld ? g1(cSld, 'spTree') : null;
1815
+ if (!spTree) continue;
1816
+ for (const sp of gtn(spTree, 'sp')) {
1817
+ const nvSpPr = g1(sp, 'nvSpPr');
1818
+ const nvPr = nvSpPr ? g1(nvSpPr, 'nvPr') : null;
1819
+ const ph = nvPr ? g1(nvPr, 'ph') : null;
1820
+ if (!ph) continue;
1821
+
1822
+ const phType = attr(ph, 'type', 'body');
1823
+ const phIdx = attr(ph, 'idx', '0');
1824
+ const key = `${phType}:${phIdx}`;
1825
+
1826
+ if (map[key]) continue; // layout wins over master
1827
+
1828
+ const spPr = g1(sp, 'spPr');
1829
+ const xfrm = g1(spPr, 'xfrm');
1830
+ if (!xfrm) continue;
1831
+ const off = g1(xfrm, 'off');
1832
+ const ext = g1(xfrm, 'ext');
1833
+ if (!off || !ext) continue;
1834
+
1835
+ map[key] = {
1836
+ x: attrInt(off, 'x', 0),
1837
+ y: attrInt(off, 'y', 0),
1838
+ w: attrInt(ext, 'cx', 0),
1839
+ h: attrInt(ext, 'cy', 0),
1840
+ txBody: g1(sp, 'txBody'),
1841
+ };
1842
+ }
1843
+ }
1844
+ return map;
1845
+ }
1846
+
1847
+ // Look up placeholder position for a slide shape that has no xfrm
1848
+ export function resolvePlaceholderXfrm(spEl, placeholderMap) {
1849
+ if (!placeholderMap) return null;
1850
+ const nvSpPr = g1(spEl, 'nvSpPr');
1851
+ const nvPr = nvSpPr ? g1(nvSpPr, 'nvPr') : null;
1852
+ const ph = nvPr ? g1(nvPr, 'ph') : null;
1853
+ if (!ph) return null;
1854
+ const phType = attr(ph, 'type', 'body');
1855
+ const phIdx = attr(ph, 'idx', '0');
1856
+ // Try exact key, then idx-only, then type-only
1857
+ return placeholderMap[`${phType}:${phIdx}`]
1858
+ || placeholderMap[`${phType}:0`]
1859
+ || placeholderMap[`body:${phIdx}`]
1860
+ || null;
1861
+ }
1862
+
1863
+ // ═══════════════════════════════════════════════════════════════════════════════
1864
+ // CHART PLACEHOLDER
1865
+ // ═══════════════════════════════════════════════════════════════════════════════
1866
+
1867
+ export async function renderGraphicFrame(ctx, graphicFrame, themeColors, themeData, scale, files, slideRels) {
1868
+ const graphic = g1(graphicFrame, 'graphic');
1869
+ const graphicData = graphic ? g1(graphic, 'graphicData') : null;
1870
+ const uri = graphicData ? attr(graphicData, 'uri', '') : '';
1871
+
1872
+ // Table
1873
+ if (g1(graphicFrame, 'tbl') || (graphicData && g1(graphicData, 'tbl'))) {
1874
+ return renderTable(ctx, graphicFrame, themeColors, themeData, scale);
1875
+ }
1876
+
1877
+ const xfrm = g1(graphicFrame, 'xfrm');
1878
+ if (!xfrm) return;
1879
+ const off = g1(xfrm, 'off'), ext = g1(xfrm, 'ext');
1880
+ if (!off || !ext) return;
1881
+ const fx = attrInt(off, 'x', 0) * scale;
1882
+ const fy = attrInt(off, 'y', 0) * scale;
1883
+ const fw = attrInt(ext, 'cx', 0) * scale;
1884
+ const fh = attrInt(ext, 'cy', 0) * scale;
1885
+ if (fw <= 0 || fh <= 0) return;
1886
+
1887
+ const isChart = uri.includes('chart');
1888
+ const isDiagram = uri.includes('diagram');
1889
+
1890
+ // ── Real chart rendering ─────────────────────────────────────────────────
1891
+ if (isChart && files && slideRels) {
1892
+ // Find the chart relationship
1893
+ const chartEl = graphicData ? g1(graphicData, 'chart') : null;
1894
+ const rId = chartEl
1895
+ ? (chartEl.getAttribute('r:id') || chartEl.getAttribute('id'))
1896
+ : null;
1897
+ const rel = rId ? slideRels[rId] : null;
1898
+
1899
+ if (rel && files[rel.fullPath]) {
1900
+ const chartXml = new TextDecoder().decode(files[rel.fullPath]);
1901
+ const chartDoc = parseXml(chartXml);
1902
+ renderChart(ctx, chartDoc, fx, fy, fw, fh, themeColors, scale);
1903
+ return;
1904
+ }
1905
+ }
1906
+
1907
+ // ── SmartArt / Diagram rendering ─────────────────────────────────────────
1908
+ if (isDiagram && files && slideRels) {
1909
+ const dgmEl = graphicData ? g1(graphicData, 'relIds') : null;
1910
+ const dmId = dgmEl ? (dgmEl.getAttribute('r:dm') || dgmEl.getAttribute('dm')) : null;
1911
+ const rel = dmId ? slideRels[dmId] : null;
1912
+ if (rel && files[rel.fullPath]) {
1913
+ const dataXml = new TextDecoder().decode(files[rel.fullPath]);
1914
+ const dataDoc = parseXml(dataXml);
1915
+ // Try to find a layout file
1916
+ const loId = dgmEl ? (dgmEl.getAttribute('r:lo') || dgmEl.getAttribute('lo')) : null;
1917
+ const loRel = loId ? slideRels[loId] : null;
1918
+ const layoutDoc = (loRel && files[loRel.fullPath])
1919
+ ? parseXml(new TextDecoder().decode(files[loRel.fullPath]))
1920
+ : null;
1921
+ // Delegate to SmartArt renderer (imported lazily to keep this file lean)
1922
+ const { renderSmartArt } = await import('./smartart.js');
1923
+ renderSmartArt(ctx, dataDoc, layoutDoc, fx, fy, fw, fh, themeColors, scale);
1924
+ return;
1925
+ }
1926
+ }
1927
+
1928
+ // ── Fallback placeholder ─────────────────────────────────────────────────
1929
+ const label = isChart ? '📊 Chart' : isDiagram ? '🔷 Diagram' : '⬛ Graphic';
1930
+ ctx.save();
1931
+ ctx.fillStyle = '#f4f4f8';
1932
+ ctx.strokeStyle = '#ccccdd';
1933
+ ctx.lineWidth = 1;
1934
+ ctx.fillRect(fx, fy, fw, fh);
1935
+ ctx.strokeRect(fx, fy, fw, fh);
1936
+ ctx.fillStyle = '#999';
1937
+ ctx.font = `${Math.min(fw * 0.07, 16 * scale)}px sans-serif`;
1938
+ ctx.textAlign = 'center';
1939
+ ctx.textBaseline = 'middle';
1940
+ ctx.fillText(label, fx + fw / 2, fy + fh / 2);
1941
+ ctx.textAlign = 'start';
1942
+ ctx.textBaseline = 'alphabetic';
1943
+ ctx.restore();
1944
+ }
1945
+
1946
+ // ═══════════════════════════════════════════════════════════════════════════════
1947
+ // SHAPE TREE RENDERING
1948
+ // ═══════════════════════════════════════════════════════════════════════════════
1949
+
1950
+ export async function renderSpTree(ctx, spTreeEl, rels, imageCache, themeColors, themeData, scale, placeholderMap, files) {
1951
+ if (!spTreeEl) return;
1952
+ for (const child of spTreeEl.children) {
1953
+ const ln = child.localName;
1954
+ try {
1955
+ if (ln === 'sp') await renderShape(ctx, child, rels, imageCache, themeColors, themeData, scale, null, placeholderMap);
1956
+ else if (ln === 'pic') await renderPicture(ctx, child, rels, imageCache, themeColors, scale);
1957
+ else if (ln === 'grpSp') await renderGroupShape(ctx, child, rels, imageCache, themeColors, themeData, scale);
1958
+ else if (ln === 'graphicFrame') await renderGraphicFrame(ctx, child, themeColors, themeData, scale, files, rels);
1959
+ else if (ln === 'cxnSp') await renderConnector(ctx, child, themeColors, scale);
1960
+ } catch(e) {
1961
+ console.warn('Error rendering shape:', ln, e);
1962
+ }
1963
+ }
1964
+ }