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/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
|
+
}
|