@bendyline/squisq-formats 2.7.6 → 2.8.1
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/dist/{chunk-7HREYTJH.js → chunk-AOY3UPE3.js} +1 -1
- package/dist/{chunk-32AQL6I6.js → chunk-GLIUMDYD.js} +13 -6
- package/dist/{chunk-JZ33FB6V.js → chunk-QRDJOYT3.js} +1175 -242
- package/dist/index.js +4 -4
- package/dist/outside-in/index.js +2 -2
- package/dist/pdf/index.js +1 -1
- package/dist/pptx/index.js +1 -1
- package/dist/registry/index.js +1 -1
- package/package.json +2 -2
- /package/dist/{chunk-ONBGBSKA.js → chunk-X6WFQDZD.js} +0 -0
|
@@ -62,11 +62,12 @@ import {
|
|
|
62
62
|
fontAwesomeGlyph
|
|
63
63
|
} from "./chunk-LBXBRFBZ.js";
|
|
64
64
|
import {
|
|
65
|
-
extToMime
|
|
65
|
+
extToMime,
|
|
66
|
+
readImageDimensions
|
|
66
67
|
} from "./chunk-JZR32FON.js";
|
|
67
68
|
|
|
68
69
|
// src/pptx/export.ts
|
|
69
|
-
import {
|
|
70
|
+
import { VIEWPORT_PRESETS } from "@bendyline/squisq/schemas";
|
|
70
71
|
import {
|
|
71
72
|
buildPreviewDoc,
|
|
72
73
|
createTemplateContext,
|
|
@@ -74,13 +75,14 @@ import {
|
|
|
74
75
|
expandCoverBlock,
|
|
75
76
|
expandDocBlocks,
|
|
76
77
|
flattenRenderableBlocks,
|
|
78
|
+
isGeometryBoundTemplate,
|
|
77
79
|
isTemplateBlock,
|
|
78
80
|
resolvePersistentLayers,
|
|
79
81
|
resolveThemeForDoc
|
|
80
82
|
} from "@bendyline/squisq/doc";
|
|
81
|
-
import { splitIconMarkers } from "@bendyline/squisq/icon-marker";
|
|
83
|
+
import { splitIconMarkers as splitIconMarkers3 } from "@bendyline/squisq/icon-marker";
|
|
82
84
|
import {
|
|
83
|
-
isSvgImageSource,
|
|
85
|
+
isSvgImageSource as isSvgImageSource2,
|
|
84
86
|
parseVideoEmbedUrl,
|
|
85
87
|
readFrontmatterThemeId
|
|
86
88
|
} from "@bendyline/squisq/markdown";
|
|
@@ -179,6 +181,1095 @@ var DEFAULT_TITLE_SIZE = 4400;
|
|
|
179
181
|
var DEFAULT_CODE_SIZE = 1400;
|
|
180
182
|
var HYPERLINK_COLOR = "0563C1";
|
|
181
183
|
|
|
184
|
+
// src/pptx/geometry.ts
|
|
185
|
+
var EMU_PER_CANVAS_PX = SLIDE_WIDTH / 1920;
|
|
186
|
+
function resolvePositionValue(value, dimension) {
|
|
187
|
+
if (typeof value === "number") return value;
|
|
188
|
+
if (typeof value === "string") {
|
|
189
|
+
const trimmed = value.trim();
|
|
190
|
+
if (trimmed.endsWith("%")) {
|
|
191
|
+
const percent = Number.parseFloat(trimmed);
|
|
192
|
+
if (Number.isFinite(percent)) return percent / 100 * dimension;
|
|
193
|
+
}
|
|
194
|
+
const numeric = Number.parseFloat(trimmed);
|
|
195
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
196
|
+
}
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
function resolveLayerRect(position, defaultWidth, defaultHeight) {
|
|
200
|
+
const width = Math.max(
|
|
201
|
+
1,
|
|
202
|
+
position.width != null ? resolvePositionValue(position.width, 1920) : defaultWidth
|
|
203
|
+
);
|
|
204
|
+
const height = Math.max(
|
|
205
|
+
1,
|
|
206
|
+
position.height != null ? resolvePositionValue(position.height, 1080) : defaultHeight
|
|
207
|
+
);
|
|
208
|
+
let x = resolvePositionValue(position.x, 1920);
|
|
209
|
+
let y = resolvePositionValue(position.y, 1080);
|
|
210
|
+
const anchor = position.anchor ?? "top-left";
|
|
211
|
+
if (anchor === "center") {
|
|
212
|
+
x -= width / 2;
|
|
213
|
+
y -= height / 2;
|
|
214
|
+
} else {
|
|
215
|
+
if (anchor.endsWith("right")) x -= width;
|
|
216
|
+
if (anchor.startsWith("bottom")) y -= height;
|
|
217
|
+
}
|
|
218
|
+
const clippedX = Math.min(1919, Math.max(0, x));
|
|
219
|
+
const clippedY = Math.min(1079, Math.max(0, y));
|
|
220
|
+
const clippedWidth = Math.max(1, Math.min(width - (clippedX - x), 1920 - clippedX));
|
|
221
|
+
const clippedHeight = Math.max(1, Math.min(height - (clippedY - y), 1080 - clippedY));
|
|
222
|
+
return { x: clippedX, y: clippedY, width: clippedWidth, height: clippedHeight };
|
|
223
|
+
}
|
|
224
|
+
function toEmuRect(rect) {
|
|
225
|
+
return {
|
|
226
|
+
x: Math.round(rect.x / 1920 * SLIDE_WIDTH),
|
|
227
|
+
y: Math.round(rect.y / 1080 * SLIDE_HEIGHT),
|
|
228
|
+
width: Math.max(1, Math.round(rect.width / 1920 * SLIDE_WIDTH)),
|
|
229
|
+
height: Math.max(1, Math.round(rect.height / 1080 * SLIDE_HEIGHT))
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/pptx/drawingXml.ts
|
|
234
|
+
import { resolveFontFamily } from "@bendyline/squisq/schemas";
|
|
235
|
+
|
|
236
|
+
// src/pptx/cssPaint.ts
|
|
237
|
+
function parseCssColor(color, fallback) {
|
|
238
|
+
if (!color) return { hex: toOoxmlHex(fallback), alpha: 1e5 };
|
|
239
|
+
const trimmed = color.trim();
|
|
240
|
+
if (trimmed === "transparent" || trimmed === "none") return { hex: "000000", alpha: 0 };
|
|
241
|
+
const hexMatch = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(trimmed);
|
|
242
|
+
if (hexMatch) {
|
|
243
|
+
const raw = hexMatch[1];
|
|
244
|
+
const expanded = raw.length === 3 ? raw.split("").map((char) => char + char).join("") : raw;
|
|
245
|
+
const alpha = expanded.length === 8 ? Math.round(Number.parseInt(expanded.slice(6, 8), 16) / 255 * 1e5) : 1e5;
|
|
246
|
+
return { hex: expanded.slice(0, 6).toUpperCase(), alpha };
|
|
247
|
+
}
|
|
248
|
+
const rgbMatch = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i.exec(trimmed);
|
|
249
|
+
if (rgbMatch) {
|
|
250
|
+
const channel = (value) => Math.max(0, Math.min(255, Math.round(Number(value)))).toString(16).padStart(2, "0");
|
|
251
|
+
const alpha = rgbMatch[4] ? Math.round(Math.max(0, Math.min(1, Number(rgbMatch[4]))) * 1e5) : 1e5;
|
|
252
|
+
return {
|
|
253
|
+
hex: `${channel(rgbMatch[1])}${channel(rgbMatch[2])}${channel(rgbMatch[3])}`.toUpperCase(),
|
|
254
|
+
alpha
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const named = {
|
|
258
|
+
black: "000000",
|
|
259
|
+
white: "FFFFFF",
|
|
260
|
+
red: "FF0000",
|
|
261
|
+
blue: "0000FF",
|
|
262
|
+
green: "008000",
|
|
263
|
+
gray: "808080",
|
|
264
|
+
grey: "808080"
|
|
265
|
+
};
|
|
266
|
+
return { hex: named[trimmed.toLowerCase()] ?? toOoxmlHex(fallback), alpha: 1e5 };
|
|
267
|
+
}
|
|
268
|
+
function splitTopLevel(value) {
|
|
269
|
+
const parts = [];
|
|
270
|
+
let depth = 0;
|
|
271
|
+
let current = "";
|
|
272
|
+
for (const char of value) {
|
|
273
|
+
if (char === "(") depth++;
|
|
274
|
+
if (char === ")") depth--;
|
|
275
|
+
if (char === "," && depth === 0) {
|
|
276
|
+
parts.push(current.trim());
|
|
277
|
+
current = "";
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
current += char;
|
|
281
|
+
}
|
|
282
|
+
if (current.trim()) parts.push(current.trim());
|
|
283
|
+
return parts;
|
|
284
|
+
}
|
|
285
|
+
var SIDE_ANGLES = {
|
|
286
|
+
top: 0,
|
|
287
|
+
right: 90,
|
|
288
|
+
bottom: 180,
|
|
289
|
+
left: 270,
|
|
290
|
+
"top right": 45,
|
|
291
|
+
"right top": 45,
|
|
292
|
+
"bottom right": 135,
|
|
293
|
+
"right bottom": 135,
|
|
294
|
+
"bottom left": 225,
|
|
295
|
+
"left bottom": 225,
|
|
296
|
+
"top left": 315,
|
|
297
|
+
"left top": 315
|
|
298
|
+
};
|
|
299
|
+
function parseAngle(token) {
|
|
300
|
+
const side = /^to\s+(.+)$/i.exec(token);
|
|
301
|
+
if (side) return SIDE_ANGLES[side[1].trim().toLowerCase().replace(/\s+/g, " ")] ?? null;
|
|
302
|
+
const match = /^(-?[\d.]+)(deg|turn|rad|grad)$/i.exec(token);
|
|
303
|
+
if (!match) return null;
|
|
304
|
+
const value = Number(match[1]);
|
|
305
|
+
const unit = match[2].toLowerCase();
|
|
306
|
+
if (!Number.isFinite(value)) return null;
|
|
307
|
+
if (unit === "turn") return value * 360;
|
|
308
|
+
if (unit === "rad") return value * 180 / Math.PI;
|
|
309
|
+
if (unit === "grad") return value * 0.9;
|
|
310
|
+
return value;
|
|
311
|
+
}
|
|
312
|
+
var POSITION_KEYWORDS = {
|
|
313
|
+
left: 0,
|
|
314
|
+
top: 0,
|
|
315
|
+
center: 0.5,
|
|
316
|
+
right: 1,
|
|
317
|
+
bottom: 1
|
|
318
|
+
};
|
|
319
|
+
function parseRadialCenter(token) {
|
|
320
|
+
const at = /\bat\s+(.+)$/i.exec(token);
|
|
321
|
+
if (!at) return { x: 0.5, y: 0.5 };
|
|
322
|
+
const parts = at[1].trim().toLowerCase().split(/\s+/);
|
|
323
|
+
const value = (part) => {
|
|
324
|
+
if (part === void 0) return void 0;
|
|
325
|
+
if (part in POSITION_KEYWORDS) return POSITION_KEYWORDS[part];
|
|
326
|
+
const percent = /^(-?[\d.]+)%$/.exec(part);
|
|
327
|
+
return percent ? Number(percent[1]) / 100 : void 0;
|
|
328
|
+
};
|
|
329
|
+
if (parts.length === 1) {
|
|
330
|
+
const only = parts[0];
|
|
331
|
+
if (only === "top" || only === "bottom") return { x: 0.5, y: value(only) ?? 0.5 };
|
|
332
|
+
return { x: value(only) ?? 0.5, y: 0.5 };
|
|
333
|
+
}
|
|
334
|
+
const [first, second] = parts;
|
|
335
|
+
if (first === "top" || first === "bottom" || second === "left" || second === "right") {
|
|
336
|
+
return { x: value(second) ?? 0.5, y: value(first) ?? 0.5 };
|
|
337
|
+
}
|
|
338
|
+
return { x: value(first) ?? 0.5, y: value(second) ?? 0.5 };
|
|
339
|
+
}
|
|
340
|
+
var CSS_COLOR = /^(#[0-9a-f]{3}|#[0-9a-f]{6}|#[0-9a-f]{8}|rgba?\([^)]*\)|transparent|black|white|red|blue|green|gr[ae]y)$/i;
|
|
341
|
+
function parseStop(token) {
|
|
342
|
+
const positions = [];
|
|
343
|
+
let rest = token.trim();
|
|
344
|
+
for (; ; ) {
|
|
345
|
+
const match = /\s+(-?[\d.]+)%$/.exec(rest);
|
|
346
|
+
if (!match) break;
|
|
347
|
+
positions.unshift(Number(match[1]) / 100);
|
|
348
|
+
rest = rest.slice(0, match.index).trim();
|
|
349
|
+
}
|
|
350
|
+
if (!CSS_COLOR.test(rest)) return null;
|
|
351
|
+
const color = parseCssColor(rest, "000000");
|
|
352
|
+
if (positions.length === 0) return [{ color }];
|
|
353
|
+
return positions.slice(0, 2).map((position) => ({ color: { ...color }, position }));
|
|
354
|
+
}
|
|
355
|
+
function resolveStopPositions(raw) {
|
|
356
|
+
const positions = raw.map((stop) => stop.position);
|
|
357
|
+
if (positions[0] === void 0) positions[0] = 0;
|
|
358
|
+
if (positions[positions.length - 1] === void 0) positions[positions.length - 1] = 1;
|
|
359
|
+
let previous = 0;
|
|
360
|
+
for (let index = 0; index < positions.length; index++) {
|
|
361
|
+
const value = positions[index];
|
|
362
|
+
if (value === void 0) continue;
|
|
363
|
+
positions[index] = Math.max(previous, value);
|
|
364
|
+
previous = positions[index];
|
|
365
|
+
}
|
|
366
|
+
for (let index = 1; index < positions.length; index++) {
|
|
367
|
+
if (positions[index] !== void 0) continue;
|
|
368
|
+
const start = index - 1;
|
|
369
|
+
let end = index;
|
|
370
|
+
while (positions[end] === void 0) end++;
|
|
371
|
+
const from = positions[start];
|
|
372
|
+
const to = positions[end];
|
|
373
|
+
for (let gap = index; gap < end; gap++) {
|
|
374
|
+
positions[gap] = from + (to - from) * (gap - start) / (end - start);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return raw.map((stop, index) => ({
|
|
378
|
+
color: stop.color,
|
|
379
|
+
position: Math.max(0, Math.min(1, positions[index]))
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
function borrowTransparentHues(stops) {
|
|
383
|
+
return stops.map((stop, index) => {
|
|
384
|
+
if (stop.color.alpha > 0) return stop;
|
|
385
|
+
const neighbour = stops.slice(index + 1).find((candidate) => candidate.color.alpha > 0) ?? [...stops.slice(0, index)].reverse().find((candidate) => candidate.color.alpha > 0);
|
|
386
|
+
return neighbour ? { ...stop, color: { hex: neighbour.color.hex, alpha: 0 } } : stop;
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
function parseCssGradient(value) {
|
|
390
|
+
const match = /(linear|radial)-gradient\(\s*([\s\S]*)\)\s*$/i.exec(value.trim());
|
|
391
|
+
if (!match) return null;
|
|
392
|
+
const kind = match[1].toLowerCase();
|
|
393
|
+
const args = splitTopLevel(match[2]);
|
|
394
|
+
if (args.length === 0) return null;
|
|
395
|
+
const first = args[0];
|
|
396
|
+
let angle = 180;
|
|
397
|
+
let center = { x: 0.5, y: 0.5 };
|
|
398
|
+
let stopTokens = args;
|
|
399
|
+
if (kind === "linear") {
|
|
400
|
+
const parsed = parseAngle(first);
|
|
401
|
+
if (parsed !== null) {
|
|
402
|
+
angle = parsed;
|
|
403
|
+
stopTokens = args.slice(1);
|
|
404
|
+
}
|
|
405
|
+
} else if (/^(circle|ellipse|closest-|farthest-|at\s)/i.test(first) || /\bat\s/i.test(first)) {
|
|
406
|
+
center = parseRadialCenter(first);
|
|
407
|
+
stopTokens = args.slice(1);
|
|
408
|
+
}
|
|
409
|
+
const raw = stopTokens.flatMap((token) => parseStop(token) ?? []);
|
|
410
|
+
if (raw.length < 2) return null;
|
|
411
|
+
const stops = borrowTransparentHues(resolveStopPositions(raw));
|
|
412
|
+
return kind === "linear" ? { kind, angle, stops } : { kind, centerX: center.x, centerY: center.y, stops };
|
|
413
|
+
}
|
|
414
|
+
function isOpaqueGradient(gradient) {
|
|
415
|
+
return gradient.stops.every((stop) => stop.color.alpha >= 1e5);
|
|
416
|
+
}
|
|
417
|
+
function cssGradientFillXml(gradient, opacity = 1) {
|
|
418
|
+
const factor = Math.max(0, Math.min(1, opacity));
|
|
419
|
+
const stops = gradient.stops.map(({ color, position }) => {
|
|
420
|
+
const alpha = Math.round(color.alpha * factor);
|
|
421
|
+
return `<a:gs pos="${Math.round(position * 1e5)}"><a:srgbClr val="${color.hex}">` + (alpha < 1e5 ? `<a:alpha val="${alpha}"/>` : "") + `</a:srgbClr></a:gs>`;
|
|
422
|
+
}).join("");
|
|
423
|
+
if (gradient.kind === "linear") {
|
|
424
|
+
const angle = Math.round(((gradient.angle - 90) % 360 + 360) % 360 * 6e4);
|
|
425
|
+
return `<a:gradFill rotWithShape="1"><a:gsLst>${stops}</a:gsLst><a:lin ang="${angle}" scaled="0"/></a:gradFill>`;
|
|
426
|
+
}
|
|
427
|
+
const edge = (value) => Math.round(Math.max(0, Math.min(1, value)) * 1e5);
|
|
428
|
+
return `<a:gradFill rotWithShape="1"><a:gsLst>${stops}</a:gsLst><a:path path="circle"><a:fillToRect l="${edge(gradient.centerX)}" t="${edge(gradient.centerY)}" r="${edge(1 - gradient.centerX)}" b="${edge(1 - gradient.centerY)}"/></a:path></a:gradFill>`;
|
|
429
|
+
}
|
|
430
|
+
function blendOver(dst, src) {
|
|
431
|
+
const alpha = Math.max(0, Math.min(1, src.alpha / 1e5));
|
|
432
|
+
const channel = (offset) => {
|
|
433
|
+
const under = Number.parseInt(dst.slice(offset, offset + 2), 16);
|
|
434
|
+
const over = Number.parseInt(src.hex.slice(offset, offset + 2), 16);
|
|
435
|
+
return Math.round(over * alpha + under * (1 - alpha)).toString(16).padStart(2, "0");
|
|
436
|
+
};
|
|
437
|
+
return `${channel(0)}${channel(2)}${channel(4)}`.toUpperCase();
|
|
438
|
+
}
|
|
439
|
+
function cssGradientToString(gradient) {
|
|
440
|
+
const stops = gradient.stops.map(({ color, position }) => {
|
|
441
|
+
const alpha = color.alpha / 1e5;
|
|
442
|
+
const value = alpha >= 1 ? `#${color.hex}` : `rgba(${Number.parseInt(color.hex.slice(0, 2), 16)}, ${Number.parseInt(color.hex.slice(2, 4), 16)}, ${Number.parseInt(color.hex.slice(4, 6), 16)}, ${alpha})`;
|
|
443
|
+
return `${value} ${+(position * 100).toFixed(3)}%`;
|
|
444
|
+
}).join(", ");
|
|
445
|
+
return gradient.kind === "linear" ? `linear-gradient(${gradient.angle}deg, ${stops})` : `radial-gradient(ellipse at ${+(gradient.centerX * 100).toFixed(3)}% ${+(gradient.centerY * 100).toFixed(3)}%, ${stops})`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/pptx/drawingXml.ts
|
|
449
|
+
function officeTypeface(family, fallback) {
|
|
450
|
+
const resolved = resolveFontFamily(family, fallback);
|
|
451
|
+
const first = resolved.split(",")[0]?.trim().replace(/^['"]|['"]$/g, "");
|
|
452
|
+
return first || fallback;
|
|
453
|
+
}
|
|
454
|
+
function solidColorXml(color) {
|
|
455
|
+
const alpha = color.alpha < 1e5 ? `<a:alpha val="${color.alpha}"/>` : "";
|
|
456
|
+
return `<a:solidFill><a:srgbClr val="${color.hex}">${alpha}</a:srgbClr></a:solidFill>`;
|
|
457
|
+
}
|
|
458
|
+
function fillXml(fill, opacity, gradient) {
|
|
459
|
+
if (gradient) return gradientFillXml(gradient.from, gradient.to, gradient.angle ?? 0, opacity);
|
|
460
|
+
if (!fill || fill === "none" || fill === "transparent") return "<a:noFill/>";
|
|
461
|
+
if (fill.includes("gradient(")) {
|
|
462
|
+
const parsed = parseCssGradient(fill);
|
|
463
|
+
if (parsed) return cssGradientFillXml(parsed, opacity ?? 1);
|
|
464
|
+
const colors = fill.match(/#[0-9a-f]{3,8}|rgba?\([^)]*\)/gi) ?? [];
|
|
465
|
+
if (colors.length >= 2) {
|
|
466
|
+
const angle = Number.parseFloat(
|
|
467
|
+
/(?:linear-gradient\()\s*([\d.-]+)deg/i.exec(fill)?.[1] ?? "0"
|
|
468
|
+
);
|
|
469
|
+
return gradientFillXml(colors[0], colors[colors.length - 1], angle, opacity);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const color = parseCssColor(fill, "000000");
|
|
473
|
+
if (opacity !== void 0)
|
|
474
|
+
color.alpha = Math.round(color.alpha * Math.max(0, Math.min(1, opacity)));
|
|
475
|
+
return solidColorXml(color);
|
|
476
|
+
}
|
|
477
|
+
function gradientFillXml(from, to, angle, opacity) {
|
|
478
|
+
const first = parseCssColor(from, "000000");
|
|
479
|
+
const second = parseCssColor(to, first.hex);
|
|
480
|
+
if (opacity !== void 0) {
|
|
481
|
+
const factor = Math.max(0, Math.min(1, opacity));
|
|
482
|
+
first.alpha = Math.round(first.alpha * factor);
|
|
483
|
+
second.alpha = Math.round(second.alpha * factor);
|
|
484
|
+
}
|
|
485
|
+
const ooxmlAngle = Math.round(((90 - angle) % 360 + 360) % 360 * 6e4);
|
|
486
|
+
return `<a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:srgbClr val="${first.hex}">${first.alpha < 1e5 ? `<a:alpha val="${first.alpha}"/>` : ""}</a:srgbClr></a:gs><a:gs pos="100000"><a:srgbClr val="${second.hex}">${second.alpha < 1e5 ? `<a:alpha val="${second.alpha}"/>` : ""}</a:srgbClr></a:gs></a:gsLst><a:lin ang="${ooxmlAngle}" scaled="1"/></a:gradFill>`;
|
|
487
|
+
}
|
|
488
|
+
function lineXml(color, widthPx, borderStyle) {
|
|
489
|
+
if (!color || !widthPx || widthPx <= 0) return "<a:ln><a:noFill/></a:ln>";
|
|
490
|
+
const width = Math.max(1, Math.round(widthPx * EMU_PER_CANVAS_PX));
|
|
491
|
+
return `<a:ln w="${width}">${solidColorXml(parseCssColor(color, "000000"))}${dashXml(borderStyle)}</a:ln>`;
|
|
492
|
+
}
|
|
493
|
+
function dashXml(style, dasharray) {
|
|
494
|
+
if (style === "dashed" || dasharray && dasharray !== "none") return '<a:prstDash val="dash"/>';
|
|
495
|
+
if (style === "dotted") return '<a:prstDash val="dot"/>';
|
|
496
|
+
return "";
|
|
497
|
+
}
|
|
498
|
+
function patternFillXml(pattern, ground) {
|
|
499
|
+
const size = pattern.size ?? 24;
|
|
500
|
+
const preset = pattern.kind === "dots" ? "pct5" : pattern.kind === "grid" ? size >= 32 ? "lgGrid" : "smGrid" : size >= 32 ? "wdUpDiag" : "ltUpDiag";
|
|
501
|
+
const ink = parseCssColor(pattern.color, "FFFFFF");
|
|
502
|
+
ink.alpha = Math.round(ink.alpha * Math.max(0, Math.min(1, pattern.opacity ?? 1)));
|
|
503
|
+
const alpha = ink.alpha < 1e5 ? `<a:alpha val="${ink.alpha}"/>` : "";
|
|
504
|
+
return `<a:pattFill prst="${preset}"><a:fgClr><a:srgbClr val="${ink.hex}">${alpha}</a:srgbClr></a:fgClr>` + (ground ? `<a:bgClr><a:srgbClr val="${parseCssColor(ground, "000000").hex}"/></a:bgClr>` : `<a:bgClr><a:srgbClr val="${ink.hex}"><a:alpha val="0"/></a:srgbClr></a:bgClr>`) + `</a:pattFill>`;
|
|
505
|
+
}
|
|
506
|
+
function buildLayerShape(layer, shapeId) {
|
|
507
|
+
const rect = toEmuRect(resolveLayerRect(layer.position, 100, 100));
|
|
508
|
+
const geometry = layer.content.shape === "circle" ? "ellipse" : layer.content.shape === "line" ? "line" : layer.content.borderRadius ? "roundRect" : "rect";
|
|
509
|
+
const fill = layer.content.shape === "line" ? "<a:noFill/>" : layer.content.pattern ? patternFillXml(layer.content.pattern) : fillXml(layer.content.fill, layer.content.fillOpacity, layer.content.gradient);
|
|
510
|
+
const stroke = lineXml(
|
|
511
|
+
layer.content.stroke ?? (layer.content.shape === "line" ? "#ffffff" : void 0),
|
|
512
|
+
layer.content.strokeWidth ?? (layer.content.shape === "line" ? 2 : void 0),
|
|
513
|
+
layer.content.borderStyle
|
|
514
|
+
);
|
|
515
|
+
return `<p:sp><p:nvSpPr><p:cNvPr id="${shapeId}" name="${escapeXml(layer.id)}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="${rect.x}" y="${rect.y}"/><a:ext cx="${rect.width}" cy="${rect.height}"/></a:xfrm><a:prstGeom prst="${geometry}"><a:avLst/></a:prstGeom>` + fill + stroke + `</p:spPr></p:sp>`;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/pptx/backdrop.ts
|
|
519
|
+
import { TEMPLATE_METADATA } from "@bendyline/squisq/doc";
|
|
520
|
+
import { isSvgImageSource } from "@bendyline/squisq/markdown";
|
|
521
|
+
var FULL_BLEED_TOLERANCE_PX = 1;
|
|
522
|
+
function coversCanvas(layer) {
|
|
523
|
+
if (layer.position.width == null || layer.position.height == null) return false;
|
|
524
|
+
const rect = resolveLayerRect(layer.position, 1920, 1080);
|
|
525
|
+
return rect.x <= FULL_BLEED_TOLERANCE_PX && rect.y <= FULL_BLEED_TOLERANCE_PX && rect.x + rect.width >= 1920 - FULL_BLEED_TOLERANCE_PX && rect.y + rect.height >= 1080 - FULL_BLEED_TOLERANCE_PX;
|
|
526
|
+
}
|
|
527
|
+
function isPaintRect(layer) {
|
|
528
|
+
if (layer.type !== "shape" || layer.content.shape !== "rect") return false;
|
|
529
|
+
if (layer.content.borderRadius) return false;
|
|
530
|
+
if (layer.content.stroke && (layer.content.strokeWidth ?? 1) > 0) return false;
|
|
531
|
+
return coversCanvas(layer);
|
|
532
|
+
}
|
|
533
|
+
function isPhotoBackdrop(layer) {
|
|
534
|
+
return layer.type === "image" && (!layer.content.src || layer.content.fit === "cover" || layer.content.fit === "fill") && !(layer.content.src && isSvgImageSource(layer.content.src)) && coversCanvas(layer);
|
|
535
|
+
}
|
|
536
|
+
function isOpaqueColor(color, opacity) {
|
|
537
|
+
if (!color || color === "none" || color === "transparent") return false;
|
|
538
|
+
return parseCssColor(color, "000000").alpha >= 1e5 && (opacity ?? 1) >= 1;
|
|
539
|
+
}
|
|
540
|
+
function isOpaquePaint(layer) {
|
|
541
|
+
if (layer.type === "image") return !!layer.content.src;
|
|
542
|
+
const { fill, fillOpacity, gradient, pattern, filter } = layer.content;
|
|
543
|
+
if (pattern || filter) return false;
|
|
544
|
+
if (gradient) {
|
|
545
|
+
return isOpaqueColor(gradient.from, fillOpacity) && isOpaqueColor(gradient.to, fillOpacity);
|
|
546
|
+
}
|
|
547
|
+
if (fill?.includes("gradient(")) {
|
|
548
|
+
const parsed = parseCssGradient(fill);
|
|
549
|
+
return !!parsed && isOpaqueGradient(parsed) && (fillOpacity ?? 1) >= 1;
|
|
550
|
+
}
|
|
551
|
+
return isOpaqueColor(fill, fillOpacity);
|
|
552
|
+
}
|
|
553
|
+
function isDrawable(layer) {
|
|
554
|
+
return layer.content.filter?.type !== "noise";
|
|
555
|
+
}
|
|
556
|
+
var CLEAR = { hex: "000000", alpha: 0 };
|
|
557
|
+
function layerPaint(layer) {
|
|
558
|
+
const { fill, fillOpacity, gradient, pattern } = layer.content;
|
|
559
|
+
const factor = Math.max(0, Math.min(1, fillOpacity ?? 1));
|
|
560
|
+
const fade = (color) => ({
|
|
561
|
+
hex: color.hex,
|
|
562
|
+
alpha: Math.round(color.alpha * factor)
|
|
563
|
+
});
|
|
564
|
+
if (pattern) return { kind: "pattern", pattern };
|
|
565
|
+
if (gradient) {
|
|
566
|
+
return {
|
|
567
|
+
kind: "gradient",
|
|
568
|
+
gradient: {
|
|
569
|
+
kind: "linear",
|
|
570
|
+
angle: 180 - (gradient.angle ?? 0),
|
|
571
|
+
stops: [
|
|
572
|
+
{ position: 0, color: fade(parseCssColor(gradient.from, "000000")) },
|
|
573
|
+
{ position: 1, color: fade(parseCssColor(gradient.to, "000000")) }
|
|
574
|
+
]
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (!fill || fill === "none" || fill === "transparent") return { kind: "solid", color: CLEAR };
|
|
579
|
+
if (fill.includes("gradient(")) {
|
|
580
|
+
const parsed = parseCssGradient(fill);
|
|
581
|
+
if (!parsed) return null;
|
|
582
|
+
const stops = parsed.stops.map((stop) => ({ ...stop, color: fade(stop.color) }));
|
|
583
|
+
return { kind: "gradient", gradient: { ...parsed, stops } };
|
|
584
|
+
}
|
|
585
|
+
return { kind: "solid", color: fade(parseCssColor(fill, "000000")) };
|
|
586
|
+
}
|
|
587
|
+
function compositeBackdrop(base, overlays) {
|
|
588
|
+
const paints = [base, ...overlays].map(layerPaint);
|
|
589
|
+
if (paints.some((paint) => paint === null)) return null;
|
|
590
|
+
const stack = paints;
|
|
591
|
+
const varying = stack.filter((paint) => paint.kind !== "solid");
|
|
592
|
+
if (varying.length > 1 || stack[0].kind === "pattern") return null;
|
|
593
|
+
const uniform = (paint) => paint.kind === "solid" ? paint.color : CLEAR;
|
|
594
|
+
const fold = (sample) => stack.reduce((under, paint) => blendOver(under, sample(paint)), "000000");
|
|
595
|
+
const only = varying[0];
|
|
596
|
+
let content;
|
|
597
|
+
if (!only) {
|
|
598
|
+
content = { shape: "rect", fill: `#${fold(uniform)}` };
|
|
599
|
+
} else if (only.kind === "gradient") {
|
|
600
|
+
const stops = only.gradient.stops.map((stop) => ({
|
|
601
|
+
position: stop.position,
|
|
602
|
+
color: {
|
|
603
|
+
hex: fold((paint) => paint === only ? stop.color : uniform(paint)),
|
|
604
|
+
alpha: 1e5
|
|
605
|
+
}
|
|
606
|
+
}));
|
|
607
|
+
content = { shape: "rect", fill: cssGradientToString({ ...only.gradient, stops }) };
|
|
608
|
+
} else {
|
|
609
|
+
const ink = parseCssColor(only.pattern.color, "FFFFFF");
|
|
610
|
+
ink.alpha = Math.round(ink.alpha * Math.max(0, Math.min(1, only.pattern.opacity ?? 1)));
|
|
611
|
+
content = {
|
|
612
|
+
shape: "rect",
|
|
613
|
+
// The ground under the motif, and the motif's ink over that ground.
|
|
614
|
+
fill: `#${fold(uniform)}`,
|
|
615
|
+
pattern: {
|
|
616
|
+
...only.pattern,
|
|
617
|
+
color: `#${fold((paint) => paint === only ? ink : uniform(paint))}`,
|
|
618
|
+
opacity: 1
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
return { ...base, content };
|
|
623
|
+
}
|
|
624
|
+
function splitSlideBackdrop(layers) {
|
|
625
|
+
let start = 0;
|
|
626
|
+
while (start < layers.length && (isPaintRect(layers[start]) || isPhotoBackdrop(layers[start]))) {
|
|
627
|
+
start++;
|
|
628
|
+
}
|
|
629
|
+
let end = layers.length;
|
|
630
|
+
while (end > start) {
|
|
631
|
+
const layer = layers[end - 1];
|
|
632
|
+
if (!isPaintRect(layer) || isOpaquePaint(layer)) break;
|
|
633
|
+
end--;
|
|
634
|
+
}
|
|
635
|
+
const under = layers.slice(0, start);
|
|
636
|
+
let baseIndex = -1;
|
|
637
|
+
for (let index = under.length - 1; index >= 0; index--) {
|
|
638
|
+
if (isOpaquePaint(under[index])) {
|
|
639
|
+
baseIndex = index;
|
|
640
|
+
break;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const overlays = [...under.slice(baseIndex + 1), ...layers.slice(end)].filter(
|
|
644
|
+
(layer) => layer.type === "shape" && isDrawable(layer)
|
|
645
|
+
);
|
|
646
|
+
return {
|
|
647
|
+
base: baseIndex >= 0 ? under[baseIndex] : null,
|
|
648
|
+
overlays,
|
|
649
|
+
content: layers.slice(start, end)
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
function compositeAsFarAsPossible(base, overlays) {
|
|
653
|
+
if (base?.type !== "shape") return { base, overlays };
|
|
654
|
+
for (let count = overlays.length; count > 0; count--) {
|
|
655
|
+
const flat = compositeBackdrop(base, overlays.slice(0, count));
|
|
656
|
+
if (flat) return { base: flat, overlays: overlays.slice(count) };
|
|
657
|
+
}
|
|
658
|
+
return { base, overlays };
|
|
659
|
+
}
|
|
660
|
+
function paintSignature(layer) {
|
|
661
|
+
const { id: _id, animation: _animation, ...rest } = layer;
|
|
662
|
+
return JSON.stringify(rest);
|
|
663
|
+
}
|
|
664
|
+
function isSolidPaint(layer) {
|
|
665
|
+
const { fill, gradient, pattern } = layer.content;
|
|
666
|
+
return !gradient && !pattern && !fill?.includes("gradient(");
|
|
667
|
+
}
|
|
668
|
+
function mostCommonPaint(items) {
|
|
669
|
+
const counts = /* @__PURE__ */ new Map();
|
|
670
|
+
for (const item of items) {
|
|
671
|
+
const entry = counts.get(paintSignature(item));
|
|
672
|
+
if (entry) entry.count++;
|
|
673
|
+
else counts.set(paintSignature(item), { item, count: 1 });
|
|
674
|
+
}
|
|
675
|
+
let best = null;
|
|
676
|
+
for (const entry of counts.values()) {
|
|
677
|
+
if (!best || entry.count > best.count || entry.count === best.count && isSolidPaint(entry.item) && !isSolidPaint(best.item)) {
|
|
678
|
+
best = entry;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return best?.item ?? null;
|
|
682
|
+
}
|
|
683
|
+
function layoutLabel(block) {
|
|
684
|
+
if (block.template) return TEMPLATE_METADATA[block.template]?.label ?? block.template;
|
|
685
|
+
return block.id === "cover-block" ? "Cover" : "Slide";
|
|
686
|
+
}
|
|
687
|
+
function planDeckBackdrops(blocks) {
|
|
688
|
+
const raw = blocks.map((block) => splitSlideBackdrop(block.layers ?? []));
|
|
689
|
+
let masterOverlays = (raw[0]?.overlays ?? []).filter(
|
|
690
|
+
(overlay) => overlay.id.startsWith("persistent-") && raw.every(
|
|
691
|
+
(slide) => slide.overlays.some((other) => paintSignature(other) === paintSignature(overlay))
|
|
692
|
+
)
|
|
693
|
+
);
|
|
694
|
+
const masterSignatures = new Set(masterOverlays.map(paintSignature));
|
|
695
|
+
const split = raw.map((slide) => ({
|
|
696
|
+
...compositeAsFarAsPossible(
|
|
697
|
+
slide.base,
|
|
698
|
+
slide.overlays.filter((overlay) => !masterSignatures.has(paintSignature(overlay)))
|
|
699
|
+
),
|
|
700
|
+
content: slide.content
|
|
701
|
+
}));
|
|
702
|
+
const slideOverlays = split.map((slide) => slide.overlays);
|
|
703
|
+
const paintBases = split.map((slide) => slide.base).filter((base) => base?.type === "shape");
|
|
704
|
+
let masterBase = mostCommonPaint(paintBases);
|
|
705
|
+
const masterBaseSignature = masterBase ? paintSignature(masterBase) : null;
|
|
706
|
+
const layouts = [];
|
|
707
|
+
const layoutKeys = /* @__PURE__ */ new Map();
|
|
708
|
+
const groups = [];
|
|
709
|
+
blocks.forEach((block, index) => {
|
|
710
|
+
const key = `${layoutLabel(block)}\0${slideOverlays[index].map(paintSignature).join("\0")}`;
|
|
711
|
+
let layout = layoutKeys.get(key);
|
|
712
|
+
if (layout === void 0) {
|
|
713
|
+
layout = layouts.length;
|
|
714
|
+
layoutKeys.set(key, layout);
|
|
715
|
+
const label = layoutLabel(block);
|
|
716
|
+
const taken = layouts.filter((existing) => existing.name.replace(/ \d+$/, "") === label);
|
|
717
|
+
layouts.push({
|
|
718
|
+
name: taken.length ? `${label} ${taken.length + 1}` : label,
|
|
719
|
+
base: null,
|
|
720
|
+
overlays: slideOverlays[index]
|
|
721
|
+
});
|
|
722
|
+
groups.push([]);
|
|
723
|
+
}
|
|
724
|
+
groups[layout].push(index);
|
|
725
|
+
});
|
|
726
|
+
const slides = blocks.map(() => ({ layout: 0, base: null, content: [] }));
|
|
727
|
+
groups.forEach((members, layoutIndex) => {
|
|
728
|
+
const layout = layouts[layoutIndex];
|
|
729
|
+
const groupBase = mostCommonPaint(
|
|
730
|
+
members.map((index) => split[index].base).filter((base) => base?.type === "shape")
|
|
731
|
+
);
|
|
732
|
+
if (groupBase && paintSignature(groupBase) !== masterBaseSignature) layout.base = groupBase;
|
|
733
|
+
const inherited = layout.base ?? masterBase;
|
|
734
|
+
const inheritedSignature = inherited ? paintSignature(inherited) : null;
|
|
735
|
+
for (const index of members) {
|
|
736
|
+
const { base, content } = split[index];
|
|
737
|
+
slides[index] = {
|
|
738
|
+
layout: layoutIndex,
|
|
739
|
+
base: base && paintSignature(base) !== inheritedSignature ? base : null,
|
|
740
|
+
content
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
if (masterBase && masterOverlays.length > 0 && layouts.every((layout) => !layout.base) && slides.every((slide) => !slide.base)) {
|
|
745
|
+
const flat = compositeBackdrop(masterBase, masterOverlays);
|
|
746
|
+
if (flat) {
|
|
747
|
+
masterBase = flat;
|
|
748
|
+
masterOverlays = [];
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
return { master: { base: masterBase, overlays: masterOverlays }, layouts, slides };
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// src/pptx/layerText.ts
|
|
755
|
+
import { splitIconMarkers as splitIconMarkers2 } from "@bendyline/squisq/icon-marker";
|
|
756
|
+
|
|
757
|
+
// src/pptx/textFit.ts
|
|
758
|
+
import { resolveFontFamily as resolveFontFamily2 } from "@bendyline/squisq/schemas";
|
|
759
|
+
import { hasIconMarker, splitIconMarkers } from "@bendyline/squisq/icon-marker";
|
|
760
|
+
var CANVAS_WIDTH = 1920;
|
|
761
|
+
var CANVAS_HEIGHT = 1080;
|
|
762
|
+
var MAX_TEXT_SCALE = 2;
|
|
763
|
+
var FIT_TOLERANCE_PX = 1;
|
|
764
|
+
var PARAGRAPH_GAP_EM = 0.4;
|
|
765
|
+
var LIST_GAP_EM = 0.7;
|
|
766
|
+
var LIST_INDENT_EM = 1.2;
|
|
767
|
+
var MONO_FONT = /\b(mono|monospace|courier|consolas|menlo|monaco)\b/;
|
|
768
|
+
var SERIF_FONT = /\b(serif|georgia|times|garamond|playfair|merriweather|lora|baskerville|cambria|palatino|didot|bodoni)\b/;
|
|
769
|
+
function averageCharEm(style, basis = "office") {
|
|
770
|
+
const first = resolveFontFamily2(style.fontFamily, "sans-serif").split(",")[0].trim().replace(/^['"]|['"]$/g, "").toLowerCase();
|
|
771
|
+
const serif = !first.includes("sans") && SERIF_FONT.test(first);
|
|
772
|
+
const base = MONO_FONT.test(first) ? 0.6 : basis === "browser" ? serif ? 0.5 : 0.44 : serif ? 0.52 : 0.48;
|
|
773
|
+
return style.fontWeight === "bold" ? base * 1.07 : base;
|
|
774
|
+
}
|
|
775
|
+
function wrapByChars(text, charsPerLine) {
|
|
776
|
+
if (!text.trim()) return [""];
|
|
777
|
+
if (charsPerLine <= 0) return [text];
|
|
778
|
+
const lines = [];
|
|
779
|
+
let current = "";
|
|
780
|
+
for (const word of text.split(/\s+/)) {
|
|
781
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
782
|
+
if (candidate.length <= charsPerLine) {
|
|
783
|
+
current = candidate;
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
if (current) lines.push(current);
|
|
787
|
+
let remaining = word;
|
|
788
|
+
while (remaining.length > charsPerLine) {
|
|
789
|
+
lines.push(remaining.slice(0, charsPerLine));
|
|
790
|
+
remaining = remaining.slice(charsPerLine);
|
|
791
|
+
}
|
|
792
|
+
current = remaining;
|
|
793
|
+
}
|
|
794
|
+
if (current) lines.push(current);
|
|
795
|
+
return lines.length > 0 ? lines : [""];
|
|
796
|
+
}
|
|
797
|
+
function measurableText(text) {
|
|
798
|
+
return splitIconMarkers(text).map((run) => run.type === "text" ? run.text : "M").join("");
|
|
799
|
+
}
|
|
800
|
+
function isHtmlText(layer) {
|
|
801
|
+
return Boolean(layer.content.html?.trim()) || hasIconMarker(layer.content.text ?? "");
|
|
802
|
+
}
|
|
803
|
+
function hasBlockGaps(layer) {
|
|
804
|
+
return Boolean(layer.content.html?.trim());
|
|
805
|
+
}
|
|
806
|
+
function layerParagraphs(layer) {
|
|
807
|
+
return layer.content.paragraphs ?? (layer.content.text ?? "").split("\n").map((text) => ({ text }));
|
|
808
|
+
}
|
|
809
|
+
function blockGapsEm(paragraphs, lineHeight) {
|
|
810
|
+
let pending = 0;
|
|
811
|
+
let previous;
|
|
812
|
+
return paragraphs.map((paragraph) => {
|
|
813
|
+
if (!paragraph.text.trim() && !paragraph.list) {
|
|
814
|
+
pending++;
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
const gap = previous && pending > 0 ? (previous.list ? LIST_GAP_EM : PARAGRAPH_GAP_EM) + (pending - 1) * lineHeight : 0;
|
|
818
|
+
pending = 0;
|
|
819
|
+
previous = paragraph;
|
|
820
|
+
return gap;
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
function estimateFlowHeight(paragraphs, style, widthPx, blockGaps) {
|
|
824
|
+
const fontSize = style.fontSize;
|
|
825
|
+
const lineHeight = style.lineHeight ?? 1.4;
|
|
826
|
+
const advance = averageCharEm(style) * fontSize;
|
|
827
|
+
const gaps = blockGaps ? blockGapsEm(paragraphs, lineHeight) : void 0;
|
|
828
|
+
let height = 0;
|
|
829
|
+
paragraphs.forEach((paragraph, index) => {
|
|
830
|
+
const gap = gaps?.[index];
|
|
831
|
+
if (gap === null) return;
|
|
832
|
+
height += (gap ?? 0) * fontSize + (paragraph.spaceBefore ?? 0);
|
|
833
|
+
const indent = paragraph.list ? LIST_INDENT_EM * fontSize * (paragraph.list.level + 1) : 0;
|
|
834
|
+
const charsPerLine = Math.floor(Math.max(advance, widthPx - indent) / advance);
|
|
835
|
+
const lines = measurableText(paragraph.text).split("\n").reduce((count, line) => count + wrapByChars(line, charsPerLine).length, 0);
|
|
836
|
+
height += lines * lineHeight * fontSize;
|
|
837
|
+
});
|
|
838
|
+
return height;
|
|
839
|
+
}
|
|
840
|
+
function htmlContentWidth(layer) {
|
|
841
|
+
const { position, content } = layer;
|
|
842
|
+
const { style } = content;
|
|
843
|
+
const border = style.borderColor && (style.borderWidth ?? 0) > 0 ? style.borderWidth : 0;
|
|
844
|
+
const boxWidth = position.width != null ? resolvePositionValue(position.width, CANVAS_WIDTH) : CANVAS_WIDTH;
|
|
845
|
+
return Math.max(1, boxWidth - ((style.padding ?? 0) + border) * 2);
|
|
846
|
+
}
|
|
847
|
+
function estimateTextHeight(layer, scale, listIndentPx = 0) {
|
|
848
|
+
const { style } = layer.content;
|
|
849
|
+
if (isHtmlText(layer)) {
|
|
850
|
+
return estimateFlowHeight(
|
|
851
|
+
layerParagraphs(layer),
|
|
852
|
+
{ ...style, fontSize: style.fontSize * scale },
|
|
853
|
+
htmlContentWidth(layer),
|
|
854
|
+
hasBlockGaps(layer)
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
const boxWidth = layer.position.width != null ? resolvePositionValue(layer.position.width, CANVAS_WIDTH) : void 0;
|
|
858
|
+
const lineHeight = (style.lineHeight ?? 1.4) * style.fontSize * scale;
|
|
859
|
+
let lineCount = 0;
|
|
860
|
+
let spacing = 0;
|
|
861
|
+
for (const paragraph of layerParagraphs(layer)) {
|
|
862
|
+
spacing += paragraph.spaceBefore ?? 0;
|
|
863
|
+
const indent = paragraph.list ? listIndentPx * (paragraph.list.level + 1) : 0;
|
|
864
|
+
const charsPerLine = boxWidth == null ? void 0 : Math.floor((boxWidth - indent) / (style.fontSize * 0.5));
|
|
865
|
+
for (const line of measurableText(paragraph.text).split("\n")) {
|
|
866
|
+
lineCount += charsPerLine == null ? 1 : wrapByChars(line, charsPerLine).length;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (style.maxLines) lineCount = Math.min(lineCount, style.maxLines);
|
|
870
|
+
return lineCount * lineHeight + spacing;
|
|
871
|
+
}
|
|
872
|
+
function anchorOffset(anchor, size, axis) {
|
|
873
|
+
if (anchor === "center") return size / 2;
|
|
874
|
+
if (axis === "x") return anchor.includes("right") ? size : 0;
|
|
875
|
+
return anchor.includes("bottom") ? size : 0;
|
|
876
|
+
}
|
|
877
|
+
function svgTarget(layer) {
|
|
878
|
+
const { position, content } = layer;
|
|
879
|
+
const { style } = content;
|
|
880
|
+
const anchor = position.anchor ?? "top-left";
|
|
881
|
+
const rawX = resolvePositionValue(position.x, CANVAS_WIDTH);
|
|
882
|
+
const rawY = resolvePositionValue(position.y, CANVAS_HEIGHT);
|
|
883
|
+
const boxWidth = position.width != null ? resolvePositionValue(position.width, CANVAS_WIDTH) : void 0;
|
|
884
|
+
const boxHeight = position.height != null ? resolvePositionValue(position.height, CANVAS_HEIGHT) : void 0;
|
|
885
|
+
const textAnchor = style.textAlign === "center" ? "middle" : style.textAlign === "right" ? "end" : style.textAlign === "left" ? "start" : anchor.includes("right") ? "end" : anchor === "center" ? "middle" : "start";
|
|
886
|
+
const baseline = style.verticalAlign === "top" ? "before" : style.verticalAlign === "middle" ? "middle" : style.verticalAlign === "bottom" ? "after" : anchor.includes("bottom") ? "after" : anchor === "center" ? "middle" : "before";
|
|
887
|
+
let x = rawX;
|
|
888
|
+
if (boxWidth != null) {
|
|
889
|
+
const left = rawX - anchorOffset(anchor, boxWidth, "x");
|
|
890
|
+
x = textAnchor === "middle" ? left + boxWidth / 2 : textAnchor === "end" ? left + boxWidth : left;
|
|
891
|
+
}
|
|
892
|
+
let y = rawY;
|
|
893
|
+
if (boxHeight != null) {
|
|
894
|
+
const top = rawY - anchorOffset(anchor, boxHeight, "y");
|
|
895
|
+
y = baseline === "middle" ? top + boxHeight / 2 : baseline === "after" ? top + boxHeight : top;
|
|
896
|
+
}
|
|
897
|
+
const fitWidth = boxWidth ?? (textAnchor === "middle" ? Math.max(0, Math.min(x, CANVAS_WIDTH - x) * 2) : textAnchor === "end" ? Math.max(0, x) : Math.max(0, CANVAS_WIDTH - x));
|
|
898
|
+
const fitHeight = boxHeight ?? (baseline === "middle" ? Math.max(0, Math.min(y, CANVAS_HEIGHT - y) * 2) : baseline === "after" ? Math.max(0, y) : Math.max(0, CANVAS_HEIGHT - y));
|
|
899
|
+
let lines = measurableText(content.text ?? "").split("\n");
|
|
900
|
+
if (boxWidth != null) {
|
|
901
|
+
const playerChars = Math.floor(boxWidth / (style.fontSize * 0.5));
|
|
902
|
+
lines = lines.flatMap((line) => wrapByChars(line, playerChars));
|
|
903
|
+
}
|
|
904
|
+
if (style.maxLines && lines.length > style.maxLines) lines = lines.slice(0, style.maxLines);
|
|
905
|
+
const longest = Math.max(...lines.map((line) => line.length));
|
|
906
|
+
if (longest === 0) return null;
|
|
907
|
+
const width = longest * averageCharEm(style, "browser") * style.fontSize;
|
|
908
|
+
const height = estimateTextHeight(layer, 1);
|
|
909
|
+
return {
|
|
910
|
+
fits: (scale) => width * scale <= fitWidth + FIT_TOLERANCE_PX && height * scale <= fitHeight + FIT_TOLERANCE_PX,
|
|
911
|
+
rect: (scale) => {
|
|
912
|
+
const w = width * scale;
|
|
913
|
+
const h = height * scale;
|
|
914
|
+
const left = textAnchor === "middle" ? x - w / 2 : textAnchor === "end" ? x - w : x;
|
|
915
|
+
const top = baseline === "middle" ? y - h / 2 : baseline === "after" ? y - h : y;
|
|
916
|
+
return { left, top, right: left + w, bottom: top + h };
|
|
917
|
+
}
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
function htmlTarget(layer) {
|
|
921
|
+
const { position, content } = layer;
|
|
922
|
+
const { style } = content;
|
|
923
|
+
const anchor = position.anchor ?? "top-left";
|
|
924
|
+
const lineHeight = style.lineHeight ?? 1.4;
|
|
925
|
+
const padding = style.padding ?? 0;
|
|
926
|
+
const border = style.borderColor && (style.borderWidth ?? 0) > 0 ? style.borderWidth : 0;
|
|
927
|
+
const rich = hasBlockGaps(layer);
|
|
928
|
+
const boxWidth = position.width != null ? resolvePositionValue(position.width, CANVAS_WIDTH) : CANVAS_WIDTH;
|
|
929
|
+
const plain = measurableText(content.text ?? "");
|
|
930
|
+
const boxHeight = position.height != null ? resolvePositionValue(position.height, CANVAS_HEIGHT) : rich ? style.fontSize * lineHeight * 2 : Math.max(
|
|
931
|
+
1,
|
|
932
|
+
plain.split("\n").flatMap((line) => wrapByChars(line, Math.floor(boxWidth / (style.fontSize * 0.5)))).length
|
|
933
|
+
) * style.fontSize * lineHeight + padding * 2;
|
|
934
|
+
const inset = padding + border;
|
|
935
|
+
const left = resolvePositionValue(position.x, CANVAS_WIDTH) - anchorOffset(anchor, boxWidth, "x");
|
|
936
|
+
const top = resolvePositionValue(position.y, CANVAS_HEIGHT) - anchorOffset(anchor, boxHeight, "y");
|
|
937
|
+
const availableWidth = Math.max(1, boxWidth - inset * 2);
|
|
938
|
+
const availableHeight = Math.max(0, boxHeight - inset * 2);
|
|
939
|
+
const paragraphs = layerParagraphs(layer);
|
|
940
|
+
if (!paragraphs.some((paragraph) => paragraph.text.trim())) return null;
|
|
941
|
+
const justify = rich ? style.verticalAlign ?? "top" : style.verticalAlign === "top" || style.verticalAlign === "bottom" ? style.verticalAlign : "middle";
|
|
942
|
+
const heightAt = (scale) => estimateTextHeight(layer, scale);
|
|
943
|
+
return {
|
|
944
|
+
fits: (scale) => heightAt(scale) <= availableHeight + FIT_TOLERANCE_PX,
|
|
945
|
+
rect: (scale) => {
|
|
946
|
+
const h = heightAt(scale);
|
|
947
|
+
const contentTop = justify === "middle" ? top + (boxHeight - h) / 2 : justify === "bottom" ? top + boxHeight - inset - h : top + inset;
|
|
948
|
+
return {
|
|
949
|
+
left: left + inset,
|
|
950
|
+
top: contentTop,
|
|
951
|
+
right: left + inset + availableWidth,
|
|
952
|
+
bottom: contentTop + h
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
function overlapPairs(rects) {
|
|
958
|
+
const pairs = /* @__PURE__ */ new Set();
|
|
959
|
+
for (let a = 0; a < rects.length; a++) {
|
|
960
|
+
for (let b = a + 1; b < rects.length; b++) {
|
|
961
|
+
const one = rects[a];
|
|
962
|
+
const two = rects[b];
|
|
963
|
+
if (one.left < two.right - FIT_TOLERANCE_PX && one.right > two.left + FIT_TOLERANCE_PX && one.top < two.bottom - FIT_TOLERANCE_PX && one.bottom > two.top + FIT_TOLERANCE_PX) {
|
|
964
|
+
pairs.add(`${a}:${b}`);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
return pairs;
|
|
969
|
+
}
|
|
970
|
+
function estimateSlideTextScale(layers) {
|
|
971
|
+
const targets = layers.filter((layer) => layer.type === "text").map((layer) => isHtmlText(layer) ? htmlTarget(layer) : svgTarget(layer)).filter((target) => target !== null);
|
|
972
|
+
if (targets.length === 0) return 1;
|
|
973
|
+
const baseline = overlapPairs(targets.map((target) => target.rect(1)));
|
|
974
|
+
const fits = (scale) => {
|
|
975
|
+
if (!targets.every((target) => target.fits(scale))) return false;
|
|
976
|
+
for (const pair of overlapPairs(targets.map((target) => target.rect(scale)))) {
|
|
977
|
+
if (!baseline.has(pair)) return false;
|
|
978
|
+
}
|
|
979
|
+
return true;
|
|
980
|
+
};
|
|
981
|
+
if (!fits(1)) return 1;
|
|
982
|
+
if (fits(MAX_TEXT_SCALE)) return MAX_TEXT_SCALE;
|
|
983
|
+
let low = 1;
|
|
984
|
+
let high = MAX_TEXT_SCALE;
|
|
985
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
986
|
+
const candidate = (low + high) / 2;
|
|
987
|
+
if (fits(candidate)) low = candidate;
|
|
988
|
+
else high = candidate;
|
|
989
|
+
}
|
|
990
|
+
return Math.max(1, Math.floor(low * 100) / 100);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// src/pptx/textLists.ts
|
|
994
|
+
function mergeTextFlows(layers, textScale = 1) {
|
|
995
|
+
const flows = /* @__PURE__ */ new Map();
|
|
996
|
+
for (const layer of layers) {
|
|
997
|
+
if (layer.type !== "text" || !layer.content.flow) continue;
|
|
998
|
+
const members = flows.get(layer.content.flow.id) ?? [];
|
|
999
|
+
members.push(layer);
|
|
1000
|
+
flows.set(layer.content.flow.id, members);
|
|
1001
|
+
}
|
|
1002
|
+
const units = [];
|
|
1003
|
+
const emitted = /* @__PURE__ */ new Set();
|
|
1004
|
+
for (const layer of layers) {
|
|
1005
|
+
const flowId = layer.type === "text" ? layer.content.flow?.id : void 0;
|
|
1006
|
+
const members = flowId === void 0 ? void 0 : flows.get(flowId);
|
|
1007
|
+
const bodies = members?.filter((member) => member.content.flow?.role !== "marker") ?? [];
|
|
1008
|
+
if (flowId === void 0 || !members || !bodies.some((body) => body.content.paragraphs)) {
|
|
1009
|
+
units.push({ layer });
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (emitted.has(flowId)) continue;
|
|
1013
|
+
emitted.add(flowId);
|
|
1014
|
+
const rects = members.map((member) => {
|
|
1015
|
+
const { fontSize, lineHeight } = member.content.style;
|
|
1016
|
+
return resolveLayerRect(
|
|
1017
|
+
member.position,
|
|
1018
|
+
Math.max(fontSize, member.content.text.length * fontSize * 0.55),
|
|
1019
|
+
fontSize * (lineHeight ?? 1.4)
|
|
1020
|
+
);
|
|
1021
|
+
});
|
|
1022
|
+
const left = Math.min(...rects.map((rect) => rect.x));
|
|
1023
|
+
const top = Math.min(...rects.map((rect) => rect.y));
|
|
1024
|
+
const right = Math.max(...rects.map((rect) => rect.x + rect.width));
|
|
1025
|
+
const bottom = Math.max(...rects.map((rect) => rect.y + rect.height));
|
|
1026
|
+
const first = bodies[0];
|
|
1027
|
+
const indent = rects[members.indexOf(first)].x - left;
|
|
1028
|
+
const paragraphs = bodies.flatMap((body, index) => {
|
|
1029
|
+
const own = body.content.paragraphs ?? [{ text: body.content.text }];
|
|
1030
|
+
const previous = bodies[index - 1];
|
|
1031
|
+
if (!previous || own.length === 0) return own;
|
|
1032
|
+
const previousRect = rects[members.indexOf(previous)];
|
|
1033
|
+
const gap = rects[members.indexOf(body)].y - (previousRect.y + estimateTextHeight(previous, textScale));
|
|
1034
|
+
const [head, ...rest] = own;
|
|
1035
|
+
return [{ ...head, spaceBefore: Math.max(0, gap) }, ...rest];
|
|
1036
|
+
});
|
|
1037
|
+
units.push({
|
|
1038
|
+
layer: {
|
|
1039
|
+
...first,
|
|
1040
|
+
id: flowId,
|
|
1041
|
+
position: { x: left, y: top, width: right - left, height: bottom - top },
|
|
1042
|
+
content: {
|
|
1043
|
+
text: bodies.map((body) => body.content.text).join("\n"),
|
|
1044
|
+
paragraphs,
|
|
1045
|
+
style: first.content.style
|
|
1046
|
+
}
|
|
1047
|
+
},
|
|
1048
|
+
...indent > 0 ? { listIndentPx: indent } : {}
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
return units;
|
|
1052
|
+
}
|
|
1053
|
+
function planListParagraphs(paragraphs) {
|
|
1054
|
+
const runs = [];
|
|
1055
|
+
return paragraphs.map(({ list }) => {
|
|
1056
|
+
if (!list) {
|
|
1057
|
+
runs.length = 0;
|
|
1058
|
+
return void 0;
|
|
1059
|
+
}
|
|
1060
|
+
const level = Math.min(8, Math.max(0, Math.floor(list.level)));
|
|
1061
|
+
runs.length = Math.min(runs.length, level + 1);
|
|
1062
|
+
if (!list.ordered) {
|
|
1063
|
+
runs[level] = void 0;
|
|
1064
|
+
return { ordered: false, level };
|
|
1065
|
+
}
|
|
1066
|
+
const number = list.number ?? 1;
|
|
1067
|
+
const run = runs[level];
|
|
1068
|
+
if (run && run.next === number) run.next++;
|
|
1069
|
+
else runs[level] = { start: number, next: number + 1 };
|
|
1070
|
+
return { ordered: true, level, startAt: runs[level].start };
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
function listParagraphXml(plan, indentPx) {
|
|
1074
|
+
const hang = Math.round(indentPx * EMU_PER_CANVAS_PX);
|
|
1075
|
+
const startAt = plan.startAt && plan.startAt !== 1 ? ` startAt="${plan.startAt}"` : "";
|
|
1076
|
+
return {
|
|
1077
|
+
attributes: ` marL="${hang * (plan.level + 1)}" lvl="${plan.level}" indent="${-hang}"`,
|
|
1078
|
+
bullet: plan.ordered ? `<a:buAutoNum type="arabicPeriod"${startAt}/>` : `<a:buChar char="\u2022"/>`
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// src/pptx/layerText.ts
|
|
1083
|
+
function buildLayerTextShape(layer, shapeId, maxBottom, listIndentPx, textScale = 1) {
|
|
1084
|
+
const sourceText = layer.content.text ?? "";
|
|
1085
|
+
const text = measurableText(sourceText);
|
|
1086
|
+
const baseStyle = layer.content.style;
|
|
1087
|
+
const style = textScale === 1 ? baseStyle : { ...baseStyle, fontSize: baseStyle.fontSize * textScale };
|
|
1088
|
+
const padding = style.padding ?? 0;
|
|
1089
|
+
const rawLines = text.split("\n");
|
|
1090
|
+
const charEm = averageCharEm(style);
|
|
1091
|
+
const estimatedWidth = Math.min(
|
|
1092
|
+
1920,
|
|
1093
|
+
Math.max(
|
|
1094
|
+
1,
|
|
1095
|
+
Math.max(...rawLines.map((line) => line.length), 1) * style.fontSize * // A box sized to the text must not wrap it when Office's face for
|
|
1096
|
+
// the CSS stack runs a little wider than the estimate.
|
|
1097
|
+
Math.max(0.55, charEm * 1.15) + padding * 2
|
|
1098
|
+
)
|
|
1099
|
+
);
|
|
1100
|
+
const boxWidth = layer.position.width != null ? resolvePositionValue(layer.position.width, 1920) : estimatedWidth;
|
|
1101
|
+
const fittedFontSize = rawLines.length === 1 && style.fontSize >= 80 && text.length > 0 ? Math.min(style.fontSize, (boxWidth - padding * 2) / (text.length * charEm * 1.05)) : style.fontSize;
|
|
1102
|
+
const blockGaps = hasBlockGaps(layer);
|
|
1103
|
+
const paragraphSource = layerParagraphs(layer);
|
|
1104
|
+
const neededHeight = Math.max(
|
|
1105
|
+
fittedFontSize * (style.lineHeight ?? 1.4),
|
|
1106
|
+
estimateTextHeight(layer, fittedFontSize / baseStyle.fontSize, listIndentPx)
|
|
1107
|
+
);
|
|
1108
|
+
const reserve = fittedFontSize * OFFICE_TEXT_RESERVE_EM;
|
|
1109
|
+
const estimatedHeight = neededHeight + reserve + padding * 2;
|
|
1110
|
+
const resolvedRect = resolveLayerRect(layer.position, boxWidth, estimatedHeight);
|
|
1111
|
+
const safeBottom = Math.min(1060, maxBottom ?? 1060);
|
|
1112
|
+
const vertical = style.verticalAlign === "bottom" ? "b" : style.verticalAlign === "middle" ? "ctr" : "t";
|
|
1113
|
+
let virtualRect = {
|
|
1114
|
+
...resolvedRect,
|
|
1115
|
+
y: Math.min(resolvedRect.y, safeBottom - 1),
|
|
1116
|
+
height: Math.max(1, Math.min(resolvedRect.height, safeBottom - resolvedRect.y))
|
|
1117
|
+
};
|
|
1118
|
+
const visibleBox = !!style.background || !!style.backgroundGradient || !!(style.borderColor && (style.borderWidth ?? 0) > 0);
|
|
1119
|
+
if (!visibleBox && virtualRect.height < estimatedHeight) {
|
|
1120
|
+
const extra = estimatedHeight - virtualRect.height;
|
|
1121
|
+
const above = vertical === "b" ? extra : vertical === "ctr" ? extra / 2 : 0;
|
|
1122
|
+
const top = Math.max(0, virtualRect.y - above);
|
|
1123
|
+
const bottom = Math.min(safeBottom, virtualRect.y + virtualRect.height + extra - above);
|
|
1124
|
+
virtualRect = { ...virtualRect, y: top, height: Math.max(virtualRect.height, bottom - top) };
|
|
1125
|
+
}
|
|
1126
|
+
const availableHeight = Math.max(1, virtualRect.height - padding * 2);
|
|
1127
|
+
const heightScale = neededHeight > availableHeight + FIT_TOLERANCE_PX2 ? availableHeight / neededHeight : 1;
|
|
1128
|
+
const renderedFontSize = Math.max(1, fittedFontSize * heightScale * (heightScale < 1 ? 0.95 : 1));
|
|
1129
|
+
const renderedStyle = renderedFontSize === style.fontSize ? style : { ...style, fontSize: renderedFontSize };
|
|
1130
|
+
const rect = toEmuRect(virtualRect);
|
|
1131
|
+
const align = style.textAlign === "center" ? "ctr" : style.textAlign === "right" ? "r" : "l";
|
|
1132
|
+
const inset = Math.max(0, Math.round(padding * EMU_PER_CANVAS_PX));
|
|
1133
|
+
const bodyPr = `<a:bodyPr wrap="square" anchor="${vertical}" vertOverflow="clip" horzOverflow="clip" lIns="${inset}" rIns="${inset}" tIns="${inset}" bIns="${inset}"><a:noAutofit/></a:bodyPr>`;
|
|
1134
|
+
const paragraphs = layer.content.paragraphs || blockGaps ? buildStructuredParagraphs(paragraphSource, renderedStyle, align, {
|
|
1135
|
+
// A list with no marker column of its own hangs its bullets by the
|
|
1136
|
+
// player's list indent.
|
|
1137
|
+
indentPx: listIndentPx ?? renderedStyle.fontSize * LIST_INDENT_EM,
|
|
1138
|
+
spacingScale: renderedStyle.fontSize / fittedFontSize,
|
|
1139
|
+
blockGaps
|
|
1140
|
+
}) : sourceText.split("\n").map((line) => buildLayerTextParagraph(line, renderedStyle, align)).join("");
|
|
1141
|
+
return `<p:sp><p:nvSpPr><p:cNvPr id="${shapeId}" name="${escapeXml(layer.id)}"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="${rect.x}" y="${rect.y}"/><a:ext cx="${rect.width}" cy="${rect.height}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>` + fillXml(style.background, style.backgroundOpacity, style.backgroundGradient) + lineXml(style.borderColor, style.borderWidth, style.borderStyle) + `</p:spPr><p:txBody>${bodyPr}<a:lstStyle/>${paragraphs}</p:txBody></p:sp>`;
|
|
1142
|
+
}
|
|
1143
|
+
var TEXT_PX_TO_CENTIPOINTS = 50;
|
|
1144
|
+
var OFFICE_SINGLE_LINE_EM = 1.2;
|
|
1145
|
+
var OFFICE_TEXT_RESERVE_EM = 0.3;
|
|
1146
|
+
var FIT_TOLERANCE_PX2 = 1;
|
|
1147
|
+
function buildStructuredParagraphs(paragraphs, style, align, options) {
|
|
1148
|
+
const plans = planListParagraphs(paragraphs);
|
|
1149
|
+
const gaps = options.blockGaps ? blockGapsEm(paragraphs, style.lineHeight ?? 1.4) : void 0;
|
|
1150
|
+
return paragraphs.map((paragraph, index) => {
|
|
1151
|
+
const gap = gaps?.[index];
|
|
1152
|
+
if (gap === null) return "";
|
|
1153
|
+
return buildLayerTextParagraph(paragraph.text, style, align, {
|
|
1154
|
+
spaceBefore: (gap ?? 0) * style.fontSize + (paragraph.spaceBefore ?? 0) * options.spacingScale,
|
|
1155
|
+
list: plans[index],
|
|
1156
|
+
indentPx: options.indentPx
|
|
1157
|
+
});
|
|
1158
|
+
}).join("");
|
|
1159
|
+
}
|
|
1160
|
+
function buildLayerTextParagraph(text, style, align, options = {}) {
|
|
1161
|
+
const size = Math.max(100, Math.round(style.fontSize * TEXT_PX_TO_CENTIPOINTS));
|
|
1162
|
+
const color = parseCssColor(style.color, "000000");
|
|
1163
|
+
const typeface = officeTypeface(style.fontFamily, DEFAULT_FONT);
|
|
1164
|
+
const textRunProperties = [
|
|
1165
|
+
`lang="en-US"`,
|
|
1166
|
+
`dirty="0"`,
|
|
1167
|
+
`sz="${size}"`,
|
|
1168
|
+
...style.fontWeight === "bold" ? ['b="1"'] : [],
|
|
1169
|
+
...style.fontStyle === "italic" ? ['i="1"'] : []
|
|
1170
|
+
].join(" ");
|
|
1171
|
+
const lineSpacing = Math.max(
|
|
1172
|
+
1e4,
|
|
1173
|
+
Math.round((style.lineHeight ?? 1.4) / OFFICE_SINGLE_LINE_EM * 1e5)
|
|
1174
|
+
);
|
|
1175
|
+
const lineRuns = (line) => splitIconMarkers2(line).map((run) => {
|
|
1176
|
+
if (run.type === "text") {
|
|
1177
|
+
if (!run.text) return "";
|
|
1178
|
+
return `<a:r><a:rPr ${textRunProperties}>${solidColorXml(color)}<a:latin typeface="${escapeXml(typeface)}"/></a:rPr><a:t>${escapeXml(run.text)}</a:t></a:r>`;
|
|
1179
|
+
}
|
|
1180
|
+
const glyph = fontAwesomeGlyph(run.family, run.name);
|
|
1181
|
+
if (!glyph) return "";
|
|
1182
|
+
const iconTypeface = fontAwesomeFace(run.family).typeface;
|
|
1183
|
+
return `<a:r><a:rPr lang="en-US" dirty="0" sz="${size}">${solidColorXml(color)}<a:latin typeface="${escapeXml(iconTypeface)}"/></a:rPr><a:t>${escapeXml(glyph)}</a:t></a:r>`;
|
|
1184
|
+
}).join("");
|
|
1185
|
+
const runs = text.split("\n").map(lineRuns).join(`<a:br><a:rPr ${textRunProperties}/></a:br>`);
|
|
1186
|
+
const spaceBefore = Math.round((options.spaceBefore ?? 0) * TEXT_PX_TO_CENTIPOINTS);
|
|
1187
|
+
const { attributes, bullet } = options.list ? listParagraphXml(options.list, options.indentPx ?? style.fontSize * LIST_INDENT_EM) : { attributes: "", bullet: "" };
|
|
1188
|
+
return `<a:p><a:pPr${attributes} algn="${align}"><a:lnSpc><a:spcPct val="${lineSpacing}"/></a:lnSpc>` + (spaceBefore > 0 ? `<a:spcBef><a:spcPts val="${spaceBefore}"/></a:spcBef>` : "") + // The bullet takes the text's colour explicitly: renderers that do not
|
|
1189
|
+
// follow the first run fall back to the master's default — black, which
|
|
1190
|
+
// vanishes on a dark deck.
|
|
1191
|
+
(bullet ? `<a:buClr><a:srgbClr val="${color.hex}"/></a:buClr>${bullet}` : "") + `</a:pPr>` + (runs || `<a:r><a:rPr ${textRunProperties}>${solidColorXml(color)}<a:latin typeface="${escapeXml(typeface)}"/></a:rPr><a:t> </a:t></a:r>`) + `</a:p>`;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
// src/pptx/masters.ts
|
|
1195
|
+
import { relativeLuminance } from "@bendyline/squisq/schemas";
|
|
1196
|
+
var GROUP_SHAPE_PROPS = `<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>`;
|
|
1197
|
+
function backgroundXml(fill) {
|
|
1198
|
+
return `<p:bg><p:bgPr>${fill}<a:effectLst/></p:bgPr></p:bg>`;
|
|
1199
|
+
}
|
|
1200
|
+
function paintBackgroundXml(base) {
|
|
1201
|
+
const { fill, fillOpacity, gradient, pattern } = base.content;
|
|
1202
|
+
return backgroundXml(
|
|
1203
|
+
pattern ? patternFillXml(pattern, fill) : fillXml(fill, fillOpacity, gradient)
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
function colorBackgroundXml(hex) {
|
|
1207
|
+
return backgroundXml(`<a:solidFill><a:srgbClr val="${hex}"/></a:solidFill>`);
|
|
1208
|
+
}
|
|
1209
|
+
function photoBackgroundXml(relId, layer, data) {
|
|
1210
|
+
let crop = "";
|
|
1211
|
+
const size = layer.content.fit === "cover" ? readImageDimensions(data) : null;
|
|
1212
|
+
if (size && size.width > 0 && size.height > 0) {
|
|
1213
|
+
const imageAspect = size.width / size.height;
|
|
1214
|
+
const slideAspect = 16 / 9;
|
|
1215
|
+
if (Math.abs(imageAspect - slideAspect) > 1e-3) {
|
|
1216
|
+
const trim = imageAspect > slideAspect ? 1 - slideAspect / imageAspect : 1 - imageAspect / slideAspect;
|
|
1217
|
+
const edge = Math.round(trim / 2 * 1e5);
|
|
1218
|
+
crop = imageAspect > slideAspect ? `<a:srcRect l="${edge}" r="${edge}"/>` : `<a:srcRect t="${edge}" b="${edge}"/>`;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return backgroundXml(
|
|
1222
|
+
`<a:blipFill dpi="0" rotWithShape="1"><a:blip r:embed="${relId}"/>${crop}<a:stretch><a:fillRect/></a:stretch></a:blipFill>`
|
|
1223
|
+
);
|
|
1224
|
+
}
|
|
1225
|
+
function overlayShapesXml(overlays) {
|
|
1226
|
+
return overlays.map((overlay, index) => buildLayerShape(overlay, index + 2)).join("");
|
|
1227
|
+
}
|
|
1228
|
+
function buildDeckLayoutXml(layout) {
|
|
1229
|
+
return xmlDeclaration() + `<p:sldLayout xmlns:a="${NS_DRAWINGML}" xmlns:r="${NS_R}" xmlns:p="${NS_PML}" preserve="1" userDrawn="1"><p:cSld name="${escapeXml(layout.name)}">` + (layout.base ? paintBackgroundXml(layout.base) : "") + `<p:spTree>${GROUP_SHAPE_PROPS}${overlayShapesXml(layout.overlays)}</p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:sldLayout>`;
|
|
1230
|
+
}
|
|
1231
|
+
function isDarkTheme(theme) {
|
|
1232
|
+
const background = toOoxmlHex(theme.colors.background, "FFFFFF");
|
|
1233
|
+
const text = toOoxmlHex(theme.colors.text, "000000");
|
|
1234
|
+
return relativeLuminance(`#${background}`) < relativeLuminance(`#${text}`);
|
|
1235
|
+
}
|
|
1236
|
+
function colorMapXml(dark) {
|
|
1237
|
+
const roles = dark ? 'bg1="dk1" tx1="lt1" bg2="dk2" tx2="lt2"' : 'bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2"';
|
|
1238
|
+
return `<p:clrMap ${roles} accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/>`;
|
|
1239
|
+
}
|
|
1240
|
+
function buildDeckMasterXml(options) {
|
|
1241
|
+
const layoutIds = options.layoutRelIds.map((relId, index) => `<p:sldLayoutId id="${2147483649 + index}" r:id="${relId}"/>`).join("");
|
|
1242
|
+
return xmlDeclaration() + `<p:sldMaster xmlns:a="${NS_DRAWINGML}" xmlns:r="${NS_R}" xmlns:p="${NS_PML}"><p:cSld>${options.backgroundXml}<p:spTree>${GROUP_SHAPE_PROPS}${overlayShapesXml(options.overlays)}</p:spTree></p:cSld>` + colorMapXml(options.dark) + `<p:sldLayoutIdLst>${layoutIds}</p:sldLayoutIdLst></p:sldMaster>`;
|
|
1243
|
+
}
|
|
1244
|
+
function buildDeckThemeXml(theme) {
|
|
1245
|
+
const c = theme.colors;
|
|
1246
|
+
const hex = (color, fallback) => toOoxmlHex(color, fallback);
|
|
1247
|
+
const dark = isDarkTheme(theme);
|
|
1248
|
+
const background = hex(c.background, "FFFFFF");
|
|
1249
|
+
const text = hex(c.text, "000000");
|
|
1250
|
+
const backgroundLight = hex(c.backgroundLight, background);
|
|
1251
|
+
const textMuted = hex(c.textMuted, text);
|
|
1252
|
+
const accents = [c.primary, c.secondary, c.highlight, c.warning].concat(Object.values(theme.colorSchemes ?? {}).map((scheme) => scheme.accent)).map((color) => color ? toOoxmlHex(color, "") : "").filter((color, index, all) => color && all.indexOf(color) === index);
|
|
1253
|
+
const distinct = accents.length > 0 ? [...accents] : [text];
|
|
1254
|
+
for (let index = accents.length; index < 6; index++) {
|
|
1255
|
+
accents.push(distinct[index % distinct.length]);
|
|
1256
|
+
}
|
|
1257
|
+
const slots = [
|
|
1258
|
+
["dk1", dark ? background : text],
|
|
1259
|
+
["lt1", dark ? text : background],
|
|
1260
|
+
["dk2", dark ? backgroundLight : textMuted],
|
|
1261
|
+
["lt2", dark ? textMuted : backgroundLight],
|
|
1262
|
+
...accents.slice(0, 6).map((color, index) => [`accent${index + 1}`, color]),
|
|
1263
|
+
["hlink", hex(c.highlight || c.primary, "0563C1")],
|
|
1264
|
+
["folHlink", textMuted]
|
|
1265
|
+
];
|
|
1266
|
+
const name = escapeXml(theme.name || "Squisq");
|
|
1267
|
+
const titleFont = escapeXml(officeTypeface(theme.typography?.titleFont, DEFAULT_TITLE_FONT));
|
|
1268
|
+
const bodyFont = escapeXml(officeTypeface(theme.typography?.bodyFont, DEFAULT_FONT));
|
|
1269
|
+
const solid = `<a:solidFill><a:schemeClr val="phClr"/></a:solidFill>`;
|
|
1270
|
+
return xmlDeclaration() + `<a:theme xmlns:a="${NS_DRAWINGML}" name="${name}"><a:themeElements><a:clrScheme name="${name}">` + slots.map(([slot, color]) => `<a:${slot}><a:srgbClr val="${color}"/></a:${slot}>`).join("") + `</a:clrScheme><a:fontScheme name="${name}"><a:majorFont><a:latin typeface="${titleFont}"/><a:ea typeface=""/><a:cs typeface=""/></a:majorFont><a:minorFont><a:latin typeface="${bodyFont}"/><a:ea typeface=""/><a:cs typeface=""/></a:minorFont></a:fontScheme><a:fmtScheme name="${name}"><a:fillStyleLst>${solid}${solid}${solid}</a:fillStyleLst><a:lnStyleLst><a:ln w="6350">${solid}</a:ln><a:ln w="6350">${solid}</a:ln><a:ln w="6350">${solid}</a:ln></a:lnStyleLst><a:effectStyleLst><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle><a:effectStyle><a:effectLst/></a:effectStyle></a:effectStyleLst><a:bgFillStyleLst>${solid}${solid}${solid}</a:bgFillStyleLst></a:fmtScheme></a:themeElements></a:theme>`;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
182
1273
|
// src/pptx/templates.ts
|
|
183
1274
|
function buildPresentationXml(slideCount, slideRelIds, slideMasterRelId, _themeRelId, embeddedFonts = []) {
|
|
184
1275
|
const sldIdList = [];
|
|
@@ -290,9 +1381,10 @@ async function docToPptx(doc, options = {}) {
|
|
|
290
1381
|
const hasLayer = (type) => slideBlocks.some((b) => b.layers?.some((l) => l.type === type));
|
|
291
1382
|
const videoPosters = hasLayer("videoEmbed") ? await import("./videoPoster-D7DTZC2L.js") : null;
|
|
292
1383
|
const hasSvgImage = slideBlocks.some(
|
|
293
|
-
(b) => b.layers?.some((l) => l.type === "image" &&
|
|
1384
|
+
(b) => b.layers?.some((l) => l.type === "image" && isSvgImageSource2(l.content.src))
|
|
294
1385
|
);
|
|
295
1386
|
const svgIslandPosters = hasLayer("svg") || hasSvgImage ? await import("./svgIslandPoster-RYA6PDX7.js") : null;
|
|
1387
|
+
const backdrops = planDeckBackdrops(slideBlocks);
|
|
296
1388
|
const slideXmls = [];
|
|
297
1389
|
const slideContexts = [];
|
|
298
1390
|
for (let index = 0; index < slideBlocks.length; index++) {
|
|
@@ -307,15 +1399,21 @@ async function docToPptx(doc, options = {}) {
|
|
|
307
1399
|
ctx.diagramImages = effectiveOptions.diagramImages;
|
|
308
1400
|
ctx.videoPosters = videoPosters;
|
|
309
1401
|
ctx.svgIslandPosters = svgIslandPosters;
|
|
310
|
-
|
|
1402
|
+
const plan = backdrops.slides[index];
|
|
1403
|
+
ctx.useLayout(plan.layout + 1);
|
|
1404
|
+
slideXmls.push(buildLayerSlideXml(slideBlocks[index], ctx, plan));
|
|
311
1405
|
slideContexts.push(ctx);
|
|
312
1406
|
}
|
|
313
|
-
return buildPptxPackage(slideXmls, slideContexts, effectiveOptions
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
1407
|
+
return buildPptxPackage(slideXmls, slideContexts, effectiveOptions, {
|
|
1408
|
+
layoutXmls: backdrops.layouts.map(buildDeckLayoutXml),
|
|
1409
|
+
masterXml: (layoutRelIds) => buildDeckMasterXml({
|
|
1410
|
+
backgroundXml: backdrops.master.base ? paintBackgroundXml(backdrops.master.base) : colorBackgroundXml(style.background),
|
|
1411
|
+
overlays: backdrops.master.overlays,
|
|
1412
|
+
layoutRelIds,
|
|
1413
|
+
dark: isDarkTheme(theme)
|
|
1414
|
+
}),
|
|
1415
|
+
themeXml: buildDeckThemeXml(theme)
|
|
1416
|
+
});
|
|
319
1417
|
}
|
|
320
1418
|
function resolveSlideStyle(themeId, options, doc) {
|
|
321
1419
|
if (!themeId) {
|
|
@@ -430,6 +1528,10 @@ var SlideContext = class {
|
|
|
430
1528
|
allocRelId() {
|
|
431
1529
|
return this.relIds.alloc();
|
|
432
1530
|
}
|
|
1531
|
+
/** Point this slide at slide layout `number` (1-based, as its part is named). */
|
|
1532
|
+
useLayout(number) {
|
|
1533
|
+
this.relationships[0].target = `../slideLayouts/slideLayout${number}.xml`;
|
|
1534
|
+
}
|
|
433
1535
|
allocShapeId() {
|
|
434
1536
|
return this.nextShapeId++;
|
|
435
1537
|
}
|
|
@@ -527,31 +1629,46 @@ function buildSlideXml(slide, ctx) {
|
|
|
527
1629
|
const extensionAttrs = transitionXml.includes("p14:") ? ` xmlns:mc="${NS_MC}" xmlns:p14="${NS_PML_2010}" mc:Ignorable="p14"` : "";
|
|
528
1630
|
return xmlDeclaration() + `<p:sld xmlns:a="${NS_DRAWINGML}" xmlns:r="${NS_R}" xmlns:p="${NS_PML}"${extensionAttrs}><p:cSld>` + bgXml + `<p:spTree>` + shapes.join("") + `</p:spTree></p:cSld>` + transitionXml + `</p:sld>`;
|
|
529
1631
|
}
|
|
530
|
-
function buildLayerSlideXml(block, ctx) {
|
|
1632
|
+
function buildLayerSlideXml(block, ctx, plan) {
|
|
531
1633
|
const shapes = [
|
|
532
1634
|
`<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr/>`
|
|
533
1635
|
];
|
|
534
|
-
|
|
535
|
-
|
|
1636
|
+
let layers = plan.content;
|
|
1637
|
+
let bgXml = "";
|
|
1638
|
+
if (plan.base?.type === "image") {
|
|
1639
|
+
const photo = plan.base;
|
|
1640
|
+
const data = ctx.images?.get(photo.content.src);
|
|
1641
|
+
if (data) {
|
|
1642
|
+
bgXml = photoBackgroundXml(
|
|
1643
|
+
ctx.addImage(photo.content.src, data, photo.content.alt),
|
|
1644
|
+
photo,
|
|
1645
|
+
data
|
|
1646
|
+
);
|
|
1647
|
+
} else {
|
|
1648
|
+
layers = [photo, ...layers];
|
|
1649
|
+
}
|
|
1650
|
+
} else if (plan.base) {
|
|
1651
|
+
bgXml = paintBackgroundXml(plan.base);
|
|
1652
|
+
}
|
|
1653
|
+
const textScale = isGeometryBoundTemplate(block.template) ? 1 : estimateSlideTextScale(layers);
|
|
1654
|
+
const units = mergeTextFlows(layers, textScale);
|
|
1655
|
+
const textLayerY = units.filter(({ layer }) => layer.type === "text").map(({ layer }) => resolvePositionValue(layer.position.y, 1080));
|
|
1656
|
+
for (const layer of layers) {
|
|
536
1657
|
if (layer.type !== "text") continue;
|
|
537
|
-
for (const run of
|
|
1658
|
+
for (const run of splitIconMarkers3(layer.content.text ?? "")) {
|
|
538
1659
|
if (run.type === "icon") ctx.iconFamilies.add(run.family);
|
|
539
1660
|
}
|
|
540
1661
|
}
|
|
541
|
-
for (let index = 0; index <
|
|
1662
|
+
for (let index = 0; index < units.length; index++) {
|
|
542
1663
|
if ((index & 31) === 0) ctx.signal?.throwIfAborted();
|
|
543
|
-
const layer =
|
|
1664
|
+
const { layer, listIndentPx } = units[index];
|
|
544
1665
|
const currentY = layer.type === "text" ? resolvePositionValue(layer.position.y, 1080) : void 0;
|
|
545
1666
|
const nextTextY = currentY === void 0 ? void 0 : textLayerY.filter((candidate) => candidate > currentY + 1).sort((a, b) => a - b)[0];
|
|
1667
|
+
const textBottom = nextTextY === void 0 || currentY === void 0 ? void 0 : Math.max(currentY + 1, nextTextY - 8);
|
|
546
1668
|
shapes.push(
|
|
547
|
-
...convertLayerToShapes(
|
|
548
|
-
layer,
|
|
549
|
-
ctx,
|
|
550
|
-
nextTextY === void 0 || currentY === void 0 ? void 0 : Math.max(currentY + 1, nextTextY - 8)
|
|
551
|
-
)
|
|
1669
|
+
...layer.type === "text" ? [buildLayerTextShape(layer, ctx.allocShapeId(), textBottom, listIndentPx, textScale)] : convertLayerToShapes(layer, ctx, textBottom)
|
|
552
1670
|
);
|
|
553
1671
|
}
|
|
554
|
-
const bgXml = ctx.style.hasTheme ? `<p:bg><p:bgPr><a:solidFill><a:srgbClr val="${ctx.style.background}"/></a:solidFill><a:effectLst/></p:bgPr></p:bg>` : "";
|
|
555
1672
|
const transitionXml = buildTransitionXml(block.transition);
|
|
556
1673
|
const extensionAttrs = transitionXml.includes("p14:") ? ` xmlns:mc="${NS_MC}" xmlns:p14="${NS_PML_2010}" mc:Ignorable="p14"` : "";
|
|
557
1674
|
return xmlDeclaration() + `<p:sld xmlns:a="${NS_DRAWINGML}" xmlns:r="${NS_R}" xmlns:p="${NS_PML}"${extensionAttrs}><p:cSld>${bgXml}<p:spTree>${shapes.join("")}</p:spTree></p:cSld>` + transitionXml + `</p:sld>`;
|
|
@@ -564,7 +1681,7 @@ function convertLayerToShapes(layer, ctx, textBottom) {
|
|
|
564
1681
|
if (layer.content.filter?.type === "noise") return [];
|
|
565
1682
|
return [buildLayerShape(layer, ctx.allocShapeId())];
|
|
566
1683
|
case "image": {
|
|
567
|
-
const svg =
|
|
1684
|
+
const svg = isSvgImageSource2(layer.content.src);
|
|
568
1685
|
const area = toEmuRect(resolveLayerRect(layer.position, 1920, 1080));
|
|
569
1686
|
const picture = svg ? svgImagePictureShapes(layer, area, ctx) : null;
|
|
570
1687
|
if (picture) return picture;
|
|
@@ -635,7 +1752,7 @@ function convertLayerToShapes(layer, ctx, textBottom) {
|
|
|
635
1752
|
}
|
|
636
1753
|
case "video": {
|
|
637
1754
|
const poster = layer.content.posterSrc;
|
|
638
|
-
const data = poster && !
|
|
1755
|
+
const data = poster && !isSvgImageSource2(poster) ? ctx.images?.get(poster) : void 0;
|
|
639
1756
|
if (poster && data) {
|
|
640
1757
|
const relId = ctx.addImage(poster, data, layer.content.alt);
|
|
641
1758
|
const embedded = ctx.embeddedImages.find((candidate) => candidate.relId === relId);
|
|
@@ -716,127 +1833,6 @@ function convertLayerToShapes(layer, ctx, textBottom) {
|
|
|
716
1833
|
}
|
|
717
1834
|
}
|
|
718
1835
|
}
|
|
719
|
-
function resolvePositionValue(value, dimension) {
|
|
720
|
-
if (typeof value === "number") return value;
|
|
721
|
-
if (typeof value === "string") {
|
|
722
|
-
const trimmed = value.trim();
|
|
723
|
-
if (trimmed.endsWith("%")) {
|
|
724
|
-
const percent = Number.parseFloat(trimmed);
|
|
725
|
-
if (Number.isFinite(percent)) return percent / 100 * dimension;
|
|
726
|
-
}
|
|
727
|
-
const numeric = Number.parseFloat(trimmed);
|
|
728
|
-
if (Number.isFinite(numeric)) return numeric;
|
|
729
|
-
}
|
|
730
|
-
return 0;
|
|
731
|
-
}
|
|
732
|
-
function resolveLayerRect(position, defaultWidth, defaultHeight) {
|
|
733
|
-
const width = Math.max(
|
|
734
|
-
1,
|
|
735
|
-
position.width != null ? resolvePositionValue(position.width, 1920) : defaultWidth
|
|
736
|
-
);
|
|
737
|
-
const height = Math.max(
|
|
738
|
-
1,
|
|
739
|
-
position.height != null ? resolvePositionValue(position.height, 1080) : defaultHeight
|
|
740
|
-
);
|
|
741
|
-
let x = resolvePositionValue(position.x, 1920);
|
|
742
|
-
let y = resolvePositionValue(position.y, 1080);
|
|
743
|
-
const anchor = position.anchor ?? "top-left";
|
|
744
|
-
if (anchor === "center") {
|
|
745
|
-
x -= width / 2;
|
|
746
|
-
y -= height / 2;
|
|
747
|
-
} else {
|
|
748
|
-
if (anchor.endsWith("right")) x -= width;
|
|
749
|
-
if (anchor.startsWith("bottom")) y -= height;
|
|
750
|
-
}
|
|
751
|
-
const clippedX = Math.min(1919, Math.max(0, x));
|
|
752
|
-
const clippedY = Math.min(1079, Math.max(0, y));
|
|
753
|
-
const clippedWidth = Math.max(1, Math.min(width - (clippedX - x), 1920 - clippedX));
|
|
754
|
-
const clippedHeight = Math.max(1, Math.min(height - (clippedY - y), 1080 - clippedY));
|
|
755
|
-
return { x: clippedX, y: clippedY, width: clippedWidth, height: clippedHeight };
|
|
756
|
-
}
|
|
757
|
-
function toEmuRect(rect) {
|
|
758
|
-
return {
|
|
759
|
-
x: Math.round(rect.x / 1920 * SLIDE_WIDTH),
|
|
760
|
-
y: Math.round(rect.y / 1080 * SLIDE_HEIGHT),
|
|
761
|
-
width: Math.max(1, Math.round(rect.width / 1920 * SLIDE_WIDTH)),
|
|
762
|
-
height: Math.max(1, Math.round(rect.height / 1080 * SLIDE_HEIGHT))
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
function buildLayerTextShape(layer, shapeId, maxBottom) {
|
|
766
|
-
const sourceText = layer.content.text ?? "";
|
|
767
|
-
const text = splitIconMarkers(sourceText).map((run) => run.type === "text" ? run.text : "M").join("");
|
|
768
|
-
const style = layer.content.style;
|
|
769
|
-
const padding = style.padding ?? 0;
|
|
770
|
-
const rawLines = text.split("\n");
|
|
771
|
-
const estimatedWidth = Math.min(
|
|
772
|
-
1920,
|
|
773
|
-
Math.max(1, Math.max(...rawLines.map((line) => line.length), 1) * style.fontSize * 0.55)
|
|
774
|
-
);
|
|
775
|
-
const boxWidth = layer.position.width != null ? resolvePositionValue(layer.position.width, 1920) : estimatedWidth;
|
|
776
|
-
const fittedFontSize = rawLines.length === 1 && style.fontSize >= 80 && text.length > 0 ? Math.min(style.fontSize, boxWidth / (text.length * 0.78)) : style.fontSize;
|
|
777
|
-
const charsPerLine = Math.max(1, Math.floor(boxWidth / Math.max(1, fittedFontSize * 0.58)));
|
|
778
|
-
const wrappedLineCount = Math.max(
|
|
779
|
-
1,
|
|
780
|
-
rawLines.reduce((count, line) => count + Math.max(1, Math.ceil(line.length / charsPerLine)), 0)
|
|
781
|
-
);
|
|
782
|
-
const officeFontMetricSlack = fittedFontSize * 0.8;
|
|
783
|
-
const estimatedHeight = Math.max(
|
|
784
|
-
fittedFontSize * (style.lineHeight ?? 1.4) * wrappedLineCount + officeFontMetricSlack + padding * 2,
|
|
785
|
-
fittedFontSize + officeFontMetricSlack + padding * 2
|
|
786
|
-
);
|
|
787
|
-
const resolvedRect = resolveLayerRect(layer.position, boxWidth, estimatedHeight);
|
|
788
|
-
const safeBottom = Math.min(1060, maxBottom ?? 1060);
|
|
789
|
-
const virtualRect = {
|
|
790
|
-
...resolvedRect,
|
|
791
|
-
y: Math.min(resolvedRect.y, safeBottom - 1),
|
|
792
|
-
height: Math.max(1, Math.min(resolvedRect.height, safeBottom - resolvedRect.y))
|
|
793
|
-
};
|
|
794
|
-
const heightScale = Math.min(1, virtualRect.height / estimatedHeight);
|
|
795
|
-
const renderedFontSize = Math.max(1, fittedFontSize * heightScale * (heightScale < 1 ? 0.9 : 1));
|
|
796
|
-
const renderedStyle = renderedFontSize === style.fontSize ? style : { ...style, fontSize: renderedFontSize };
|
|
797
|
-
const rect = toEmuRect(virtualRect);
|
|
798
|
-
const align = style.textAlign === "center" ? "ctr" : style.textAlign === "right" ? "r" : "l";
|
|
799
|
-
const vertical = style.verticalAlign === "bottom" ? "b" : style.verticalAlign === "middle" ? "ctr" : "t";
|
|
800
|
-
const inset = Math.max(0, Math.round(padding / 1920 * SLIDE_WIDTH));
|
|
801
|
-
const bodyPr = `<a:bodyPr wrap="square" anchor="${vertical}" vertOverflow="clip" horzOverflow="clip" lIns="${inset}" rIns="${inset}" tIns="${inset}" bIns="${inset}"><a:noAutofit/></a:bodyPr>`;
|
|
802
|
-
const paragraphs = sourceText.split("\n").map((line) => buildLayerTextParagraph(line, renderedStyle, align)).join("");
|
|
803
|
-
return `<p:sp><p:nvSpPr><p:cNvPr id="${shapeId}" name="${escapeXml(layer.id)}"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="${rect.x}" y="${rect.y}"/><a:ext cx="${rect.width}" cy="${rect.height}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom>` + fillXml(style.background, style.backgroundOpacity, style.backgroundGradient) + lineXml(style.borderColor, style.borderWidth, style.borderStyle) + `</p:spPr><p:txBody>${bodyPr}<a:lstStyle/>${paragraphs}</p:txBody></p:sp>`;
|
|
804
|
-
}
|
|
805
|
-
function buildLayerTextParagraph(text, style, align) {
|
|
806
|
-
const size = Math.max(100, Math.round(style.fontSize * 75));
|
|
807
|
-
const color = parseCssColor(style.color, "000000");
|
|
808
|
-
const typeface = officeTypeface(style.fontFamily, DEFAULT_FONT);
|
|
809
|
-
const textRunProperties = [
|
|
810
|
-
`lang="en-US"`,
|
|
811
|
-
`dirty="0"`,
|
|
812
|
-
`sz="${size}"`,
|
|
813
|
-
...style.fontWeight === "bold" ? ['b="1"'] : [],
|
|
814
|
-
...style.fontStyle === "italic" ? ['i="1"'] : []
|
|
815
|
-
].join(" ");
|
|
816
|
-
const lineSpacing = Math.max(1e4, Math.round((style.lineHeight ?? 1.4) * 1e5));
|
|
817
|
-
const runs = splitIconMarkers(text).map((run) => {
|
|
818
|
-
if (run.type === "text") {
|
|
819
|
-
if (!run.text) return "";
|
|
820
|
-
return `<a:r><a:rPr ${textRunProperties}>${solidColorXml(color)}<a:latin typeface="${escapeXml(typeface)}"/></a:rPr><a:t>${escapeXml(run.text)}</a:t></a:r>`;
|
|
821
|
-
}
|
|
822
|
-
const glyph = fontAwesomeGlyph(run.family, run.name);
|
|
823
|
-
if (!glyph) return "";
|
|
824
|
-
const iconTypeface = fontAwesomeFace(run.family).typeface;
|
|
825
|
-
return `<a:r><a:rPr lang="en-US" dirty="0" sz="${size}">${solidColorXml(color)}<a:latin typeface="${escapeXml(iconTypeface)}"/></a:rPr><a:t>${escapeXml(glyph)}</a:t></a:r>`;
|
|
826
|
-
}).join("");
|
|
827
|
-
return `<a:p><a:pPr algn="${align}"><a:lnSpc><a:spcPct val="${lineSpacing}"/></a:lnSpc></a:pPr>` + (runs || `<a:r><a:rPr ${textRunProperties}>${solidColorXml(color)}<a:latin typeface="${escapeXml(typeface)}"/></a:rPr><a:t> </a:t></a:r>`) + `</a:p>`;
|
|
828
|
-
}
|
|
829
|
-
function buildLayerShape(layer, shapeId) {
|
|
830
|
-
const rect = toEmuRect(resolveLayerRect(layer.position, 100, 100));
|
|
831
|
-
const geometry = layer.content.shape === "circle" ? "ellipse" : layer.content.shape === "line" ? "line" : layer.content.borderRadius ? "roundRect" : "rect";
|
|
832
|
-
const fill = layer.content.shape === "line" ? "<a:noFill/>" : fillXml(layer.content.fill, layer.content.fillOpacity, layer.content.gradient);
|
|
833
|
-
const stroke = lineXml(
|
|
834
|
-
layer.content.stroke ?? (layer.content.shape === "line" ? "#ffffff" : void 0),
|
|
835
|
-
layer.content.strokeWidth ?? (layer.content.shape === "line" ? 2 : void 0),
|
|
836
|
-
layer.content.borderStyle
|
|
837
|
-
);
|
|
838
|
-
return `<p:sp><p:nvSpPr><p:cNvPr id="${shapeId}" name="${escapeXml(layer.id)}"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="${rect.x}" y="${rect.y}"/><a:ext cx="${rect.width}" cy="${rect.height}"/></a:xfrm><a:prstGeom prst="${geometry}"><a:avLst/></a:prstGeom>` + fill + stroke + `</p:spPr></p:sp>`;
|
|
839
|
-
}
|
|
840
1836
|
function buildTableLayerShapes(layer, ctx) {
|
|
841
1837
|
const visibleBodyRows = layer.content.maxVisibleRows ? layer.content.rows.slice(0, Math.max(1, Math.floor(layer.content.maxVisibleRows))) : layer.content.rows;
|
|
842
1838
|
const rows = [layer.content.headers, ...visibleBodyRows];
|
|
@@ -928,7 +1924,7 @@ function buildPathLayerShapes(layer, ctx) {
|
|
|
928
1924
|
const flipH = x2 < x1 ? ' flipH="1"' : "";
|
|
929
1925
|
const flipV = y2 < y1 ? ' flipV="1"' : "";
|
|
930
1926
|
const stroke = parseCssColor(layer.content.stroke, "FFFFFF");
|
|
931
|
-
const width = Math.max(1, Math.round((layer.content.strokeWidth ?? 2) *
|
|
1927
|
+
const width = Math.max(1, Math.round((layer.content.strokeWidth ?? 2) * EMU_PER_CANVAS_PX));
|
|
932
1928
|
const head = markerXml("headEnd", layer.content.startMarker);
|
|
933
1929
|
const tail = markerXml("tailEnd", layer.content.endMarker);
|
|
934
1930
|
const dash = dashXml(layer.content.borderStyle, layer.content.dasharray);
|
|
@@ -956,79 +1952,6 @@ function textLayerForPlaceholder(position, text, ctx) {
|
|
|
956
1952
|
}
|
|
957
1953
|
};
|
|
958
1954
|
}
|
|
959
|
-
function parseCssColor(color, fallback) {
|
|
960
|
-
if (!color) return { hex: toOoxmlHex(fallback), alpha: 1e5 };
|
|
961
|
-
const trimmed = color.trim();
|
|
962
|
-
if (trimmed === "transparent" || trimmed === "none") return { hex: "000000", alpha: 0 };
|
|
963
|
-
const hexMatch = /^#?([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i.exec(trimmed);
|
|
964
|
-
if (hexMatch) {
|
|
965
|
-
const raw = hexMatch[1];
|
|
966
|
-
const expanded = raw.length === 3 ? raw.split("").map((char) => char + char).join("") : raw;
|
|
967
|
-
const alpha = expanded.length === 8 ? Math.round(Number.parseInt(expanded.slice(6, 8), 16) / 255 * 1e5) : 1e5;
|
|
968
|
-
return { hex: expanded.slice(0, 6).toUpperCase(), alpha };
|
|
969
|
-
}
|
|
970
|
-
const rgbMatch = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i.exec(trimmed);
|
|
971
|
-
if (rgbMatch) {
|
|
972
|
-
const channel = (value) => Math.max(0, Math.min(255, Math.round(Number(value)))).toString(16).padStart(2, "0");
|
|
973
|
-
const alpha = rgbMatch[4] ? Math.round(Math.max(0, Math.min(1, Number(rgbMatch[4]))) * 1e5) : 1e5;
|
|
974
|
-
return {
|
|
975
|
-
hex: `${channel(rgbMatch[1])}${channel(rgbMatch[2])}${channel(rgbMatch[3])}`.toUpperCase(),
|
|
976
|
-
alpha
|
|
977
|
-
};
|
|
978
|
-
}
|
|
979
|
-
const named = {
|
|
980
|
-
black: "000000",
|
|
981
|
-
white: "FFFFFF",
|
|
982
|
-
red: "FF0000",
|
|
983
|
-
blue: "0000FF",
|
|
984
|
-
green: "008000",
|
|
985
|
-
gray: "808080",
|
|
986
|
-
grey: "808080"
|
|
987
|
-
};
|
|
988
|
-
return { hex: named[trimmed.toLowerCase()] ?? toOoxmlHex(fallback), alpha: 1e5 };
|
|
989
|
-
}
|
|
990
|
-
function solidColorXml(color) {
|
|
991
|
-
const alpha = color.alpha < 1e5 ? `<a:alpha val="${color.alpha}"/>` : "";
|
|
992
|
-
return `<a:solidFill><a:srgbClr val="${color.hex}">${alpha}</a:srgbClr></a:solidFill>`;
|
|
993
|
-
}
|
|
994
|
-
function fillXml(fill, opacity, gradient) {
|
|
995
|
-
if (gradient) return gradientFillXml(gradient.from, gradient.to, gradient.angle ?? 0, opacity);
|
|
996
|
-
if (!fill || fill === "none" || fill === "transparent") return "<a:noFill/>";
|
|
997
|
-
if (fill.includes("gradient(")) {
|
|
998
|
-
const colors = fill.match(/#[0-9a-f]{3,8}|rgba?\([^)]*\)/gi) ?? [];
|
|
999
|
-
if (colors.length >= 2) {
|
|
1000
|
-
const angle = Number.parseFloat(
|
|
1001
|
-
/(?:linear-gradient\()\s*([\d.-]+)deg/i.exec(fill)?.[1] ?? "0"
|
|
1002
|
-
);
|
|
1003
|
-
return gradientFillXml(colors[0], colors[colors.length - 1], angle, opacity);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
const color = parseCssColor(fill, "000000");
|
|
1007
|
-
if (opacity !== void 0)
|
|
1008
|
-
color.alpha = Math.round(color.alpha * Math.max(0, Math.min(1, opacity)));
|
|
1009
|
-
return solidColorXml(color);
|
|
1010
|
-
}
|
|
1011
|
-
function gradientFillXml(from, to, angle, opacity) {
|
|
1012
|
-
const first = parseCssColor(from, "000000");
|
|
1013
|
-
const second = parseCssColor(to, first.hex);
|
|
1014
|
-
if (opacity !== void 0) {
|
|
1015
|
-
const factor = Math.max(0, Math.min(1, opacity));
|
|
1016
|
-
first.alpha = Math.round(first.alpha * factor);
|
|
1017
|
-
second.alpha = Math.round(second.alpha * factor);
|
|
1018
|
-
}
|
|
1019
|
-
const ooxmlAngle = Math.round(((90 - angle) % 360 + 360) % 360 * 6e4);
|
|
1020
|
-
return `<a:gradFill rotWithShape="1"><a:gsLst><a:gs pos="0"><a:srgbClr val="${first.hex}">${first.alpha < 1e5 ? `<a:alpha val="${first.alpha}"/>` : ""}</a:srgbClr></a:gs><a:gs pos="100000"><a:srgbClr val="${second.hex}">${second.alpha < 1e5 ? `<a:alpha val="${second.alpha}"/>` : ""}</a:srgbClr></a:gs></a:gsLst><a:lin ang="${ooxmlAngle}" scaled="1"/></a:gradFill>`;
|
|
1021
|
-
}
|
|
1022
|
-
function lineXml(color, widthPx, borderStyle) {
|
|
1023
|
-
if (!color || !widthPx || widthPx <= 0) return "<a:ln><a:noFill/></a:ln>";
|
|
1024
|
-
const width = Math.max(1, Math.round(widthPx * 9525));
|
|
1025
|
-
return `<a:ln w="${width}">${solidColorXml(parseCssColor(color, "000000"))}${dashXml(borderStyle)}</a:ln>`;
|
|
1026
|
-
}
|
|
1027
|
-
function dashXml(style, dasharray) {
|
|
1028
|
-
if (style === "dashed" || dasharray && dasharray !== "none") return '<a:prstDash val="dash"/>';
|
|
1029
|
-
if (style === "dotted") return '<a:prstDash val="dot"/>';
|
|
1030
|
-
return "";
|
|
1031
|
-
}
|
|
1032
1955
|
function markerXml(tag, marker) {
|
|
1033
1956
|
if (!marker || marker === "none") return "";
|
|
1034
1957
|
const type = marker === "arrow" ? "triangle" : marker === "open" ? "arrow" : marker === "diamond" ? "diamond" : marker === "circle" ? "oval" : "square";
|
|
@@ -1285,7 +2208,7 @@ function isImageOnlyParagraph(node) {
|
|
|
1285
2208
|
function convertParagraph(node, ctx) {
|
|
1286
2209
|
const soloImage = isImageOnlyParagraph(node);
|
|
1287
2210
|
if (soloImage && ctx.images) {
|
|
1288
|
-
const data =
|
|
2211
|
+
const data = isSvgImageSource2(soloImage.url) ? void 0 : ctx.images.get(soloImage.url);
|
|
1289
2212
|
if (data) {
|
|
1290
2213
|
ctx.addImage(soloImage.url, data, soloImage.alt || "");
|
|
1291
2214
|
return "";
|
|
@@ -1437,7 +2360,7 @@ function makeHyperlinkRun(text, rId, format) {
|
|
|
1437
2360
|
}
|
|
1438
2361
|
function convertImage(node, format, ctx) {
|
|
1439
2362
|
if (ctx.images) {
|
|
1440
|
-
const data =
|
|
2363
|
+
const data = isSvgImageSource2(node.url) ? void 0 : ctx.images.get(node.url);
|
|
1441
2364
|
if (data) {
|
|
1442
2365
|
ctx.addImage(node.url, data, node.alt || "");
|
|
1443
2366
|
return "";
|
|
@@ -1446,13 +2369,17 @@ function convertImage(node, format, ctx) {
|
|
|
1446
2369
|
const alt = node.alt || node.url;
|
|
1447
2370
|
return makeRun(`[Image: ${alt}]`, { ...format, italic: true }, ctx.style);
|
|
1448
2371
|
}
|
|
1449
|
-
|
|
2372
|
+
var SEMANTIC_DECK_STRUCTURE = {
|
|
2373
|
+
layoutXmls: [buildSlideLayoutXml("")],
|
|
2374
|
+
masterXml: ([layoutRelId]) => buildSlideMasterXml(layoutRelId),
|
|
2375
|
+
themeXml: buildThemeXml()
|
|
2376
|
+
};
|
|
2377
|
+
async function buildPptxPackage(slideXmls, slideContexts, options, structure = SEMANTIC_DECK_STRUCTURE) {
|
|
1450
2378
|
const pkg = createPackage();
|
|
1451
2379
|
const slideCount = slideXmls.length;
|
|
1452
2380
|
const relIds = new RelIdAllocator();
|
|
1453
2381
|
const PRESENTATION_PART = "ppt/presentation.xml";
|
|
1454
2382
|
const SLIDE_MASTER_PART = "ppt/slideMasters/slideMaster1.xml";
|
|
1455
|
-
const SLIDE_LAYOUT_PART = "ppt/slideLayouts/slideLayout1.xml";
|
|
1456
2383
|
const slideRelIds = [];
|
|
1457
2384
|
for (let i = 0; i < slideCount; i++) {
|
|
1458
2385
|
slideRelIds.push(relIds.alloc(PRESENTATION_PART));
|
|
@@ -1491,30 +2418,36 @@ async function buildPptxPackage(slideXmls, slideContexts, options) {
|
|
|
1491
2418
|
pkg.addBinaryPart(img.mediaPath, img.data, img.contentType);
|
|
1492
2419
|
}
|
|
1493
2420
|
}
|
|
1494
|
-
const
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
2421
|
+
const masterLayoutRelIds = [];
|
|
2422
|
+
structure.layoutXmls.forEach((layoutXml, index) => {
|
|
2423
|
+
const layoutPart = `ppt/slideLayouts/slideLayout${index + 1}.xml`;
|
|
2424
|
+
const layoutMasterRelId = relIds.alloc(layoutPart);
|
|
2425
|
+
pkg.addPart(layoutPart, layoutXml, CONTENT_TYPE_PPTX_SLIDE_LAYOUT);
|
|
2426
|
+
pkg.addRelationship(layoutPart, {
|
|
2427
|
+
id: layoutMasterRelId,
|
|
2428
|
+
type: REL_SLIDE_MASTER,
|
|
2429
|
+
target: "../slideMasters/slideMaster1.xml"
|
|
2430
|
+
});
|
|
2431
|
+
const masterLayoutRelId = relIds.alloc(SLIDE_MASTER_PART);
|
|
2432
|
+
masterLayoutRelIds.push(masterLayoutRelId);
|
|
2433
|
+
pkg.addRelationship(SLIDE_MASTER_PART, {
|
|
2434
|
+
id: masterLayoutRelId,
|
|
2435
|
+
type: REL_SLIDE_LAYOUT,
|
|
2436
|
+
target: `../slideLayouts/slideLayout${index + 1}.xml`
|
|
2437
|
+
});
|
|
1501
2438
|
});
|
|
1502
|
-
const masterLayoutRelId = relIds.alloc(SLIDE_MASTER_PART);
|
|
1503
2439
|
const masterThemeRelId = relIds.alloc(SLIDE_MASTER_PART);
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
target: "../slideLayouts/slideLayout1.xml"
|
|
1510
|
-
});
|
|
2440
|
+
pkg.addPart(
|
|
2441
|
+
SLIDE_MASTER_PART,
|
|
2442
|
+
structure.masterXml(masterLayoutRelIds),
|
|
2443
|
+
CONTENT_TYPE_PPTX_SLIDE_MASTER
|
|
2444
|
+
);
|
|
1511
2445
|
pkg.addRelationship(SLIDE_MASTER_PART, {
|
|
1512
2446
|
id: masterThemeRelId,
|
|
1513
2447
|
type: REL_THEME,
|
|
1514
2448
|
target: "../theme/theme1.xml"
|
|
1515
2449
|
});
|
|
1516
|
-
|
|
1517
|
-
pkg.addPart("ppt/theme/theme1.xml", themeXml, CONTENT_TYPE_PPTX_THEME);
|
|
2450
|
+
pkg.addPart("ppt/theme/theme1.xml", structure.themeXml, CONTENT_TYPE_PPTX_THEME);
|
|
1518
2451
|
pkg.addRelationship("", {
|
|
1519
2452
|
id: "rId1",
|
|
1520
2453
|
type: REL_OFFICE_DOCUMENT,
|