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.
@@ -0,0 +1,696 @@
1
+ /**
2
+ * smartart.js — SmartArt / Diagram renderer.
3
+ *
4
+ * Parses ppt/diagrams/data*.xml and layout*.xml and renders the 12 most
5
+ * common SmartArt layout families. Unknown layouts render a clean node-list
6
+ * fallback that shows all the text content.
7
+ *
8
+ * Supported layout families (detected from layout XML typ attribute):
9
+ * process / chevronList / accentProcess / blockList → Process flow
10
+ * cycle / continuousCycle / blockCycle → Cycle
11
+ * hierarchy / orgChart / horizontalOrg → Hierarchy / org chart
12
+ * radial / accentedRadial / cycleMatrix → Radial
13
+ * pyramid / invertedPyramid → Pyramid
14
+ * funnel → Funnel
15
+ * venn / linearVenn → Venn
16
+ * list / verticalBoxList / pictureAccentList → List
17
+ * matrix / accentedMatrix → 2×2 matrix
18
+ * relationship / divergingRadial → Relationship
19
+ * stapledDocument / squareAccentList → fallback list
20
+ */
21
+
22
+ import { g1, gtn, attr } from './utils.js';
23
+
24
+ // ── Colour palette ───────────────────────────────────────────────────────────
25
+ const PALETTE = [
26
+ '#4472C4', '#ED7D31', '#A9D18E', '#FF0000',
27
+ '#FFC000', '#5B9BD5', '#70AD47', '#C00000',
28
+ '#7030A0', '#00B0F0',
29
+ ];
30
+
31
+ function nodeColor(i, themeColors) {
32
+ const key = `accent${(i % 6) + 1}`;
33
+ if (themeColors[key]) return '#' + themeColors[key];
34
+ return PALETTE[i % PALETTE.length];
35
+ }
36
+
37
+ function lighten(hex, amount = 0.45) {
38
+ const r = parseInt(hex.slice(1, 3), 16);
39
+ const g = parseInt(hex.slice(3, 5), 16);
40
+ const b = parseInt(hex.slice(5, 7), 16);
41
+ const lr = Math.round(r + (255 - r) * amount);
42
+ const lg = Math.round(g + (255 - g) * amount);
43
+ const lb = Math.round(b + (255 - b) * amount);
44
+ return `rgb(${lr},${lg},${lb})`;
45
+ }
46
+
47
+ // ── XML helpers ──────────────────────────────────────────────────────────────
48
+
49
+ /** Extract all text from a SmartArt node element (pt). */
50
+ function nodeText(ptEl) {
51
+ const texts = [];
52
+ for (const t of gtn(ptEl, 't')) {
53
+ const txt = t.textContent.trim();
54
+ if (txt) texts.push(txt);
55
+ }
56
+ return texts.join(' ');
57
+ }
58
+
59
+ /** Read all data nodes (non-connector pts) from diagram data XML. */
60
+ function readNodes(dataDoc) {
61
+ if (!dataDoc) return [];
62
+ const ptLst = g1(dataDoc, 'ptLst');
63
+ if (!ptLst) return [];
64
+ const nodes = [];
65
+ for (const pt of gtn(ptLst, 'pt')) {
66
+ const type = attr(pt, 'type', 'node');
67
+ if (type === 'parTrans' || type === 'sibTrans') continue;
68
+ const text = nodeText(pt);
69
+ if (text || type === 'node') {
70
+ nodes.push({ id: attr(pt, 'modelId', ''), type, text });
71
+ }
72
+ }
73
+ return nodes.filter(n => n.text);
74
+ }
75
+
76
+ /** Detect layout type from layout XML. */
77
+ function detectLayout(layoutDoc) {
78
+ if (!layoutDoc) return 'list';
79
+ const diagDef = g1(layoutDoc, 'layoutDef') || layoutDoc;
80
+ const typ = attr(diagDef, 'uniqueId', '')
81
+ || attr(diagDef, 'defStyle', '')
82
+ || '';
83
+ const t = typ.toLowerCase();
84
+ if (t.includes('chevron') || t.includes('arrowprocess') || t.includes('process'))
85
+ return 'process';
86
+ if (t.includes('cycle') || t.includes('continuouscycle'))
87
+ return 'cycle';
88
+ if (t.includes('hierarchy') || t.includes('orgchart') || t.includes('org'))
89
+ return 'hierarchy';
90
+ if (t.includes('radial') || t.includes('diverging'))
91
+ return 'radial';
92
+ if (t.includes('pyramid') || t.includes('invertedpyramid'))
93
+ return 'pyramid';
94
+ if (t.includes('funnel'))
95
+ return 'funnel';
96
+ if (t.includes('venn'))
97
+ return 'venn';
98
+ if (t.includes('matrix'))
99
+ return 'matrix';
100
+ if (t.includes('list') || t.includes('bullet'))
101
+ return 'list';
102
+ if (t.includes('relationship') || t.includes('balance'))
103
+ return 'relationship';
104
+ return 'list';
105
+ }
106
+
107
+ // ── Text rendering ───────────────────────────────────────────────────────────
108
+
109
+ function drawText(ctx, text, x, y, maxW, maxH, size, color = '#fff', align = 'center') {
110
+ if (!text) return;
111
+ ctx.save();
112
+ ctx.fillStyle = color;
113
+ ctx.textAlign = align;
114
+ ctx.textBaseline = 'middle';
115
+ const words = text.split(' ');
116
+ const lineH = size * 1.3;
117
+ let lines = [];
118
+ let line = '';
119
+ for (const w of words) {
120
+ const test = line ? line + ' ' + w : w;
121
+ if (ctx.measureText(test).width > maxW - 4 && line) {
122
+ lines.push(line);
123
+ line = w;
124
+ } else {
125
+ line = test;
126
+ }
127
+ }
128
+ if (line) lines.push(line);
129
+ // Limit lines to fit height
130
+ const maxLines = Math.max(1, Math.floor(maxH / lineH));
131
+ if (lines.length > maxLines) lines = lines.slice(0, maxLines);
132
+ const startY = y - (lines.length - 1) * lineH / 2;
133
+ for (let i = 0; i < lines.length; i++) {
134
+ ctx.fillText(lines[i], x, startY + i * lineH);
135
+ }
136
+ ctx.restore();
137
+ }
138
+
139
+ function autoFontSize(text, w, h, maxSz = 14) {
140
+ const approxCharsPerLine = Math.floor(w / (maxSz * 0.55));
141
+ const words = text.split(' ');
142
+ const lines = Math.ceil(words.join(' ').length / Math.max(approxCharsPerLine, 1));
143
+ const byH = h / (lines * 1.4);
144
+ return Math.max(8, Math.min(maxSz, byH));
145
+ }
146
+
147
+ // ── Shared shape primitives ──────────────────────────────────────────────────
148
+
149
+ function fillRoundRect(ctx, x, y, w, h, r, fill, stroke) {
150
+ ctx.save();
151
+ ctx.beginPath();
152
+ r = Math.min(r, w / 2, h / 2);
153
+ ctx.moveTo(x + r, y);
154
+ ctx.arcTo(x + w, y, x + w, y + h, r);
155
+ ctx.arcTo(x + w, y + h, x, y + h, r);
156
+ ctx.arcTo(x, y + h, x, y, r);
157
+ ctx.arcTo(x, y, x + w, y, r);
158
+ ctx.closePath();
159
+ if (fill) { ctx.fillStyle = fill; ctx.fill(); }
160
+ if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.stroke(); }
161
+ ctx.restore();
162
+ }
163
+
164
+ function drawArrow(ctx, x1, y1, x2, y2, color, lw = 1.5) {
165
+ const angle = Math.atan2(y2 - y1, x2 - x1);
166
+ const headLen = 8;
167
+ ctx.save();
168
+ ctx.strokeStyle = color;
169
+ ctx.fillStyle = color;
170
+ ctx.lineWidth = lw;
171
+ ctx.beginPath();
172
+ ctx.moveTo(x1, y1);
173
+ ctx.lineTo(x2, y2);
174
+ ctx.stroke();
175
+ ctx.beginPath();
176
+ ctx.moveTo(x2, y2);
177
+ ctx.lineTo(x2 - headLen * Math.cos(angle - 0.4), y2 - headLen * Math.sin(angle - 0.4));
178
+ ctx.lineTo(x2 - headLen * Math.cos(angle + 0.4), y2 - headLen * Math.sin(angle + 0.4));
179
+ ctx.closePath();
180
+ ctx.fill();
181
+ ctx.restore();
182
+ }
183
+
184
+ // ── Layout renderers ─────────────────────────────────────────────────────────
185
+
186
+ function renderProcess(ctx, nodes, x, y, w, h, themeColors, scale) {
187
+ if (!nodes.length) return;
188
+ const N = nodes.length;
189
+ const itemW = w / N;
190
+ const pad = Math.min(itemW * 0.08, 10 * scale);
191
+ const arrowW = Math.min(itemW * 0.12, 18 * scale);
192
+ const boxW = itemW - arrowW - pad;
193
+ const boxH = h * 0.7;
194
+ const boxY = y + (h - boxH) / 2;
195
+
196
+ for (let i = 0; i < N; i++) {
197
+ const color = nodeColor(i, themeColors);
198
+ const bx = x + itemW * i + pad / 2;
199
+ const isLast = i === N - 1;
200
+
201
+ // Chevron arrow shape
202
+ ctx.save();
203
+ ctx.beginPath();
204
+ const tipX = bx + boxW + (isLast ? 0 : arrowW);
205
+ ctx.moveTo(bx, boxY);
206
+ ctx.lineTo(bx + boxW, boxY);
207
+ if (!isLast) ctx.lineTo(tipX, boxY + boxH / 2);
208
+ ctx.lineTo(bx + boxW, boxY + boxH);
209
+ ctx.lineTo(bx, boxY + boxH);
210
+ if (i > 0) ctx.lineTo(bx + arrowW, boxY + boxH / 2);
211
+ ctx.closePath();
212
+ ctx.fillStyle = color;
213
+ ctx.fill();
214
+ ctx.restore();
215
+
216
+ const fs = autoFontSize(nodes[i].text, boxW - arrowW, boxH) * scale;
217
+ ctx.font = `${fs}px sans-serif`;
218
+ const cx2 = bx + (boxW + (i > 0 ? arrowW : 0)) / 2;
219
+ drawText(ctx, nodes[i].text, cx2, boxY + boxH / 2, boxW, boxH, fs);
220
+ }
221
+ }
222
+
223
+ function renderCycle(ctx, nodes, x, y, w, h, themeColors, scale) {
224
+ if (!nodes.length) return;
225
+ const N = nodes.length;
226
+ const pcx = x + w / 2;
227
+ const pcy = y + h / 2;
228
+ const orbitR = Math.min(w, h) * 0.33;
229
+ const nodeR = Math.min(orbitR * 0.32, 40 * scale);
230
+
231
+ // Draw connecting ring
232
+ ctx.save();
233
+ ctx.strokeStyle = '#ccc';
234
+ ctx.lineWidth = 2 * scale;
235
+ ctx.setLineDash([4 * scale, 4 * scale]);
236
+ ctx.beginPath();
237
+ ctx.arc(pcx, pcy, orbitR, 0, Math.PI * 2);
238
+ ctx.stroke();
239
+ ctx.setLineDash([]);
240
+ ctx.restore();
241
+
242
+ for (let i = 0; i < N; i++) {
243
+ const angle = (i / N) * Math.PI * 2 - Math.PI / 2;
244
+ const nx = pcx + orbitR * Math.cos(angle);
245
+ const ny = pcy + orbitR * Math.sin(angle);
246
+ const color = nodeColor(i, themeColors);
247
+
248
+ // Circle node
249
+ ctx.save();
250
+ ctx.beginPath();
251
+ ctx.arc(nx, ny, nodeR, 0, Math.PI * 2);
252
+ ctx.fillStyle = color;
253
+ ctx.fill();
254
+ ctx.restore();
255
+
256
+ // Curved arrow to next node
257
+ if (N > 1) {
258
+ const nextAngle = ((i + 1) / N) * Math.PI * 2 - Math.PI / 2;
259
+ const midAngle = (angle + nextAngle) / 2;
260
+ const ax1 = pcx + orbitR * Math.cos(angle + 0.15);
261
+ const ay1 = pcy + orbitR * Math.sin(angle + 0.15);
262
+ const ax2 = pcx + orbitR * Math.cos(nextAngle - 0.15);
263
+ const ay2 = pcy + orbitR * Math.sin(nextAngle - 0.15);
264
+ ctx.save();
265
+ ctx.strokeStyle = color + '99';
266
+ ctx.lineWidth = 1.5 * scale;
267
+ ctx.beginPath();
268
+ ctx.moveTo(ax1, ay1);
269
+ ctx.quadraticCurveTo(pcx + orbitR * 1.1 * Math.cos(midAngle),
270
+ pcy + orbitR * 1.1 * Math.sin(midAngle), ax2, ay2);
271
+ ctx.stroke();
272
+ ctx.restore();
273
+ }
274
+
275
+ const fs = autoFontSize(nodes[i].text, nodeR * 1.7, nodeR * 1.2) * scale;
276
+ ctx.font = `${fs}px sans-serif`;
277
+ drawText(ctx, nodes[i].text, nx, ny, nodeR * 2, nodeR * 1.8, fs);
278
+ }
279
+ }
280
+
281
+ function renderHierarchy(ctx, nodes, x, y, w, h, themeColors, scale) {
282
+ if (!nodes.length) return;
283
+ const root = nodes[0];
284
+ const children = nodes.slice(1);
285
+ const N = Math.max(children.length, 1);
286
+
287
+ const rowH = h / (children.length > 0 ? 2.8 : 1.2);
288
+ const boxH = rowH * 0.75;
289
+ const rootW = Math.min(w * 0.3, 160 * scale);
290
+ const rootH = boxH;
291
+ const rootX = x + (w - rootW) / 2;
292
+ const rootY = y + (rowH - rootH) / 2;
293
+ const color0 = nodeColor(0, themeColors);
294
+
295
+ fillRoundRect(ctx, rootX, rootY, rootW, rootH, 6 * scale, color0, null);
296
+ const fs0 = autoFontSize(root.text, rootW - 10 * scale, rootH) * scale;
297
+ ctx.font = `bold ${fs0}px sans-serif`;
298
+ drawText(ctx, root.text, rootX + rootW / 2, rootY + rootH / 2, rootW - 10, rootH, fs0);
299
+
300
+ if (!children.length) return;
301
+
302
+ const childW = (w - 20 * scale) / N;
303
+ const childRowY = y + rowH * 1.5;
304
+
305
+ // Connector from root
306
+ const lineStartY = rootY + rootH;
307
+ const lineEndY = childRowY - 4 * scale;
308
+ ctx.save();
309
+ ctx.strokeStyle = '#999';
310
+ ctx.lineWidth = 1.5 * scale;
311
+ ctx.beginPath();
312
+ ctx.moveTo(rootX + rootW / 2, lineStartY);
313
+ ctx.lineTo(rootX + rootW / 2, (lineStartY + lineEndY) / 2);
314
+ ctx.lineTo(x + 10 * scale, (lineStartY + lineEndY) / 2);
315
+ ctx.lineTo(x + w - 10 * scale, (lineStartY + lineEndY) / 2);
316
+ ctx.stroke();
317
+ ctx.restore();
318
+
319
+ for (let i = 0; i < children.length; i++) {
320
+ const cx2 = x + childW * i + 8 * scale;
321
+ const cy2 = childRowY;
322
+ const cw2 = childW - 16 * scale;
323
+ const ch2 = boxH;
324
+ const color = nodeColor(i + 1, themeColors);
325
+
326
+ // Vertical line from horizontal bus
327
+ ctx.save();
328
+ ctx.strokeStyle = '#999';
329
+ ctx.lineWidth = 1.5 * scale;
330
+ ctx.beginPath();
331
+ ctx.moveTo(cx2 + cw2 / 2, (lineStartY + lineEndY) / 2);
332
+ ctx.lineTo(cx2 + cw2 / 2, cy2);
333
+ ctx.stroke();
334
+ ctx.restore();
335
+
336
+ fillRoundRect(ctx, cx2, cy2, cw2, ch2, 5 * scale, color, null);
337
+ const fs = autoFontSize(children[i].text, cw2 - 8 * scale, ch2) * scale;
338
+ ctx.font = `${fs}px sans-serif`;
339
+ drawText(ctx, children[i].text, cx2 + cw2 / 2, cy2 + ch2 / 2, cw2 - 8, ch2, fs);
340
+ }
341
+ }
342
+
343
+ function renderRadial(ctx, nodes, x, y, w, h, themeColors, scale) {
344
+ if (!nodes.length) return;
345
+ const center = nodes[0];
346
+ const spokes = nodes.slice(1);
347
+ const pcx = x + w / 2;
348
+ const pcy = y + h / 2;
349
+ const coreR = Math.min(w, h) * 0.18;
350
+ const orbitR = Math.min(w, h) * 0.36;
351
+ const nodeR = Math.min(orbitR * 0.28, 38 * scale);
352
+ const N = spokes.length || 1;
353
+
354
+ // Center circle
355
+ const c0 = nodeColor(0, themeColors);
356
+ ctx.save();
357
+ ctx.beginPath();
358
+ ctx.arc(pcx, pcy, coreR, 0, Math.PI * 2);
359
+ ctx.fillStyle = c0;
360
+ ctx.fill();
361
+ ctx.restore();
362
+ const cfs = autoFontSize(center.text, coreR * 1.6, coreR * 1.2) * scale;
363
+ ctx.font = `bold ${cfs}px sans-serif`;
364
+ drawText(ctx, center.text, pcx, pcy, coreR * 2, coreR * 2, cfs);
365
+
366
+ for (let i = 0; i < N; i++) {
367
+ const angle = (i / N) * Math.PI * 2 - Math.PI / 2;
368
+ const nx = pcx + orbitR * Math.cos(angle);
369
+ const ny = pcy + orbitR * Math.sin(angle);
370
+ const color = nodeColor(i + 1, themeColors);
371
+
372
+ // Spoke line
373
+ const lx1 = pcx + coreR * Math.cos(angle);
374
+ const ly1 = pcy + coreR * Math.sin(angle);
375
+ const lx2 = nx - nodeR * Math.cos(angle);
376
+ const ly2 = ny - nodeR * Math.sin(angle);
377
+ drawArrow(ctx, lx1, ly1, lx2, ly2, color + '99', 1.5 * scale);
378
+
379
+ ctx.save();
380
+ ctx.beginPath();
381
+ ctx.arc(nx, ny, nodeR, 0, Math.PI * 2);
382
+ ctx.fillStyle = color;
383
+ ctx.fill();
384
+ ctx.restore();
385
+
386
+ const fs = autoFontSize(spokes[i].text, nodeR * 1.7, nodeR * 1.2) * scale;
387
+ ctx.font = `${fs}px sans-serif`;
388
+ drawText(ctx, spokes[i].text, nx, ny, nodeR * 2, nodeR * 1.8, fs);
389
+ }
390
+ }
391
+
392
+ function renderPyramid(ctx, nodes, x, y, w, h, themeColors, scale) {
393
+ if (!nodes.length) return;
394
+ const N = nodes.length;
395
+ const layerH = h / N;
396
+ for (let i = 0; i < N; i++) {
397
+ const t = (N - i) / N; // fraction of base width at this layer
398
+ const layerW = w * t;
399
+ const lx = x + (w - layerW) / 2;
400
+ const ly = y + i * layerH;
401
+ const color = nodeColor(i, themeColors);
402
+
403
+ ctx.save();
404
+ ctx.beginPath();
405
+ ctx.moveTo(lx, ly + layerH);
406
+ ctx.lineTo(lx + layerW, ly + layerH);
407
+ const topW = w * ((N - i - 1) / N);
408
+ ctx.lineTo(x + (w + topW) / 2, ly);
409
+ ctx.lineTo(x + (w - topW) / 2, ly);
410
+ ctx.closePath();
411
+ ctx.fillStyle = color;
412
+ ctx.strokeStyle = '#fff';
413
+ ctx.lineWidth = 2 * scale;
414
+ ctx.fill();
415
+ ctx.stroke();
416
+ ctx.restore();
417
+
418
+ const fs = autoFontSize(nodes[i].text, layerW * 0.6, layerH * 0.7) * scale;
419
+ ctx.font = `${fs}px sans-serif`;
420
+ drawText(ctx, nodes[i].text, x + w / 2, ly + layerH / 2, layerW * 0.6, layerH * 0.7, fs);
421
+ }
422
+ }
423
+
424
+ function renderFunnel(ctx, nodes, x, y, w, h, themeColors, scale) {
425
+ if (!nodes.length) return;
426
+ const N = nodes.length;
427
+ const layerH = h / N;
428
+
429
+ for (let i = 0; i < N; i++) {
430
+ // Funnel: wide at top, narrow at bottom
431
+ const topFrac = 1 - (i / N) * 0.55;
432
+ const botFrac = 1 - ((i + 1) / N) * 0.55;
433
+ const topW = w * topFrac;
434
+ const botW = w * botFrac;
435
+ const lx1 = x + (w - topW) / 2;
436
+ const lx2 = x + (w - botW) / 2;
437
+ const ly = y + i * layerH;
438
+ const color = nodeColor(i, themeColors);
439
+
440
+ ctx.save();
441
+ ctx.beginPath();
442
+ ctx.moveTo(lx1, ly);
443
+ ctx.lineTo(lx1 + topW, ly);
444
+ ctx.lineTo(lx2 + botW, ly + layerH);
445
+ ctx.lineTo(lx2, ly + layerH);
446
+ ctx.closePath();
447
+ ctx.fillStyle = color;
448
+ ctx.strokeStyle = '#fff';
449
+ ctx.lineWidth = 2 * scale;
450
+ ctx.fill();
451
+ ctx.stroke();
452
+ ctx.restore();
453
+
454
+ const mw = (topW + botW) / 2;
455
+ const fs = autoFontSize(nodes[i].text, mw * 0.8, layerH * 0.7) * scale;
456
+ ctx.font = `${fs}px sans-serif`;
457
+ drawText(ctx, nodes[i].text, x + w / 2, ly + layerH / 2, mw * 0.8, layerH * 0.7, fs);
458
+ }
459
+ }
460
+
461
+ function renderVenn(ctx, nodes, x, y, w, h, themeColors, scale) {
462
+ if (!nodes.length) return;
463
+ const N = Math.min(nodes.length, 4); // Venn beyond 4 is unreadable
464
+ const pcx = x + w / 2;
465
+ const pcy = y + h / 2;
466
+ const cr = Math.min(w, h) * (N <= 2 ? 0.32 : 0.28);
467
+ const spread = cr * 0.65;
468
+
469
+ const angles = [];
470
+ for (let i = 0; i < N; i++) angles.push((i / N) * Math.PI * 2 - Math.PI / 2);
471
+
472
+ for (let i = 0; i < N; i++) {
473
+ const nx = N > 1 ? pcx + spread * Math.cos(angles[i]) : pcx;
474
+ const ny = N > 1 ? pcy + spread * Math.sin(angles[i]) : pcy;
475
+ const color = nodeColor(i, themeColors);
476
+
477
+ ctx.save();
478
+ ctx.globalAlpha = 0.55;
479
+ ctx.beginPath();
480
+ ctx.arc(nx, ny, cr, 0, Math.PI * 2);
481
+ ctx.fillStyle = color;
482
+ ctx.fill();
483
+ ctx.globalAlpha = 1;
484
+ ctx.strokeStyle = '#fff';
485
+ ctx.lineWidth = 1.5 * scale;
486
+ ctx.stroke();
487
+ ctx.restore();
488
+
489
+ const lx = N > 1 ? pcx + (spread + cr * 0.45) * Math.cos(angles[i]) : pcx;
490
+ const ly = N > 1 ? pcy + (spread + cr * 0.45) * Math.sin(angles[i]) : pcy;
491
+ const fs = autoFontSize(nodes[i].text, cr * 1.2, cr * 0.7) * scale;
492
+ ctx.font = `${fs}px sans-serif`;
493
+ drawText(ctx, nodes[i].text, lx, ly, cr * 1.2, cr * 0.7, fs, '#333');
494
+ }
495
+ }
496
+
497
+ function renderMatrix(ctx, nodes, x, y, w, h, themeColors, scale) {
498
+ const grid = [
499
+ nodes[0] || { text: '' }, nodes[1] || { text: '' },
500
+ nodes[2] || { text: '' }, nodes[3] || { text: '' },
501
+ ];
502
+ const cellW = w / 2;
503
+ const cellH = h / 2;
504
+ const pad = 8 * scale;
505
+
506
+ for (let row = 0; row < 2; row++) {
507
+ for (let col = 0; col < 2; col++) {
508
+ const idx = row * 2 + col;
509
+ const cx2 = x + col * cellW + pad;
510
+ const cy2 = y + row * cellH + pad;
511
+ const cw2 = cellW - pad * 2;
512
+ const ch2 = cellH - pad * 2;
513
+ const color = nodeColor(idx, themeColors);
514
+ fillRoundRect(ctx, cx2, cy2, cw2, ch2, 8 * scale, color, null);
515
+ const fs = autoFontSize(grid[idx].text, cw2 - 10, ch2) * scale;
516
+ ctx.font = `${fs}px sans-serif`;
517
+ drawText(ctx, grid[idx].text, cx2 + cw2 / 2, cy2 + ch2 / 2, cw2 - 10, ch2, fs);
518
+ }
519
+ }
520
+ }
521
+
522
+ function renderList(ctx, nodes, x, y, w, h, themeColors, scale) {
523
+ if (!nodes.length) return;
524
+ const N = nodes.length;
525
+ const itemH = h / N;
526
+ const dotR = Math.min(itemH * 0.2, 14 * scale);
527
+ const pad = dotR * 3;
528
+
529
+ for (let i = 0; i < N; i++) {
530
+ const iy = y + i * itemH;
531
+ const cy2 = iy + itemH / 2;
532
+ const color = nodeColor(i, themeColors);
533
+
534
+ // Dot
535
+ ctx.save();
536
+ ctx.beginPath();
537
+ ctx.arc(x + dotR, cy2, dotR, 0, Math.PI * 2);
538
+ ctx.fillStyle = color;
539
+ ctx.fill();
540
+ ctx.restore();
541
+
542
+ // Background bar
543
+ fillRoundRect(ctx, x + pad, iy + itemH * 0.1, w - pad - 4 * scale, itemH * 0.8,
544
+ 4 * scale, color + '22', null);
545
+
546
+ // Text
547
+ const fs = autoFontSize(nodes[i].text, w - pad - 20 * scale, itemH * 0.7) * scale;
548
+ ctx.font = `${fs}px sans-serif`;
549
+ ctx.fillStyle = '#333';
550
+ ctx.textAlign = 'left';
551
+ ctx.textBaseline = 'middle';
552
+ ctx.fillText(nodes[i].text, x + pad + 8 * scale, cy2);
553
+ }
554
+ }
555
+
556
+ function renderRelationship(ctx, nodes, x, y, w, h, themeColors, scale) {
557
+ if (nodes.length < 2) { renderList(ctx, nodes, x, y, w, h, themeColors, scale); return; }
558
+ const center = nodes[Math.floor(nodes.length / 2)];
559
+ const left = nodes.slice(0, Math.floor(nodes.length / 2));
560
+ const right = nodes.slice(Math.floor(nodes.length / 2) + 1);
561
+
562
+ const pcx = x + w / 2;
563
+ const pcy = y + h / 2;
564
+ const midR = Math.min(w * 0.14, h * 0.25);
565
+
566
+ // Center bubble
567
+ const c0 = nodeColor(Math.floor(nodes.length / 2), themeColors);
568
+ ctx.save();
569
+ ctx.beginPath();
570
+ ctx.arc(pcx, pcy, midR, 0, Math.PI * 2);
571
+ ctx.fillStyle = c0;
572
+ ctx.fill();
573
+ ctx.restore();
574
+ const cfs = autoFontSize(center.text, midR * 1.6, midR * 1.2) * scale;
575
+ ctx.font = `bold ${cfs}px sans-serif`;
576
+ drawText(ctx, center.text, pcx, pcy, midR * 2, midR * 1.8, cfs);
577
+
578
+ const rowH = h / Math.max(left.length, right.length, 1);
579
+ const boxW = w * 0.3;
580
+ const boxH = rowH * 0.7;
581
+
582
+ const drawSide = (group, side) => {
583
+ const bx = side === 'left' ? x : x + w - boxW;
584
+ const arrowX2 = side === 'left' ? pcx - midR : pcx + midR;
585
+ for (let i = 0; i < group.length; i++) {
586
+ const by = y + rowH * i + (rowH - boxH) / 2;
587
+ const color = nodeColor(i + (side === 'right' ? left.length + 1 : 0), themeColors);
588
+ fillRoundRect(ctx, bx, by, boxW, boxH, 5 * scale, color, null);
589
+ const fs = autoFontSize(group[i].text, boxW - 8, boxH) * scale;
590
+ ctx.font = `${fs}px sans-serif`;
591
+ drawText(ctx, group[i].text, bx + boxW / 2, by + boxH / 2, boxW - 8, boxH, fs);
592
+ const arrowX1 = side === 'left' ? bx + boxW : bx;
593
+ drawArrow(ctx, arrowX1, by + boxH / 2, arrowX2, pcy, color + '88', 1.5 * scale);
594
+ }
595
+ };
596
+
597
+ drawSide(left, 'left');
598
+ drawSide(right, 'right');
599
+ }
600
+
601
+ // ── Fallback: generic node grid ──────────────────────────────────────────────
602
+
603
+ function renderFallback(ctx, nodes, x, y, w, h, themeColors, scale) {
604
+ const N = nodes.length;
605
+ if (!N) return;
606
+ const cols = Math.ceil(Math.sqrt(N));
607
+ const rows = Math.ceil(N / cols);
608
+ const cellW = w / cols;
609
+ const cellH = h / rows;
610
+ const pad = Math.min(cellW, cellH) * 0.1;
611
+
612
+ for (let i = 0; i < N; i++) {
613
+ const col = i % cols;
614
+ const row = Math.floor(i / cols);
615
+ const bx = x + col * cellW + pad;
616
+ const by = y + row * cellH + pad;
617
+ const bw = cellW - pad * 2;
618
+ const bh = cellH - pad * 2;
619
+ const color = nodeColor(i, themeColors);
620
+ fillRoundRect(ctx, bx, by, bw, bh, 8 * scale, color, null);
621
+ const light = lighten(color);
622
+ // Subtle gradient shine
623
+ ctx.save();
624
+ const grad = ctx.createLinearGradient(bx, by, bx, by + bh * 0.5);
625
+ grad.addColorStop(0, 'rgba(255,255,255,0.18)');
626
+ grad.addColorStop(1, 'rgba(255,255,255,0)');
627
+ ctx.fillStyle = grad;
628
+ ctx.beginPath();
629
+ ctx.roundRect?.(bx, by, bw, bh, 8 * scale);
630
+ ctx.fill();
631
+ ctx.restore();
632
+
633
+ const fs = autoFontSize(nodes[i].text, bw - 12 * scale, bh) * scale;
634
+ ctx.font = `${fs}px sans-serif`;
635
+ drawText(ctx, nodes[i].text, bx + bw / 2, by + bh / 2, bw - 12 * scale, bh, fs);
636
+ }
637
+ }
638
+
639
+ // ── Main entry ───────────────────────────────────────────────────────────────
640
+
641
+ /**
642
+ * Render a SmartArt diagram.
643
+ *
644
+ * @param {CanvasRenderingContext2D} ctx
645
+ * @param {Document} dataDoc — ppt/diagrams/data*.xml parsed
646
+ * @param {Document} layoutDoc — ppt/diagrams/layout*.xml parsed (may be null)
647
+ * @param {number} x, y, w, h — bounding box in canvas pixels
648
+ * @param {object} themeColors
649
+ * @param {number} scale
650
+ */
651
+ export function renderSmartArt(ctx, dataDoc, layoutDoc, x, y, w, h, themeColors, scale) {
652
+ const nodes = readNodes(dataDoc);
653
+ const layout = detectLayout(layoutDoc);
654
+ const pad = 16 * scale;
655
+
656
+ ctx.save();
657
+ // Subtle background
658
+ ctx.fillStyle = '#f7f9fc';
659
+ ctx.strokeStyle = '#e0e4ec';
660
+ ctx.lineWidth = 1;
661
+ ctx.beginPath();
662
+ ctx.roundRect?.(x, y, w, h, 6 * scale) || ctx.rect(x, y, w, h);
663
+ ctx.fill();
664
+ ctx.stroke();
665
+
666
+ const ix = x + pad;
667
+ const iy = y + pad;
668
+ const iw = w - pad * 2;
669
+ const ih = h - pad * 2;
670
+
671
+ if (!nodes.length) {
672
+ ctx.fillStyle = '#aaa';
673
+ ctx.font = `${12 * scale}px sans-serif`;
674
+ ctx.textAlign = 'center';
675
+ ctx.textBaseline = 'middle';
676
+ ctx.fillText('SmartArt Diagram', x + w / 2, y + h / 2);
677
+ ctx.restore();
678
+ return;
679
+ }
680
+
681
+ switch (layout) {
682
+ case 'process': renderProcess(ctx, nodes, ix, iy, iw, ih, themeColors, scale); break;
683
+ case 'cycle': renderCycle(ctx, nodes, ix, iy, iw, ih, themeColors, scale); break;
684
+ case 'hierarchy': renderHierarchy(ctx, nodes, ix, iy, iw, ih, themeColors, scale); break;
685
+ case 'radial': renderRadial(ctx, nodes, ix, iy, iw, ih, themeColors, scale); break;
686
+ case 'pyramid': renderPyramid(ctx, nodes, ix, iy, iw, ih, themeColors, scale); break;
687
+ case 'funnel': renderFunnel(ctx, nodes, ix, iy, iw, ih, themeColors, scale); break;
688
+ case 'venn': renderVenn(ctx, nodes, ix, iy, iw, ih, themeColors, scale); break;
689
+ case 'matrix': renderMatrix(ctx, nodes, ix, iy, iw, ih, themeColors, scale); break;
690
+ case 'list': renderList(ctx, nodes, ix, iy, iw, ih, themeColors, scale); break;
691
+ case 'relationship': renderRelationship(ctx, nodes, ix, iy, iw, ih, themeColors, scale); break;
692
+ default: renderFallback(ctx, nodes, ix, iy, iw, ih, themeColors, scale);
693
+ }
694
+
695
+ ctx.restore();
696
+ }