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/LICENSE +9 -0
- package/README.md +209 -0
- package/package.json +53 -0
- package/src/animation.js +817 -0
- package/src/charts.js +989 -0
- package/src/clipboard.js +416 -0
- package/src/colors.js +297 -0
- package/src/effects3d.js +312 -0
- package/src/extract.js +535 -0
- package/src/fntdata.js +265 -0
- package/src/fonts.js +676 -0
- package/src/index.js +751 -0
- package/src/pdf.js +298 -0
- package/src/render.js +1964 -0
- package/src/shapes.js +666 -0
- package/src/slideshow.js +492 -0
- package/src/smartart.js +696 -0
- package/src/svg.js +732 -0
- package/src/theme.js +88 -0
- package/src/utils.js +50 -0
- package/src/writer.js +1015 -0
- package/src/zip-writer.js +214 -0
- package/src/zip.js +194 -0
package/src/smartart.js
ADDED
|
@@ -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
|
+
}
|