docgen-utils 1.0.6 → 1.0.7
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/bundle.js +1107 -160
- package/dist/bundle.min.js +81 -79
- package/dist/packages/slides/common.d.ts +69 -0
- package/dist/packages/slides/common.d.ts.map +1 -1
- package/dist/packages/slides/convert.d.ts +3 -1
- package/dist/packages/slides/convert.d.ts.map +1 -1
- package/dist/packages/slides/convert.js +243 -14
- package/dist/packages/slides/convert.js.map +1 -1
- package/dist/packages/slides/createPresentation.d.ts.map +1 -1
- package/dist/packages/slides/createPresentation.js +10 -1
- package/dist/packages/slides/createPresentation.js.map +1 -1
- package/dist/packages/slides/parse.d.ts.map +1 -1
- package/dist/packages/slides/parse.js +1115 -69
- package/dist/packages/slides/parse.js.map +1 -1
- package/dist/packages/slides/transform.d.ts.map +1 -1
- package/dist/packages/slides/transform.js +74 -26
- package/dist/packages/slides/transform.js.map +1 -1
- package/dist/packages/slides/vendor/pptxgen.d.ts +25 -0
- package/dist/packages/slides/vendor/pptxgen.js +107 -79
- package/package.json +2 -3
|
@@ -43,6 +43,20 @@ export function rgbToHex(rgbStr) {
|
|
|
43
43
|
.map((n) => parseInt(n).toString(16).padStart(2, '0'))
|
|
44
44
|
.join('');
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Check if a CSS color value is fully transparent (alpha = 0).
|
|
48
|
+
* Returns `true` for `transparent`, `rgba(0,0,0,0)`, or any `rgba()` with alpha ≤ 0.
|
|
49
|
+
*/
|
|
50
|
+
function isFullyTransparent(colorStr) {
|
|
51
|
+
if (colorStr === 'transparent' || colorStr === 'rgba(0, 0, 0, 0)')
|
|
52
|
+
return true;
|
|
53
|
+
const match = colorStr.match(/rgba\(\d+,\s*\d+,\s*\d+,\s*([\d.]+)\)/);
|
|
54
|
+
if (match) {
|
|
55
|
+
const alpha = parseFloat(match[1]);
|
|
56
|
+
return alpha <= 0;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
46
60
|
/**
|
|
47
61
|
* Extract transparency percentage from an `rgba()` string.
|
|
48
62
|
* Returns `null` for opaque or non-rgba values, otherwise 0-100.
|
|
@@ -65,7 +79,11 @@ export function extractAlpha(rgbStr) {
|
|
|
65
79
|
*/
|
|
66
80
|
export function parseCssGradient(gradientStr) {
|
|
67
81
|
const colorToHex = (colorStr) => {
|
|
68
|
-
colorStr = colorStr.trim();
|
|
82
|
+
colorStr = colorStr.trim().toLowerCase();
|
|
83
|
+
// Handle 'transparent' keyword (equivalent to rgba(0,0,0,0))
|
|
84
|
+
if (colorStr === 'transparent') {
|
|
85
|
+
return '000000';
|
|
86
|
+
}
|
|
69
87
|
if (colorStr.startsWith('#')) {
|
|
70
88
|
let hex = colorStr.slice(1);
|
|
71
89
|
if (hex.length === 3)
|
|
@@ -83,7 +101,11 @@ export function parseCssGradient(gradientStr) {
|
|
|
83
101
|
return 'FFFFFF';
|
|
84
102
|
};
|
|
85
103
|
const extractTransparency = (colorStr) => {
|
|
86
|
-
colorStr = colorStr.trim();
|
|
104
|
+
colorStr = colorStr.trim().toLowerCase();
|
|
105
|
+
// Handle 'transparent' keyword (equivalent to rgba(0,0,0,0) - fully transparent)
|
|
106
|
+
if (colorStr === 'transparent') {
|
|
107
|
+
return 100; // 100% transparency = fully transparent
|
|
108
|
+
}
|
|
87
109
|
const rgbaMatch = colorStr.match(/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/);
|
|
88
110
|
if (rgbaMatch) {
|
|
89
111
|
const alpha = parseFloat(rgbaMatch[1]);
|
|
@@ -220,6 +242,125 @@ function shouldSkipBold(fontFamily) {
|
|
|
220
242
|
const normalizedFont = fontFamily.toLowerCase().replace(/['"]/g, '').split(',')[0].trim();
|
|
221
243
|
return SINGLE_WEIGHT_FONTS.includes(normalizedFont);
|
|
222
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Extract letter-spacing as points from computed style.
|
|
247
|
+
* Returns the value in points, or null if normal/zero.
|
|
248
|
+
*/
|
|
249
|
+
function extractLetterSpacing(computed) {
|
|
250
|
+
const ls = computed.letterSpacing;
|
|
251
|
+
if (!ls || ls === 'normal' || ls === '0px')
|
|
252
|
+
return null;
|
|
253
|
+
const pxVal = parseFloat(ls);
|
|
254
|
+
if (isNaN(pxVal) || pxVal === 0)
|
|
255
|
+
return null;
|
|
256
|
+
return pxVal * PT_PER_PX;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Parse CSS text-shadow into a glow effect and/or shadow.
|
|
260
|
+
*
|
|
261
|
+
* Patterns:
|
|
262
|
+
* - `0 0 <blur> <color>` → glow (zero offset, radial blur)
|
|
263
|
+
* - `<offsetX> <offsetY> <blur> <color>` → shadow (with direction)
|
|
264
|
+
*
|
|
265
|
+
* When multiple shadows are present, we pick the most impactful one
|
|
266
|
+
* (largest blur for glow, largest offset for shadow).
|
|
267
|
+
*
|
|
268
|
+
* Returns `{ glow, shadow }` — either or both may be null.
|
|
269
|
+
*/
|
|
270
|
+
function parseTextShadow(textShadow) {
|
|
271
|
+
if (!textShadow || textShadow === 'none')
|
|
272
|
+
return { glow: null, shadow: null };
|
|
273
|
+
// Split multiple shadows by comma, but respect parentheses
|
|
274
|
+
const shadows = [];
|
|
275
|
+
let current = '';
|
|
276
|
+
let depth = 0;
|
|
277
|
+
for (const char of textShadow) {
|
|
278
|
+
if (char === '(')
|
|
279
|
+
depth++;
|
|
280
|
+
else if (char === ')')
|
|
281
|
+
depth--;
|
|
282
|
+
if (char === ',' && depth === 0) {
|
|
283
|
+
shadows.push(current.trim());
|
|
284
|
+
current = '';
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
current += char;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (current.trim())
|
|
291
|
+
shadows.push(current.trim());
|
|
292
|
+
let bestGlow = null;
|
|
293
|
+
let bestShadow = null;
|
|
294
|
+
let bestGlowBlur = 0;
|
|
295
|
+
let bestShadowOffset = 0;
|
|
296
|
+
for (const shadowStr of shadows) {
|
|
297
|
+
// Extract color (rgb/rgba)
|
|
298
|
+
const colorMatch = shadowStr.match(/rgba?\([^)]+\)/);
|
|
299
|
+
// Extract numeric values (offsetX offsetY blur [spread])
|
|
300
|
+
const numericParts = shadowStr.match(/([-\d.]+)px/g);
|
|
301
|
+
if (!numericParts || numericParts.length < 2)
|
|
302
|
+
continue;
|
|
303
|
+
const offsetX = parseFloat(numericParts[0]);
|
|
304
|
+
const offsetY = parseFloat(numericParts[1]);
|
|
305
|
+
const blur = numericParts.length > 2 ? parseFloat(numericParts[2]) : 0;
|
|
306
|
+
if (blur <= 0)
|
|
307
|
+
continue; // No visual effect without blur
|
|
308
|
+
// Extract alpha from rgba
|
|
309
|
+
let opacity = 1.0;
|
|
310
|
+
if (colorMatch) {
|
|
311
|
+
const alphaMatch = colorMatch[0].match(/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/);
|
|
312
|
+
if (alphaMatch) {
|
|
313
|
+
opacity = parseFloat(alphaMatch[1]);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const color = colorMatch ? rgbToHex(colorMatch[0]) : '000000';
|
|
317
|
+
const isGlow = Math.abs(offsetX) < 1 && Math.abs(offsetY) < 1;
|
|
318
|
+
if (isGlow) {
|
|
319
|
+
// Glow: zero offset, large blur
|
|
320
|
+
// CSS text-shadow blur radius defines the Gaussian blur standard deviation.
|
|
321
|
+
// OOXML <a:glow rad> defines how far the glow extends beyond the shape.
|
|
322
|
+
//
|
|
323
|
+
// Visual testing shows that CSS Gaussian blur appears much larger and more
|
|
324
|
+
// vibrant than OOXML glow at equivalent numeric values. This is because:
|
|
325
|
+
// 1. CSS blur uses a Gaussian distribution that spreads intensity over ~2-3x the radius
|
|
326
|
+
// 2. OOXML glow has a sharper falloff and appears more contained
|
|
327
|
+
// 3. LibreOffice/PowerPoint may render glows more subtly than browsers
|
|
328
|
+
//
|
|
329
|
+
// To achieve visual equivalence:
|
|
330
|
+
// - Scale glow size by 2.5x (CSS blur spreads much wider than OOXML glow)
|
|
331
|
+
// - Scale opacity by 0.3x (OOXML glow appears more intense at same alpha)
|
|
332
|
+
const GLOW_SIZE_SCALE = 2.5;
|
|
333
|
+
const GLOW_OPACITY_SCALE = 0.3;
|
|
334
|
+
if (blur > bestGlowBlur) {
|
|
335
|
+
bestGlowBlur = blur;
|
|
336
|
+
bestGlow = {
|
|
337
|
+
size: blur * PT_PER_PX * GLOW_SIZE_SCALE,
|
|
338
|
+
color,
|
|
339
|
+
opacity: Math.min(opacity * GLOW_OPACITY_SCALE, 1.0),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
else {
|
|
344
|
+
// Shadow: has offset
|
|
345
|
+
const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY);
|
|
346
|
+
if (offset > bestShadowOffset) {
|
|
347
|
+
bestShadowOffset = offset;
|
|
348
|
+
let angle = Math.atan2(offsetY, offsetX) * (180 / Math.PI);
|
|
349
|
+
if (angle < 0)
|
|
350
|
+
angle += 360;
|
|
351
|
+
bestShadow = {
|
|
352
|
+
type: 'outer',
|
|
353
|
+
angle: Math.round(angle),
|
|
354
|
+
blur: blur * PT_PER_PX,
|
|
355
|
+
color,
|
|
356
|
+
offset: offset * PT_PER_PX,
|
|
357
|
+
opacity,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { glow: bestGlow, shadow: bestShadow };
|
|
363
|
+
}
|
|
223
364
|
function applyTextTransform(text, textTransform) {
|
|
224
365
|
if (textTransform === 'uppercase')
|
|
225
366
|
return text.toUpperCase();
|
|
@@ -281,6 +422,216 @@ function getPositionAndSize(el, rect, rotation) {
|
|
|
281
422
|
h: el.offsetHeight,
|
|
282
423
|
};
|
|
283
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* Apply CSS filter effects (brightness, contrast, saturate) to an image by
|
|
427
|
+
* rendering it through a canvas with the filter applied. Returns a new data
|
|
428
|
+
* URI with the filters baked into the pixel data.
|
|
429
|
+
*
|
|
430
|
+
* This avoids relying on OOXML `<a:lum>` which LibreOffice renders differently
|
|
431
|
+
* than CSS `filter: brightness() contrast()`. By pre-processing the image,
|
|
432
|
+
* the result looks identical regardless of the presentation software.
|
|
433
|
+
*
|
|
434
|
+
* Returns the original src if canvas filter is not supported or fails.
|
|
435
|
+
*/
|
|
436
|
+
function applyImageFilter(img, filter) {
|
|
437
|
+
try {
|
|
438
|
+
// Only process if the image is loaded and has dimensions
|
|
439
|
+
if (!img.naturalWidth || !img.naturalHeight)
|
|
440
|
+
return img.src;
|
|
441
|
+
if (!filter || filter === 'none')
|
|
442
|
+
return img.src;
|
|
443
|
+
const canvas = document.createElement('canvas');
|
|
444
|
+
canvas.width = img.naturalWidth;
|
|
445
|
+
canvas.height = img.naturalHeight;
|
|
446
|
+
const ctx = canvas.getContext('2d');
|
|
447
|
+
if (!ctx)
|
|
448
|
+
return img.src;
|
|
449
|
+
// Apply the CSS filter to the canvas context
|
|
450
|
+
// Canvas2D supports the same filter syntax as CSS
|
|
451
|
+
ctx.filter = filter;
|
|
452
|
+
ctx.drawImage(img, 0, 0);
|
|
453
|
+
// Export as JPEG data URI (smaller than PNG for photos)
|
|
454
|
+
return canvas.toDataURL('image/jpeg', 0.90);
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// If anything fails (CORS, etc.), return original
|
|
458
|
+
return img.src;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Apply CSS mask-image gradient to an image using canvas.
|
|
463
|
+
*
|
|
464
|
+
* CSS mask-image creates a transparency effect based on the mask's alpha values.
|
|
465
|
+
* We simulate this by:
|
|
466
|
+
* 1. Drawing the image on a canvas
|
|
467
|
+
* 2. Drawing a gradient mask on top using 'destination-in' composite mode
|
|
468
|
+
*
|
|
469
|
+
* Returns a PNG data URI with the mask applied, or null if masking fails.
|
|
470
|
+
*
|
|
471
|
+
* @param img - The image element to mask
|
|
472
|
+
* @param maskGradient - The parsed gradient to use as a mask
|
|
473
|
+
* @param displayWidth - Display width in pixels
|
|
474
|
+
* @param displayHeight - Display height in pixels
|
|
475
|
+
* @param cssOpacity - Optional CSS opacity to bake into the mask (0-1)
|
|
476
|
+
*/
|
|
477
|
+
function applyImageMask(img, maskGradient, displayWidth, displayHeight, cssOpacity) {
|
|
478
|
+
try {
|
|
479
|
+
if (!img.naturalWidth || !img.naturalHeight)
|
|
480
|
+
return null;
|
|
481
|
+
if (!img.complete)
|
|
482
|
+
return null;
|
|
483
|
+
const canvas = document.createElement('canvas');
|
|
484
|
+
// Use display dimensions for the masked output
|
|
485
|
+
canvas.width = Math.round(displayWidth);
|
|
486
|
+
canvas.height = Math.round(displayHeight);
|
|
487
|
+
const ctx = canvas.getContext('2d');
|
|
488
|
+
if (!ctx)
|
|
489
|
+
return null;
|
|
490
|
+
// Apply CSS opacity to the image if specified
|
|
491
|
+
if (cssOpacity !== undefined && cssOpacity < 1) {
|
|
492
|
+
ctx.globalAlpha = cssOpacity;
|
|
493
|
+
}
|
|
494
|
+
// Draw the image scaled to display size
|
|
495
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
496
|
+
// Reset globalAlpha for the mask operation
|
|
497
|
+
ctx.globalAlpha = 1;
|
|
498
|
+
// Check if canvas has content (CORS check)
|
|
499
|
+
try {
|
|
500
|
+
ctx.getImageData(0, 0, 1, 1);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
// CORS failure - can't read image data
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
// Apply the mask gradient
|
|
507
|
+
applyMaskGradientToCanvas(ctx, canvas.width, canvas.height, maskGradient);
|
|
508
|
+
// Export as PNG (needs alpha channel for transparency)
|
|
509
|
+
return canvas.toDataURL('image/png');
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Apply mask gradient to a canvas context using destination-in composite mode.
|
|
517
|
+
* This creates transparency based on the gradient's alpha values.
|
|
518
|
+
*/
|
|
519
|
+
function applyMaskGradientToCanvas(ctx, width, height, maskGradient) {
|
|
520
|
+
ctx.globalCompositeOperation = 'destination-in';
|
|
521
|
+
// Create gradient for the mask
|
|
522
|
+
let gradient;
|
|
523
|
+
if (maskGradient.type === 'radial') {
|
|
524
|
+
const cx = (maskGradient.centerX ?? 50) / 100 * width;
|
|
525
|
+
const cy = (maskGradient.centerY ?? 50) / 100 * height;
|
|
526
|
+
const radius = Math.max(width, height);
|
|
527
|
+
gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
// Linear gradient - CSS angle to canvas coordinates
|
|
531
|
+
// CSS angles: 0° = to top, 90° = to right, 180° = to bottom, 270° = to left
|
|
532
|
+
// We map the gradient to span the full image dimensions along the gradient axis
|
|
533
|
+
const cssAngle = maskGradient.angle ?? 180;
|
|
534
|
+
const angleRad = (cssAngle - 90) * Math.PI / 180;
|
|
535
|
+
// For cardinal directions, use exact edge-to-edge gradients
|
|
536
|
+
// This ensures position 0% and 100% map exactly to the image edges
|
|
537
|
+
let x1, y1, x2, y2;
|
|
538
|
+
if (cssAngle === 0) {
|
|
539
|
+
// to top: bottom to top
|
|
540
|
+
x1 = width / 2;
|
|
541
|
+
y1 = height;
|
|
542
|
+
x2 = width / 2;
|
|
543
|
+
y2 = 0;
|
|
544
|
+
}
|
|
545
|
+
else if (cssAngle === 90) {
|
|
546
|
+
// to right: left to right
|
|
547
|
+
x1 = 0;
|
|
548
|
+
y1 = height / 2;
|
|
549
|
+
x2 = width;
|
|
550
|
+
y2 = height / 2;
|
|
551
|
+
}
|
|
552
|
+
else if (cssAngle === 180) {
|
|
553
|
+
// to bottom: top to bottom
|
|
554
|
+
x1 = width / 2;
|
|
555
|
+
y1 = 0;
|
|
556
|
+
x2 = width / 2;
|
|
557
|
+
y2 = height;
|
|
558
|
+
}
|
|
559
|
+
else if (cssAngle === 270) {
|
|
560
|
+
// to left: right to left
|
|
561
|
+
x1 = width;
|
|
562
|
+
y1 = height / 2;
|
|
563
|
+
x2 = 0;
|
|
564
|
+
y2 = height / 2;
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
// For non-cardinal angles, use diagonal calculation
|
|
568
|
+
const halfDiag = Math.sqrt(width * width + height * height) / 2;
|
|
569
|
+
const cx = width / 2;
|
|
570
|
+
const cy = height / 2;
|
|
571
|
+
x1 = cx - Math.cos(angleRad) * halfDiag;
|
|
572
|
+
y1 = cy - Math.sin(angleRad) * halfDiag;
|
|
573
|
+
x2 = cx + Math.cos(angleRad) * halfDiag;
|
|
574
|
+
y2 = cy + Math.sin(angleRad) * halfDiag;
|
|
575
|
+
}
|
|
576
|
+
gradient = ctx.createLinearGradient(x1, y1, x2, y2);
|
|
577
|
+
}
|
|
578
|
+
// Add color stops with alpha from the mask gradient
|
|
579
|
+
for (const stop of maskGradient.stops) {
|
|
580
|
+
// transparency is 0-100 where 100 = fully transparent
|
|
581
|
+
// Canvas alpha is 0-1 where 1 = fully opaque
|
|
582
|
+
const alpha = stop.transparency !== undefined ? (100 - stop.transparency) / 100 : 1;
|
|
583
|
+
gradient.addColorStop(stop.position / 100, `rgba(255,255,255,${alpha})`);
|
|
584
|
+
}
|
|
585
|
+
ctx.fillStyle = gradient;
|
|
586
|
+
ctx.fillRect(0, 0, width, height);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Parse CSS `filter` property to extract brightness, contrast, and saturate values.
|
|
590
|
+
*
|
|
591
|
+
* Returns an object with the extracted values in OOXML-compatible format:
|
|
592
|
+
* - brightness: percent change (-100 to 100). brightness(0.6) → -40.
|
|
593
|
+
* - contrast: percent change (-100 to 100). contrast(1.1) → 10.
|
|
594
|
+
* - saturation: percent (0 = grayscale, 100 = normal). saturate(1.1) → 110.
|
|
595
|
+
*
|
|
596
|
+
* Returns null if no relevant filter functions found.
|
|
597
|
+
*/
|
|
598
|
+
function parseCssFilter(filter) {
|
|
599
|
+
if (!filter || filter === 'none')
|
|
600
|
+
return null;
|
|
601
|
+
const result = {};
|
|
602
|
+
let hasValues = false;
|
|
603
|
+
// Match brightness(value) — value is a multiplier (0.6 = 60% brightness)
|
|
604
|
+
const brightnessMatch = filter.match(/brightness\(([\d.]+)\)/);
|
|
605
|
+
if (brightnessMatch) {
|
|
606
|
+
const val = parseFloat(brightnessMatch[1]);
|
|
607
|
+
if (val !== 1.0) {
|
|
608
|
+
// Convert: brightness(0.6) → bright = (0.6 - 1) * 100 = -40
|
|
609
|
+
result.brightness = Math.round((val - 1) * 100);
|
|
610
|
+
hasValues = true;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Match contrast(value) — value is a multiplier (1.1 = 110% contrast)
|
|
614
|
+
const contrastMatch = filter.match(/contrast\(([\d.]+)\)/);
|
|
615
|
+
if (contrastMatch) {
|
|
616
|
+
const val = parseFloat(contrastMatch[1]);
|
|
617
|
+
if (val !== 1.0) {
|
|
618
|
+
// Convert: contrast(1.1) → contrast = (1.1 - 1) * 100 = 10
|
|
619
|
+
result.contrast = Math.round((val - 1) * 100);
|
|
620
|
+
hasValues = true;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Match saturate(value) — value is a multiplier (1.1 = 110% saturation)
|
|
624
|
+
const saturateMatch = filter.match(/saturate\(([\d.]+)\)/);
|
|
625
|
+
if (saturateMatch) {
|
|
626
|
+
const val = parseFloat(saturateMatch[1]);
|
|
627
|
+
if (val !== 1.0) {
|
|
628
|
+
// Convert: saturate(1.1) → saturation = 110
|
|
629
|
+
result.saturation = Math.round(val * 100);
|
|
630
|
+
hasValues = true;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return hasValues ? result : null;
|
|
634
|
+
}
|
|
284
635
|
function parseBoxShadow(boxShadow) {
|
|
285
636
|
if (!boxShadow || boxShadow === 'none')
|
|
286
637
|
return null;
|
|
@@ -317,6 +668,134 @@ function parseBoxShadow(boxShadow) {
|
|
|
317
668
|
opacity,
|
|
318
669
|
};
|
|
319
670
|
}
|
|
671
|
+
/**
|
|
672
|
+
* Extract visible CSS pseudo-elements (::before, ::after) from an element.
|
|
673
|
+
*
|
|
674
|
+
* Pseudo-elements are invisible to the DOM but can create visual content via CSS.
|
|
675
|
+
* Common patterns:
|
|
676
|
+
* - Accent lines: thin gradient lines at edges of cards/titles
|
|
677
|
+
* - Overlays: gradient fades over images
|
|
678
|
+
* - Background glows: large low-opacity radial gradients
|
|
679
|
+
*
|
|
680
|
+
* Returns an array of ShapeElements representing the pseudo-elements.
|
|
681
|
+
*/
|
|
682
|
+
function extractPseudoElements(el, win) {
|
|
683
|
+
const results = [];
|
|
684
|
+
const parentRect = el.getBoundingClientRect();
|
|
685
|
+
if (parentRect.width <= 0 || parentRect.height <= 0)
|
|
686
|
+
return results;
|
|
687
|
+
for (const pseudo of ['::before', '::after']) {
|
|
688
|
+
const pComputed = win.getComputedStyle(el, pseudo);
|
|
689
|
+
// Skip if no content or content is 'none'
|
|
690
|
+
const content = pComputed.content;
|
|
691
|
+
if (!content || content === 'none' || content === 'normal')
|
|
692
|
+
continue;
|
|
693
|
+
// Skip if display is none
|
|
694
|
+
if (pComputed.display === 'none')
|
|
695
|
+
continue;
|
|
696
|
+
// Parse dimensions
|
|
697
|
+
let pWidth = parseFloat(pComputed.width);
|
|
698
|
+
let pHeight = parseFloat(pComputed.height);
|
|
699
|
+
// If dimensions are auto/0, infer from parent and positioning
|
|
700
|
+
if (isNaN(pWidth) || pWidth <= 0) {
|
|
701
|
+
// Check if left/right both set (→ width = parent width)
|
|
702
|
+
const left = parseFloat(pComputed.left);
|
|
703
|
+
const right = parseFloat(pComputed.right);
|
|
704
|
+
if (!isNaN(left) && !isNaN(right)) {
|
|
705
|
+
pWidth = parentRect.width - left - right;
|
|
706
|
+
}
|
|
707
|
+
else {
|
|
708
|
+
pWidth = parentRect.width;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (isNaN(pHeight) || pHeight <= 0) {
|
|
712
|
+
// Check if top/bottom both set (→ height = parent height)
|
|
713
|
+
const top = parseFloat(pComputed.top);
|
|
714
|
+
const bottom = parseFloat(pComputed.bottom);
|
|
715
|
+
if (!isNaN(top) && !isNaN(bottom)) {
|
|
716
|
+
pHeight = parentRect.height - top - bottom;
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
pHeight = parentRect.height;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
// Skip tiny elements that won't be visible
|
|
723
|
+
if (pWidth < 1 || pHeight < 1)
|
|
724
|
+
continue;
|
|
725
|
+
// Determine position (relative to parent)
|
|
726
|
+
let pLeft = parseFloat(pComputed.left);
|
|
727
|
+
let pTop = parseFloat(pComputed.top);
|
|
728
|
+
if (isNaN(pLeft))
|
|
729
|
+
pLeft = 0;
|
|
730
|
+
if (isNaN(pTop))
|
|
731
|
+
pTop = 0;
|
|
732
|
+
// Convert to absolute page coordinates
|
|
733
|
+
const absLeft = parentRect.left + pLeft;
|
|
734
|
+
const absTop = parentRect.top + pTop;
|
|
735
|
+
// Check for visual content: background color, gradient, or border
|
|
736
|
+
const hasBg = pComputed.backgroundColor && pComputed.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
|
737
|
+
const bgImage = pComputed.backgroundImage;
|
|
738
|
+
const hasGradient = bgImage && bgImage !== 'none' &&
|
|
739
|
+
(bgImage.includes('linear-gradient') || bgImage.includes('radial-gradient'));
|
|
740
|
+
const hasBgImage = bgImage && bgImage !== 'none' && bgImage.includes('url(');
|
|
741
|
+
if (!hasBg && !hasGradient && !hasBgImage)
|
|
742
|
+
continue;
|
|
743
|
+
// Parse gradient if present
|
|
744
|
+
let gradient = null;
|
|
745
|
+
if (hasGradient) {
|
|
746
|
+
gradient = parseCssGradient(bgImage);
|
|
747
|
+
}
|
|
748
|
+
// Parse opacity
|
|
749
|
+
const elementOpacity = parseFloat(pComputed.opacity);
|
|
750
|
+
const hasOpacity = !isNaN(elementOpacity) && elementOpacity < 1;
|
|
751
|
+
// Parse border-radius
|
|
752
|
+
let rectRadius = 0;
|
|
753
|
+
const borderRadius = pComputed.borderRadius;
|
|
754
|
+
const radiusValue = parseFloat(borderRadius);
|
|
755
|
+
if (radiusValue > 0) {
|
|
756
|
+
if (borderRadius.includes('%')) {
|
|
757
|
+
if (radiusValue >= 50) {
|
|
758
|
+
rectRadius = 1; // Fully rounded (pill shape)
|
|
759
|
+
}
|
|
760
|
+
else {
|
|
761
|
+
const minDim = Math.min(pWidth, pHeight);
|
|
762
|
+
rectRadius = (radiusValue / 100) * pxToInch(minDim);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
else {
|
|
766
|
+
rectRadius = pxToInch(radiusValue);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
// Parse box-shadow
|
|
770
|
+
const shadow = parseBoxShadow(pComputed.boxShadow);
|
|
771
|
+
// Create shape element
|
|
772
|
+
const shapeElement = {
|
|
773
|
+
type: 'shape',
|
|
774
|
+
text: '',
|
|
775
|
+
textRuns: null,
|
|
776
|
+
style: null,
|
|
777
|
+
position: {
|
|
778
|
+
x: pxToInch(absLeft),
|
|
779
|
+
y: pxToInch(absTop),
|
|
780
|
+
w: pxToInch(pWidth),
|
|
781
|
+
h: pxToInch(pHeight),
|
|
782
|
+
},
|
|
783
|
+
shape: {
|
|
784
|
+
fill: hasBg ? rgbToHex(pComputed.backgroundColor) : null,
|
|
785
|
+
gradient: gradient,
|
|
786
|
+
transparency: hasBg ? extractAlpha(pComputed.backgroundColor) : null,
|
|
787
|
+
line: null,
|
|
788
|
+
rectRadius: rectRadius,
|
|
789
|
+
shadow: shadow,
|
|
790
|
+
opacity: hasOpacity ? elementOpacity : null,
|
|
791
|
+
isEllipse: false,
|
|
792
|
+
softEdge: null,
|
|
793
|
+
},
|
|
794
|
+
};
|
|
795
|
+
results.push(shapeElement);
|
|
796
|
+
}
|
|
797
|
+
return results;
|
|
798
|
+
}
|
|
320
799
|
function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, win) {
|
|
321
800
|
let prevNodeIsText = false;
|
|
322
801
|
let pendingSoftBreak = false;
|
|
@@ -346,12 +825,13 @@ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, wi
|
|
|
346
825
|
const el = node;
|
|
347
826
|
const options = { ...baseOptions };
|
|
348
827
|
const computed = win.getComputedStyle(el);
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
828
|
+
// Handle all inline formatting elements
|
|
829
|
+
const inlineTags = new Set([
|
|
830
|
+
'SPAN', 'B', 'STRONG', 'I', 'EM', 'U',
|
|
831
|
+
'CODE', 'A', 'MARK', 'SUB', 'SUP', 'SMALL', 'S', 'DEL', 'INS',
|
|
832
|
+
'ABBR', 'TIME', 'CITE', 'Q', 'DFN', 'KBD', 'SAMP', 'VAR',
|
|
833
|
+
]);
|
|
834
|
+
if (inlineTags.has(el.tagName)) {
|
|
355
835
|
const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600;
|
|
356
836
|
if (isBold && !shouldSkipBold(computed.fontFamily))
|
|
357
837
|
options.bold = true;
|
|
@@ -367,6 +847,12 @@ function parseInlineFormatting(element, baseOptions, runs, baseTextTransform, wi
|
|
|
367
847
|
}
|
|
368
848
|
if (computed.fontSize)
|
|
369
849
|
options.fontSize = pxToPoints(computed.fontSize);
|
|
850
|
+
if (computed.fontFamily) {
|
|
851
|
+
options.fontFace = computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim();
|
|
852
|
+
}
|
|
853
|
+
const runLetterSpacing = extractLetterSpacing(computed);
|
|
854
|
+
if (runLetterSpacing !== null)
|
|
855
|
+
options.charSpacing = runLetterSpacing;
|
|
370
856
|
if (computed.textTransform && computed.textTransform !== 'none') {
|
|
371
857
|
const transformStr = computed.textTransform;
|
|
372
858
|
textTransform = (text) => applyTextTransform(text, transformStr);
|
|
@@ -436,6 +922,9 @@ export function parseSlideHtml(doc) {
|
|
|
436
922
|
const placeholders = [];
|
|
437
923
|
const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI', 'SPAN'];
|
|
438
924
|
const processed = new Set();
|
|
925
|
+
// Extract pseudo-elements from body (e.g., body::before radial glows)
|
|
926
|
+
const bodyPseudoElements = extractPseudoElements(body, win);
|
|
927
|
+
elements.push(...bodyPseudoElements);
|
|
439
928
|
doc.querySelectorAll('*').forEach((el) => {
|
|
440
929
|
if (processed.has(el))
|
|
441
930
|
return;
|
|
@@ -465,7 +954,12 @@ export function parseSlideHtml(doc) {
|
|
|
465
954
|
(computed.borderRightWidth && parseFloat(computed.borderRightWidth) > 0) ||
|
|
466
955
|
(computed.borderBottomWidth && parseFloat(computed.borderBottomWidth) > 0) ||
|
|
467
956
|
(computed.borderLeftWidth && parseFloat(computed.borderLeftWidth) > 0);
|
|
468
|
-
|
|
957
|
+
// Check for gradient background (backgroundImage contains linear-gradient or radial-gradient)
|
|
958
|
+
const spanBgImage = computed.backgroundImage;
|
|
959
|
+
const hasGradientBg = spanBgImage &&
|
|
960
|
+
spanBgImage !== 'none' &&
|
|
961
|
+
(spanBgImage.includes('linear-gradient') || spanBgImage.includes('radial-gradient'));
|
|
962
|
+
if (hasBg || hasBorder || hasGradientBg) {
|
|
469
963
|
const rect = htmlEl.getBoundingClientRect();
|
|
470
964
|
if (rect.width > 0 && rect.height > 0) {
|
|
471
965
|
const text = el.textContent.trim();
|
|
@@ -473,7 +967,13 @@ export function parseSlideHtml(doc) {
|
|
|
473
967
|
const borderRadius = computed.borderRadius;
|
|
474
968
|
const radiusValue = parseFloat(borderRadius);
|
|
475
969
|
let rectRadius = 0;
|
|
476
|
-
|
|
970
|
+
// Detect ellipse: border-radius >= 50% on roughly square elements
|
|
971
|
+
const isCircularRadius = borderRadius.includes('%')
|
|
972
|
+
? radiusValue >= 50
|
|
973
|
+
: (radiusValue > 0 && radiusValue >= Math.min(rect.width, rect.height) / 2 - 1);
|
|
974
|
+
const aspectRatio = rect.width / rect.height;
|
|
975
|
+
const spanIsEllipse = isCircularRadius && aspectRatio > 0.5 && aspectRatio < 2.0;
|
|
976
|
+
if (radiusValue > 0 && !spanIsEllipse) {
|
|
477
977
|
if (borderRadius.includes('%')) {
|
|
478
978
|
const minDim = Math.min(rect.width, rect.height);
|
|
479
979
|
rectRadius = (radiusValue / 100) * pxToInch(minDim);
|
|
@@ -490,6 +990,11 @@ export function parseSlideHtml(doc) {
|
|
|
490
990
|
borderTop === borderRight &&
|
|
491
991
|
borderRight === borderBottom &&
|
|
492
992
|
borderBottom === borderLeft;
|
|
993
|
+
// Extract opacity
|
|
994
|
+
const spanOpacity = parseFloat(computed.opacity);
|
|
995
|
+
const hasSpanOpacity = !isNaN(spanOpacity) && spanOpacity < 1;
|
|
996
|
+
// Extract box-shadow
|
|
997
|
+
const spanShadow = parseBoxShadow(computed.boxShadow);
|
|
493
998
|
const shapeElement = {
|
|
494
999
|
type: 'shape',
|
|
495
1000
|
position: {
|
|
@@ -500,26 +1005,30 @@ export function parseSlideHtml(doc) {
|
|
|
500
1005
|
},
|
|
501
1006
|
text: text,
|
|
502
1007
|
textRuns: null,
|
|
503
|
-
style: {
|
|
1008
|
+
style: text ? {
|
|
504
1009
|
fontSize: pxToPoints(computed.fontSize),
|
|
505
1010
|
fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
|
|
506
1011
|
color: rgbToHex(computed.color),
|
|
507
1012
|
bold: parseInt(computed.fontWeight) >= 600,
|
|
508
1013
|
align: 'center',
|
|
509
1014
|
valign: 'middle',
|
|
510
|
-
},
|
|
1015
|
+
} : null,
|
|
511
1016
|
shape: {
|
|
512
1017
|
fill: hasBg ? rgbToHex(computed.backgroundColor) : null,
|
|
513
1018
|
gradient: bgGradient,
|
|
514
|
-
transparency: null,
|
|
1019
|
+
transparency: hasBg ? extractAlpha(computed.backgroundColor) : null,
|
|
515
1020
|
line: hasUniformBorder
|
|
516
1021
|
? {
|
|
517
1022
|
color: rgbToHex(computed.borderColor),
|
|
518
1023
|
width: pxToPoints(borderTop),
|
|
1024
|
+
transparency: extractAlpha(computed.borderColor),
|
|
519
1025
|
}
|
|
520
1026
|
: null,
|
|
521
|
-
rectRadius: rectRadius,
|
|
522
|
-
shadow:
|
|
1027
|
+
rectRadius: spanIsEllipse ? 0 : rectRadius,
|
|
1028
|
+
shadow: spanShadow,
|
|
1029
|
+
opacity: hasSpanOpacity ? spanOpacity : null,
|
|
1030
|
+
isEllipse: spanIsEllipse,
|
|
1031
|
+
softEdge: null,
|
|
523
1032
|
},
|
|
524
1033
|
};
|
|
525
1034
|
elements.push(shapeElement);
|
|
@@ -598,52 +1107,266 @@ export function parseSlideHtml(doc) {
|
|
|
598
1107
|
const nearOrigin = rect.left <= 10 && rect.top <= 10;
|
|
599
1108
|
const isFullSlideImage = coversWidth && coversHeight && nearOrigin;
|
|
600
1109
|
const objectFit = imgComputed.objectFit;
|
|
601
|
-
// Check for ancestor with overflow:hidden
|
|
1110
|
+
// Check for ancestor with overflow:hidden for border-radius AND clipping.
|
|
1111
|
+
// getBoundingClientRect() on IMG returns the FULL layout box regardless of
|
|
1112
|
+
// ancestor overflow:hidden — we must manually intersect with clipping ancestors
|
|
1113
|
+
// to get the visible area.
|
|
602
1114
|
let imgRectRadius = null;
|
|
1115
|
+
let clipLeft = rect.left;
|
|
1116
|
+
let clipTop = rect.top;
|
|
1117
|
+
let clipRight = rect.right;
|
|
1118
|
+
let clipBottom = rect.bottom;
|
|
603
1119
|
let ancestor = el.parentElement;
|
|
604
1120
|
while (ancestor && ancestor !== doc.body) {
|
|
605
1121
|
const ancestorComputed = win.getComputedStyle(ancestor);
|
|
606
1122
|
const ancestorOverflow = ancestorComputed.overflow;
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
1123
|
+
if (ancestorOverflow === 'hidden' || ancestorOverflow === 'clip') {
|
|
1124
|
+
// Compute clip intersection with this ancestor's rect
|
|
1125
|
+
const ancestorRect = ancestor.getBoundingClientRect();
|
|
1126
|
+
clipLeft = Math.max(clipLeft, ancestorRect.left);
|
|
1127
|
+
clipTop = Math.max(clipTop, ancestorRect.top);
|
|
1128
|
+
clipRight = Math.min(clipRight, ancestorRect.right);
|
|
1129
|
+
clipBottom = Math.min(clipBottom, ancestorRect.bottom);
|
|
1130
|
+
// Also extract border-radius for rounded corners
|
|
1131
|
+
const ancestorBorderRadius = ancestorComputed.borderRadius;
|
|
1132
|
+
if (ancestorBorderRadius && imgRectRadius === null) {
|
|
1133
|
+
const radiusValue = parseFloat(ancestorBorderRadius);
|
|
1134
|
+
if (radiusValue > 0) {
|
|
1135
|
+
if (ancestorBorderRadius.includes('%')) {
|
|
1136
|
+
const minDim = Math.min(ancestorRect.width, ancestorRect.height);
|
|
1137
|
+
imgRectRadius = (radiusValue / 100) * pxToInch(minDim);
|
|
1138
|
+
}
|
|
1139
|
+
else if (ancestorBorderRadius.includes('pt')) {
|
|
1140
|
+
imgRectRadius = radiusValue / 72;
|
|
1141
|
+
}
|
|
1142
|
+
else {
|
|
1143
|
+
imgRectRadius = pxToInch(radiusValue);
|
|
1144
|
+
}
|
|
622
1145
|
}
|
|
623
|
-
break;
|
|
624
1146
|
}
|
|
625
1147
|
}
|
|
626
1148
|
ancestor = ancestor.parentElement;
|
|
627
1149
|
}
|
|
1150
|
+
// Use the clipped rect (intersection with overflow:hidden ancestors)
|
|
1151
|
+
const clippedW = Math.max(0, clipRight - clipLeft);
|
|
1152
|
+
const clippedH = Math.max(0, clipBottom - clipTop);
|
|
1153
|
+
const wasClipped = clippedW < rect.width - 1 || clippedH < rect.height - 1;
|
|
1154
|
+
// Pre-bake CSS filter effects (brightness, contrast, saturate) into the
|
|
1155
|
+
// image pixels via canvas rendering. This avoids relying on OOXML <a:lum>
|
|
1156
|
+
// which LibreOffice renders differently than CSS filters.
|
|
1157
|
+
const cssFilter = imgComputed.filter;
|
|
1158
|
+
let imgSrc = el.src;
|
|
1159
|
+
let filterPreBaked = false;
|
|
1160
|
+
if (cssFilter && cssFilter !== 'none') {
|
|
1161
|
+
const bakedSrc = applyImageFilter(el, cssFilter);
|
|
1162
|
+
if (bakedSrc !== imgSrc) {
|
|
1163
|
+
imgSrc = bakedSrc;
|
|
1164
|
+
filterPreBaked = true;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
// Pre-bake CSS mask-image gradient into the image using canvas.
|
|
1168
|
+
// This creates a transparency gradient effect that PPTX can display natively.
|
|
1169
|
+
const maskImageProp = imgComputed.maskImage ||
|
|
1170
|
+
imgComputed.webkitMaskImage ||
|
|
1171
|
+
imgComputed.getPropertyValue('mask-image') ||
|
|
1172
|
+
imgComputed.getPropertyValue('-webkit-mask-image');
|
|
1173
|
+
// Extract CSS opacity early so we can bake it into the mask if present
|
|
1174
|
+
const imgOpacity = parseFloat(imgComputed.opacity);
|
|
1175
|
+
const hasOpacity = !isNaN(imgOpacity) && imgOpacity < 1 && imgOpacity >= 0;
|
|
1176
|
+
// Pre-bake CSS mask-image gradient into the image using canvas.
|
|
1177
|
+
// This creates a transparency gradient effect (PNG with alpha) that PPTX displays natively.
|
|
1178
|
+
// When a mask is applied, we also bake in the CSS opacity to ensure correct alpha blending.
|
|
1179
|
+
let maskApplied = false;
|
|
1180
|
+
if (maskImageProp && maskImageProp !== 'none' &&
|
|
1181
|
+
(maskImageProp.includes('linear-gradient') || maskImageProp.includes('radial-gradient'))) {
|
|
1182
|
+
const maskGradient = parseCssGradient(maskImageProp);
|
|
1183
|
+
if (maskGradient) {
|
|
1184
|
+
const displayW = wasClipped ? clippedW : rect.width;
|
|
1185
|
+
const displayH = wasClipped ? clippedH : rect.height;
|
|
1186
|
+
// Pass CSS opacity to bake into the mask - this ensures the gradient transparency
|
|
1187
|
+
// is combined with the overall image opacity in a single PNG alpha channel
|
|
1188
|
+
const maskedSrc = applyImageMask(el, maskGradient, displayW, displayH, hasOpacity ? imgOpacity : undefined);
|
|
1189
|
+
if (maskedSrc) {
|
|
1190
|
+
imgSrc = maskedSrc;
|
|
1191
|
+
maskApplied = true;
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
// Read intrinsic image dimensions for object-fit:cover crop calculation
|
|
1196
|
+
const imgEl = el;
|
|
1197
|
+
const natW = imgEl.naturalWidth;
|
|
1198
|
+
const natH = imgEl.naturalHeight;
|
|
628
1199
|
const imageElement = {
|
|
629
1200
|
type: isFullSlideImage ? 'slideBackgroundImage' : 'image',
|
|
630
|
-
src:
|
|
1201
|
+
src: imgSrc,
|
|
631
1202
|
position: {
|
|
632
|
-
x: pxToInch(rect.left),
|
|
633
|
-
y: pxToInch(rect.top),
|
|
634
|
-
w: pxToInch(rect.width),
|
|
635
|
-
h: pxToInch(rect.height),
|
|
1203
|
+
x: pxToInch(wasClipped ? clipLeft : rect.left),
|
|
1204
|
+
y: pxToInch(wasClipped ? clipTop : rect.top),
|
|
1205
|
+
w: pxToInch(wasClipped ? clippedW : rect.width),
|
|
1206
|
+
h: pxToInch(wasClipped ? clippedH : rect.height),
|
|
636
1207
|
},
|
|
637
1208
|
sizing: objectFit === 'cover' ? { type: 'cover' } : null,
|
|
638
1209
|
};
|
|
1210
|
+
// Store natural dimensions for cover crop calculation in convert.ts
|
|
1211
|
+
if (objectFit === 'cover' && natW > 0 && natH > 0 && !isFullSlideImage) {
|
|
1212
|
+
imageElement.naturalWidth = natW;
|
|
1213
|
+
imageElement.naturalHeight = natH;
|
|
1214
|
+
}
|
|
639
1215
|
if (imgRectRadius !== null) {
|
|
640
1216
|
imageElement.rectRadius = imgRectRadius;
|
|
641
1217
|
}
|
|
1218
|
+
// If canvas pre-bake failed (e.g. CORS), fall back to OOXML filter properties.
|
|
1219
|
+
// These use <a:lum> / <a:duotone> which LibreOffice may render differently,
|
|
1220
|
+
// but it's better than no filter at all.
|
|
1221
|
+
if (!filterPreBaked && cssFilter && cssFilter !== 'none') {
|
|
1222
|
+
const filterValues = parseCssFilter(cssFilter);
|
|
1223
|
+
if (filterValues) {
|
|
1224
|
+
if (filterValues.brightness !== undefined) {
|
|
1225
|
+
imageElement.brightness = filterValues.brightness;
|
|
1226
|
+
}
|
|
1227
|
+
if (filterValues.contrast !== undefined) {
|
|
1228
|
+
imageElement.contrast = filterValues.contrast;
|
|
1229
|
+
}
|
|
1230
|
+
if (filterValues.saturation !== undefined) {
|
|
1231
|
+
imageElement.saturation = filterValues.saturation;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
// Extract CSS opacity on the image element itself
|
|
1236
|
+
// Skip if mask was applied since opacity is already baked into the PNG
|
|
1237
|
+
if (hasOpacity && !maskApplied) {
|
|
1238
|
+
imageElement.transparency = Math.round((1 - imgOpacity) * 100);
|
|
1239
|
+
}
|
|
642
1240
|
elements.push(imageElement);
|
|
643
1241
|
processed.add(el);
|
|
644
1242
|
return;
|
|
645
1243
|
}
|
|
646
1244
|
}
|
|
1245
|
+
// Extract inline SVG elements as images
|
|
1246
|
+
// Inline SVGs are converted to data URI images for PPTX embedding.
|
|
1247
|
+
// This handles icons, illustrations, and other vector graphics in the HTML.
|
|
1248
|
+
if (el.tagName === 'svg') {
|
|
1249
|
+
const rect = htmlEl.getBoundingClientRect();
|
|
1250
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
1251
|
+
// Skip SVGs with very low opacity - these are usually decorative overlays
|
|
1252
|
+
// (e.g., grain textures, noise patterns) that won't render well in PPTX
|
|
1253
|
+
const computedStyle = win.getComputedStyle(htmlEl);
|
|
1254
|
+
const opacity = parseFloat(computedStyle.opacity || '1');
|
|
1255
|
+
if (opacity < 0.1) {
|
|
1256
|
+
processed.add(el);
|
|
1257
|
+
el.querySelectorAll('*').forEach((child) => processed.add(child));
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
// Skip SVGs that rely primarily on filter or pattern elements for rendering.
|
|
1261
|
+
// These use feTurbulence, feGaussianBlur, pattern fills, etc. that won't render
|
|
1262
|
+
// correctly when converted to static PNG for PPTX embedding.
|
|
1263
|
+
const svgEl = el;
|
|
1264
|
+
const hasFilter = svgEl.querySelector('filter, pattern');
|
|
1265
|
+
const hasRenderableContent = svgEl.querySelector('path, circle, ellipse, line, polyline, polygon, text, image');
|
|
1266
|
+
// Check for rects that use filter references (filter="url(...)")
|
|
1267
|
+
const allRects = svgEl.querySelectorAll('rect');
|
|
1268
|
+
const hasRectWithoutFilter = Array.from(allRects).some((r) => !r.getAttribute('filter')?.startsWith('url('));
|
|
1269
|
+
const hasOnlyFilterPattern = hasFilter && !hasRenderableContent && !hasRectWithoutFilter;
|
|
1270
|
+
if (hasOnlyFilterPattern) {
|
|
1271
|
+
processed.add(el);
|
|
1272
|
+
el.querySelectorAll('*').forEach((child) => processed.add(child));
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
// Also skip SVGs where ALL visible elements use filters (noise/grain overlays)
|
|
1276
|
+
// even if they have other rect elements for background
|
|
1277
|
+
const usesFilterRef = svgEl.querySelector('[filter^="url("]');
|
|
1278
|
+
if (hasFilter && usesFilterRef && !hasRenderableContent) {
|
|
1279
|
+
processed.add(el);
|
|
1280
|
+
el.querySelectorAll('*').forEach((child) => processed.add(child));
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
// Clone the SVG to avoid modifying the original
|
|
1284
|
+
const svgClone = el.cloneNode(true);
|
|
1285
|
+
// Ensure the SVG has proper xmlns attribute for standalone rendering
|
|
1286
|
+
if (!svgClone.hasAttribute('xmlns')) {
|
|
1287
|
+
svgClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
1288
|
+
}
|
|
1289
|
+
// ALWAYS set width/height to the rendered dimensions.
|
|
1290
|
+
// The original SVG may have unitless width/height (e.g., width="40") which
|
|
1291
|
+
// are NOT scaled by transform.ts's px→pt conversion. Using the rendered
|
|
1292
|
+
// bounding rect ensures the SVG image matches the actual displayed size.
|
|
1293
|
+
svgClone.setAttribute('width', String(rect.width));
|
|
1294
|
+
svgClone.setAttribute('height', String(rect.height));
|
|
1295
|
+
// Resolve 'currentColor' to the actual computed color value.
|
|
1296
|
+
// SVGs often use fill="currentColor" to inherit the text color from CSS.
|
|
1297
|
+
// When serializing for PPTX embedding, we must replace currentColor with
|
|
1298
|
+
// the actual hex/rgb value since PowerPoint doesn't support CSS inheritance.
|
|
1299
|
+
const computedColor = win.getComputedStyle(htmlEl).color;
|
|
1300
|
+
const rootStyles = win.getComputedStyle(doc.documentElement);
|
|
1301
|
+
// Helper to resolve CSS color values including var() custom properties
|
|
1302
|
+
const resolveColorValue = (value) => {
|
|
1303
|
+
if (!value)
|
|
1304
|
+
return null;
|
|
1305
|
+
// Handle currentColor
|
|
1306
|
+
if (value === 'currentColor') {
|
|
1307
|
+
return computedColor || null;
|
|
1308
|
+
}
|
|
1309
|
+
// Handle CSS custom properties: var(--xxx) or var(--xxx, fallback)
|
|
1310
|
+
const varMatch = value.match(/^var\(\s*(--[\w-]+)(?:\s*,\s*(.+))?\s*\)$/);
|
|
1311
|
+
if (varMatch) {
|
|
1312
|
+
const propName = varMatch[1];
|
|
1313
|
+
const fallback = varMatch[2];
|
|
1314
|
+
const resolvedValue = rootStyles.getPropertyValue(propName).trim();
|
|
1315
|
+
return resolvedValue || fallback || null;
|
|
1316
|
+
}
|
|
1317
|
+
return null; // Not a dynamic value, keep as-is
|
|
1318
|
+
};
|
|
1319
|
+
// Replace dynamic color values (currentColor, var()) in fill and stroke attributes
|
|
1320
|
+
const resolveDynamicColors = (element) => {
|
|
1321
|
+
const fill = element.getAttribute('fill');
|
|
1322
|
+
const resolvedFill = resolveColorValue(fill);
|
|
1323
|
+
if (resolvedFill) {
|
|
1324
|
+
element.setAttribute('fill', resolvedFill);
|
|
1325
|
+
}
|
|
1326
|
+
const stroke = element.getAttribute('stroke');
|
|
1327
|
+
const resolvedStroke = resolveColorValue(stroke);
|
|
1328
|
+
if (resolvedStroke) {
|
|
1329
|
+
element.setAttribute('stroke', resolvedStroke);
|
|
1330
|
+
}
|
|
1331
|
+
// Recurse into child elements
|
|
1332
|
+
element.querySelectorAll('*').forEach(resolveDynamicColors);
|
|
1333
|
+
};
|
|
1334
|
+
resolveDynamicColors(svgClone);
|
|
1335
|
+
// Serialize SVG to string
|
|
1336
|
+
const serializer = new XMLSerializer();
|
|
1337
|
+
const svgString = serializer.serializeToString(svgClone);
|
|
1338
|
+
// Convert to data URI (base64 encoded for better compatibility)
|
|
1339
|
+
const svgBase64 = btoa(unescape(encodeURIComponent(svgString)));
|
|
1340
|
+
const dataUri = `data:image/svg+xml;base64,${svgBase64}`;
|
|
1341
|
+
const imageElement = {
|
|
1342
|
+
type: 'image',
|
|
1343
|
+
src: dataUri,
|
|
1344
|
+
position: {
|
|
1345
|
+
x: pxToInch(rect.left),
|
|
1346
|
+
y: pxToInch(rect.top),
|
|
1347
|
+
w: pxToInch(rect.width),
|
|
1348
|
+
h: pxToInch(rect.height),
|
|
1349
|
+
},
|
|
1350
|
+
sizing: null,
|
|
1351
|
+
};
|
|
1352
|
+
elements.push(imageElement);
|
|
1353
|
+
processed.add(el);
|
|
1354
|
+
// Also mark all SVG children as processed to avoid re-processing
|
|
1355
|
+
el.querySelectorAll('*').forEach((child) => processed.add(child));
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
// Handle <i> elements — skip Font Awesome / icon font elements cleanly.
|
|
1360
|
+
// Font Awesome uses ::before pseudo-elements with font-family "Font Awesome ..." to render icons
|
|
1361
|
+
// as PUA (Private Use Area) Unicode characters. These PUA characters don't have visual
|
|
1362
|
+
// representations in standard fonts used by PowerPoint/LibreOffice, and Unicode symbol
|
|
1363
|
+
// approximations (⊞, ↗, etc.) look too different from the original FA icons, causing
|
|
1364
|
+
// pixel regressions. The icon container shapes (icon-circle DIVs) still render as shapes,
|
|
1365
|
+
// providing visual context. We simply skip the <i> elements entirely.
|
|
1366
|
+
if (el.tagName === 'I') {
|
|
1367
|
+
processed.add(el);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
647
1370
|
// Extract DIVs with backgrounds/borders as shapes
|
|
648
1371
|
const isContainer = el.tagName === 'DIV' && !textTags.includes(el.tagName);
|
|
649
1372
|
if (isContainer) {
|
|
@@ -684,7 +1407,7 @@ export function parseSlideHtml(doc) {
|
|
|
684
1407
|
const y = pxToInch(rect.top);
|
|
685
1408
|
const w = pxToInch(rect.width);
|
|
686
1409
|
const h = pxToInch(rect.height);
|
|
687
|
-
if (parseFloat(borderTop) > 0) {
|
|
1410
|
+
if (parseFloat(borderTop) > 0 && !isFullyTransparent(computed.borderTopColor)) {
|
|
688
1411
|
const widthPt = pxToPoints(borderTop);
|
|
689
1412
|
const inset = widthPt / 72 / 2;
|
|
690
1413
|
borderLines.push({
|
|
@@ -695,9 +1418,10 @@ export function parseSlideHtml(doc) {
|
|
|
695
1418
|
y2: y + inset,
|
|
696
1419
|
width: widthPt,
|
|
697
1420
|
color: rgbToHex(computed.borderTopColor),
|
|
1421
|
+
transparency: extractAlpha(computed.borderTopColor),
|
|
698
1422
|
});
|
|
699
1423
|
}
|
|
700
|
-
if (parseFloat(borderRight) > 0) {
|
|
1424
|
+
if (parseFloat(borderRight) > 0 && !isFullyTransparent(computed.borderRightColor)) {
|
|
701
1425
|
const widthPt = pxToPoints(borderRight);
|
|
702
1426
|
const inset = widthPt / 72 / 2;
|
|
703
1427
|
borderLines.push({
|
|
@@ -708,9 +1432,10 @@ export function parseSlideHtml(doc) {
|
|
|
708
1432
|
y2: y + h,
|
|
709
1433
|
width: widthPt,
|
|
710
1434
|
color: rgbToHex(computed.borderRightColor),
|
|
1435
|
+
transparency: extractAlpha(computed.borderRightColor),
|
|
711
1436
|
});
|
|
712
1437
|
}
|
|
713
|
-
if (parseFloat(borderBottom) > 0) {
|
|
1438
|
+
if (parseFloat(borderBottom) > 0 && !isFullyTransparent(computed.borderBottomColor)) {
|
|
714
1439
|
const widthPt = pxToPoints(borderBottom);
|
|
715
1440
|
const inset = widthPt / 72 / 2;
|
|
716
1441
|
borderLines.push({
|
|
@@ -721,9 +1446,10 @@ export function parseSlideHtml(doc) {
|
|
|
721
1446
|
y2: y + h - inset,
|
|
722
1447
|
width: widthPt,
|
|
723
1448
|
color: rgbToHex(computed.borderBottomColor),
|
|
1449
|
+
transparency: extractAlpha(computed.borderBottomColor),
|
|
724
1450
|
});
|
|
725
1451
|
}
|
|
726
|
-
if (parseFloat(borderLeft) > 0) {
|
|
1452
|
+
if (parseFloat(borderLeft) > 0 && !isFullyTransparent(computed.borderLeftColor)) {
|
|
727
1453
|
const widthPt = pxToPoints(borderLeft);
|
|
728
1454
|
const inset = widthPt / 72 / 2;
|
|
729
1455
|
borderLines.push({
|
|
@@ -734,6 +1460,7 @@ export function parseSlideHtml(doc) {
|
|
|
734
1460
|
y2: y + h,
|
|
735
1461
|
width: widthPt,
|
|
736
1462
|
color: rgbToHex(computed.borderLeftColor),
|
|
1463
|
+
transparency: extractAlpha(computed.borderLeftColor),
|
|
737
1464
|
});
|
|
738
1465
|
}
|
|
739
1466
|
}
|
|
@@ -763,11 +1490,65 @@ export function parseSlideHtml(doc) {
|
|
|
763
1490
|
};
|
|
764
1491
|
elements.push(bgImgElement);
|
|
765
1492
|
}
|
|
766
|
-
// Check for text children
|
|
767
|
-
const
|
|
768
|
-
const
|
|
1493
|
+
// Check for text children — include standard text tags plus leaf DIVs with only direct text
|
|
1494
|
+
const textTagSet = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'SPAN']);
|
|
1495
|
+
const allChildren = Array.from(el.children);
|
|
1496
|
+
// Collect text-bearing children: standard text tags AND leaf DIVs (no child elements, only text nodes)
|
|
1497
|
+
const textChildren = allChildren.filter((child) => {
|
|
1498
|
+
if (textTagSet.has(child.tagName))
|
|
1499
|
+
return true;
|
|
1500
|
+
// Include text-only DIVs: leaf (no child elements) OR only <br>/<p> children
|
|
1501
|
+
// The transformer wraps bare text in <p> tags, so a DIV with a single <p> child
|
|
1502
|
+
// is still effectively a text-only DIV. Examples after transformation:
|
|
1503
|
+
// <div id="stat-value-1"><p>1.1°C</p></div>
|
|
1504
|
+
// <div id="stat-label-1">Global temperature rise since<br>pre-industrial era</div>
|
|
1505
|
+
if (child.tagName === 'DIV') {
|
|
1506
|
+
const childElements = Array.from(child.children);
|
|
1507
|
+
// A text-only DIV has:
|
|
1508
|
+
// - no child elements, OR
|
|
1509
|
+
// - only <br> children (inline breaks), OR
|
|
1510
|
+
// - a single <p> child (injected by transform.ts text wrapping)
|
|
1511
|
+
const isTextOnlyDiv = childElements.length === 0 ||
|
|
1512
|
+
childElements.every(ce => ce.tagName === 'BR') ||
|
|
1513
|
+
(childElements.length === 1 && childElements[0].tagName === 'P' &&
|
|
1514
|
+
childElements[0].children.length === 0);
|
|
1515
|
+
if (!isTextOnlyDiv)
|
|
1516
|
+
return false;
|
|
1517
|
+
const childText = child.textContent?.trim();
|
|
1518
|
+
if (!childText)
|
|
1519
|
+
return false;
|
|
1520
|
+
const childComputed = win.getComputedStyle(child);
|
|
1521
|
+
const childHasBg = childComputed.backgroundColor && childComputed.backgroundColor !== 'rgba(0, 0, 0, 0)';
|
|
1522
|
+
const childHasBgImg = childComputed.backgroundImage && childComputed.backgroundImage !== 'none';
|
|
1523
|
+
const childBorders = [
|
|
1524
|
+
childComputed.borderTopWidth,
|
|
1525
|
+
childComputed.borderRightWidth,
|
|
1526
|
+
childComputed.borderBottomWidth,
|
|
1527
|
+
childComputed.borderLeftWidth,
|
|
1528
|
+
].map(b => parseFloat(b) || 0);
|
|
1529
|
+
const childHasBorder = childBorders.some(b => b > 0);
|
|
1530
|
+
// Only treat as text child if the DIV is unstyled text-only div
|
|
1531
|
+
return !childHasBg && !childHasBgImg && !childHasBorder;
|
|
1532
|
+
}
|
|
1533
|
+
return false;
|
|
1534
|
+
});
|
|
1535
|
+
// Non-text children exclude decorative elements (icons, SVGs) for text extraction purposes
|
|
1536
|
+
const decorativeTags = new Set(['I', 'SVG', 'CANVAS', 'VIDEO', 'AUDIO', 'IFRAME']);
|
|
1537
|
+
const nonTextChildren = allChildren.filter((child) => !textTagSet.has(child.tagName) &&
|
|
1538
|
+
!decorativeTags.has(child.tagName) &&
|
|
1539
|
+
!(child.tagName === 'DIV' && textChildren.includes(child)));
|
|
769
1540
|
const isSingleTextChild = textChildren.length === 1 &&
|
|
770
|
-
|
|
1541
|
+
(() => {
|
|
1542
|
+
const tc = textChildren[0];
|
|
1543
|
+
const tcChildren = Array.from(tc.children);
|
|
1544
|
+
// A text child is "simple" (no further structural content) if:
|
|
1545
|
+
// - it has no child elements, OR
|
|
1546
|
+
// - all children are <BR> tags (inline breaks), OR
|
|
1547
|
+
// - it has a single <P> child with no further child elements (from transform.ts wrapping)
|
|
1548
|
+
return tcChildren.length === 0 ||
|
|
1549
|
+
tcChildren.every(ce => ce.tagName === 'BR') ||
|
|
1550
|
+
(tcChildren.length === 1 && tcChildren[0].tagName === 'P' && tcChildren[0].children.length === 0);
|
|
1551
|
+
})() &&
|
|
771
1552
|
nonTextChildren.length === 0;
|
|
772
1553
|
// Detect flexbox alignment
|
|
773
1554
|
const display = computed.display;
|
|
@@ -830,8 +1611,16 @@ export function parseSlideHtml(doc) {
|
|
|
830
1611
|
let shapeText = '';
|
|
831
1612
|
let shapeTextRuns = null;
|
|
832
1613
|
let shapeStyle = null;
|
|
833
|
-
const hasTextChildren = textChildren.length > 0
|
|
834
|
-
|
|
1614
|
+
const hasTextChildren = textChildren.length > 0;
|
|
1615
|
+
// When a container has BOTH non-text children (styled shapes like icon-circles)
|
|
1616
|
+
// AND multiple text children, don't merge the text into the parent shape.
|
|
1617
|
+
// The text children have their own positioning via flex/grid layout and should
|
|
1618
|
+
// remain as standalone elements for better fidelity. Only merge text when:
|
|
1619
|
+
// - It's a single text child (isSingleTextChild), OR
|
|
1620
|
+
// - There are ONLY text children (no non-text siblings to compete with for space)
|
|
1621
|
+
const shouldMergeText = hasTextChildren && (isSingleTextChild ||
|
|
1622
|
+
nonTextChildren.length === 0);
|
|
1623
|
+
if (shouldMergeText) {
|
|
835
1624
|
if (isSingleTextChild) {
|
|
836
1625
|
const textEl = textChildren[0];
|
|
837
1626
|
const textComputed = win.getComputedStyle(textEl);
|
|
@@ -902,37 +1691,87 @@ export function parseSlideHtml(doc) {
|
|
|
902
1691
|
inset: 0,
|
|
903
1692
|
wrap: !shouldNotWrap,
|
|
904
1693
|
};
|
|
1694
|
+
const shapeLetterSpacing = extractLetterSpacing(textComputed);
|
|
1695
|
+
if (shapeLetterSpacing !== null)
|
|
1696
|
+
shapeStyle.charSpacing = shapeLetterSpacing;
|
|
1697
|
+
// Extract text-shadow for shape text
|
|
1698
|
+
const shapeTextShadow = parseTextShadow(textComputed.textShadow);
|
|
1699
|
+
if (shapeTextShadow.glow)
|
|
1700
|
+
shapeStyle.glow = shapeTextShadow.glow;
|
|
1701
|
+
if (shapeTextShadow.shadow)
|
|
1702
|
+
shapeStyle.textShadow = shapeTextShadow.shadow;
|
|
905
1703
|
processed.add(textEl);
|
|
1704
|
+
// Also mark descendants as processed (e.g., <p> from transform.ts wrapping)
|
|
1705
|
+
textEl.querySelectorAll('*').forEach(desc => processed.add(desc));
|
|
906
1706
|
}
|
|
907
1707
|
else {
|
|
908
1708
|
shapeTextRuns = [];
|
|
909
1709
|
textChildren.forEach((textChild, idx) => {
|
|
910
1710
|
const textEl = textChild;
|
|
911
1711
|
const textComputed = win.getComputedStyle(textEl);
|
|
912
|
-
const
|
|
913
|
-
if (!
|
|
1712
|
+
const fullText = textEl.textContent.trim();
|
|
1713
|
+
if (!fullText)
|
|
914
1714
|
return;
|
|
915
1715
|
const isBold = textComputed.fontWeight === 'bold' ||
|
|
916
1716
|
parseInt(textComputed.fontWeight) >= 600;
|
|
917
1717
|
const isItalic = textComputed.fontStyle === 'italic';
|
|
918
1718
|
const isUnderline = textComputed.textDecoration &&
|
|
919
1719
|
textComputed.textDecoration.includes('underline');
|
|
920
|
-
const
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1720
|
+
const baseRunOptions = {
|
|
1721
|
+
fontSize: pxToPoints(textComputed.fontSize),
|
|
1722
|
+
fontFace: textComputed.fontFamily
|
|
1723
|
+
.split(',')[0]
|
|
1724
|
+
.replace(/['"]/g, '')
|
|
1725
|
+
.trim(),
|
|
1726
|
+
color: rgbToHex(textComputed.color),
|
|
1727
|
+
bold: isBold,
|
|
1728
|
+
italic: isItalic,
|
|
1729
|
+
underline: isUnderline || false,
|
|
1730
|
+
...(extractLetterSpacing(textComputed) !== null ? { charSpacing: extractLetterSpacing(textComputed) } : {}),
|
|
1731
|
+
...(() => {
|
|
1732
|
+
const ts = parseTextShadow(textComputed.textShadow);
|
|
1733
|
+
return ts.glow ? { glow: ts.glow } : {};
|
|
1734
|
+
})(),
|
|
1735
|
+
};
|
|
1736
|
+
// Check if this element contains <br> tags — split into multiple runs with line breaks
|
|
1737
|
+
const hasBrChildren = textEl.querySelector('br') !== null;
|
|
1738
|
+
if (hasBrChildren) {
|
|
1739
|
+
// Walk child nodes to split text at <br> boundaries
|
|
1740
|
+
let segments = [];
|
|
1741
|
+
let currentSegment = '';
|
|
1742
|
+
for (const node of Array.from(textEl.childNodes)) {
|
|
1743
|
+
if (node.tagName === 'BR') {
|
|
1744
|
+
segments.push(currentSegment.trim());
|
|
1745
|
+
currentSegment = '';
|
|
1746
|
+
}
|
|
1747
|
+
else {
|
|
1748
|
+
currentSegment += node.textContent || '';
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
if (currentSegment.trim())
|
|
1752
|
+
segments.push(currentSegment.trim());
|
|
1753
|
+
segments = segments.filter(s => s.length > 0);
|
|
1754
|
+
segments.forEach((segment, segIdx) => {
|
|
1755
|
+
const prefix = (segIdx === 0 && idx > 0 && shapeTextRuns.length > 0) ? '\n' : '';
|
|
1756
|
+
const runText = prefix + segment;
|
|
1757
|
+
const options = { ...baseRunOptions };
|
|
1758
|
+
if (segIdx > 0) {
|
|
1759
|
+
options.softBreakBefore = true;
|
|
1760
|
+
}
|
|
1761
|
+
shapeTextRuns.push({ text: runText, options });
|
|
1762
|
+
});
|
|
1763
|
+
}
|
|
1764
|
+
else {
|
|
1765
|
+
const runText = idx > 0 && shapeTextRuns.length > 0 ? '\n' + fullText : fullText;
|
|
1766
|
+
const options = idx > 0 && shapeTextRuns.length > 0
|
|
1767
|
+
? { ...baseRunOptions, breakLine: true }
|
|
1768
|
+
: baseRunOptions;
|
|
1769
|
+
shapeTextRuns.push({ text: runText, options });
|
|
1770
|
+
}
|
|
935
1771
|
processed.add(textEl);
|
|
1772
|
+
// Also mark all descendant elements as processed (e.g., <p> tags injected
|
|
1773
|
+
// by transform.ts wrapping, <br> tags, <span> inline formatting)
|
|
1774
|
+
textEl.querySelectorAll('*').forEach(desc => processed.add(desc));
|
|
936
1775
|
});
|
|
937
1776
|
shapeStyle = {
|
|
938
1777
|
align: align,
|
|
@@ -942,6 +1781,42 @@ export function parseSlideHtml(doc) {
|
|
|
942
1781
|
}
|
|
943
1782
|
}
|
|
944
1783
|
if (hasBg || hasUniformBorder || bgGradient) {
|
|
1784
|
+
// Detect element-level opacity
|
|
1785
|
+
const elementOpacity = parseFloat(computed.opacity);
|
|
1786
|
+
const hasOpacity = !isNaN(elementOpacity) && elementOpacity < 1;
|
|
1787
|
+
// Detect CSS filter: blur() for soft-edge effect
|
|
1788
|
+
let softEdgePt = null;
|
|
1789
|
+
const filterStr = computed.filter;
|
|
1790
|
+
if (filterStr && filterStr !== 'none') {
|
|
1791
|
+
const blurMatch = filterStr.match(/blur\(([\d.]+)px\)/);
|
|
1792
|
+
if (blurMatch) {
|
|
1793
|
+
const blurPx = parseFloat(blurMatch[1]);
|
|
1794
|
+
if (blurPx > 0) {
|
|
1795
|
+
softEdgePt = blurPx * PT_PER_PX;
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
// Detect ellipse shape: border-radius >= 50% on roughly square elements
|
|
1800
|
+
const borderRadiusStr = computed.borderRadius;
|
|
1801
|
+
const borderRadiusVal = parseFloat(borderRadiusStr);
|
|
1802
|
+
const isCircularRadius = borderRadiusStr.includes('%')
|
|
1803
|
+
? borderRadiusVal >= 50
|
|
1804
|
+
: (borderRadiusVal > 0 && borderRadiusVal >= Math.min(rect.width, rect.height) / 2 - 1);
|
|
1805
|
+
const aspectRatio = rect.width / rect.height;
|
|
1806
|
+
const isEllipse = isCircularRadius && aspectRatio > 0.5 && aspectRatio < 2.0;
|
|
1807
|
+
// Note: backdrop-filter: blur() has no PPTX equivalent. The
|
|
1808
|
+
// --has-backdrop-filter marker from transform.ts is available but we
|
|
1809
|
+
// intentionally do NOT adjust fill opacity here — attempting to
|
|
1810
|
+
// compensate creates large opaque panels that look worse than the
|
|
1811
|
+
// slightly different (non-frosted) appearance. The HTML screenshot
|
|
1812
|
+
// comparison uses the raw HTML which renders backdrop-filter, so
|
|
1813
|
+
// slight differences are expected and acceptable.
|
|
1814
|
+
// Extract padding for text body insets
|
|
1815
|
+
const paddingTop = parseFloat(computed.paddingTop) || 0;
|
|
1816
|
+
const paddingRight = parseFloat(computed.paddingRight) || 0;
|
|
1817
|
+
const paddingBottom = parseFloat(computed.paddingBottom) || 0;
|
|
1818
|
+
const paddingLeft = parseFloat(computed.paddingLeft) || 0;
|
|
1819
|
+
const hasPadding = paddingTop > 2 || paddingRight > 2 || paddingBottom > 2 || paddingLeft > 2;
|
|
945
1820
|
const shapeElement = {
|
|
946
1821
|
type: 'shape',
|
|
947
1822
|
text: shapeText,
|
|
@@ -961,9 +1836,12 @@ export function parseSlideHtml(doc) {
|
|
|
961
1836
|
? {
|
|
962
1837
|
color: rgbToHex(computed.borderColor),
|
|
963
1838
|
width: pxToPoints(computed.borderWidth),
|
|
1839
|
+
transparency: extractAlpha(computed.borderColor),
|
|
964
1840
|
}
|
|
965
1841
|
: null,
|
|
966
1842
|
rectRadius: (() => {
|
|
1843
|
+
if (isEllipse)
|
|
1844
|
+
return 0; // Ellipses don't need rectRadius
|
|
967
1845
|
const radius = computed.borderRadius;
|
|
968
1846
|
const radiusValue = parseFloat(radius);
|
|
969
1847
|
if (radiusValue === 0)
|
|
@@ -979,8 +1857,20 @@ export function parseSlideHtml(doc) {
|
|
|
979
1857
|
return radiusValue / PX_PER_IN;
|
|
980
1858
|
})(),
|
|
981
1859
|
shadow: shadow,
|
|
1860
|
+
opacity: hasOpacity ? elementOpacity : null,
|
|
1861
|
+
isEllipse: isEllipse,
|
|
1862
|
+
softEdge: softEdgePt,
|
|
982
1863
|
},
|
|
983
1864
|
};
|
|
1865
|
+
// Apply CSS padding as text body insets when shape has text content
|
|
1866
|
+
if (hasPadding && shapeElement.style && (shapeText || (shapeTextRuns && shapeTextRuns.length > 0))) {
|
|
1867
|
+
shapeElement.style.margin = [
|
|
1868
|
+
paddingLeft * PT_PER_PX, // left
|
|
1869
|
+
paddingRight * PT_PER_PX, // right
|
|
1870
|
+
paddingBottom * PT_PER_PX, // bottom
|
|
1871
|
+
paddingTop * PT_PER_PX, // top
|
|
1872
|
+
];
|
|
1873
|
+
}
|
|
984
1874
|
elements.push(shapeElement);
|
|
985
1875
|
}
|
|
986
1876
|
else if (shapeStyle && shapeStyle.fontFill) {
|
|
@@ -1005,6 +1895,79 @@ export function parseSlideHtml(doc) {
|
|
|
1005
1895
|
return;
|
|
1006
1896
|
}
|
|
1007
1897
|
}
|
|
1898
|
+
// Plain DIVs with DIRECT text node content but no visual styling
|
|
1899
|
+
// (e.g. slide-footer "6 / 6", slide-number "03 / 06", stat values/labels)
|
|
1900
|
+
// CRITICAL: Only extract if the DIV has meaningful direct text nodes,
|
|
1901
|
+
// NOT just text inherited from descendant elements.
|
|
1902
|
+
if (isContainer) {
|
|
1903
|
+
const rect = htmlEl.getBoundingClientRect();
|
|
1904
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
1905
|
+
// Collect direct text nodes (not from children)
|
|
1906
|
+
// Also handle <p>-wrapped text from transform.ts which wraps bare text in <p> tags
|
|
1907
|
+
let directText = '';
|
|
1908
|
+
for (const child of Array.from(el.childNodes)) {
|
|
1909
|
+
if (child.nodeType === Node.TEXT_NODE) {
|
|
1910
|
+
directText += child.textContent || '';
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
directText = directText.trim();
|
|
1914
|
+
// If no direct text nodes, check for a single <p> wrapper (from transform.ts)
|
|
1915
|
+
const childElements = Array.from(el.children);
|
|
1916
|
+
if (!directText && childElements.length === 1 && childElements[0].tagName === 'P' &&
|
|
1917
|
+
childElements[0].children.length === 0) {
|
|
1918
|
+
directText = childElements[0].textContent?.trim() || '';
|
|
1919
|
+
}
|
|
1920
|
+
// Only proceed if this DIV has meaningful text content
|
|
1921
|
+
// AND has no structural child elements that would be extracted separately
|
|
1922
|
+
const hasStructuralChildren = childElements.length > 0 &&
|
|
1923
|
+
!childElements.every(ce => ce.tagName === 'BR') &&
|
|
1924
|
+
!(childElements.length === 1 && childElements[0].tagName === 'P' && childElements[0].children.length === 0);
|
|
1925
|
+
if (directText && !hasStructuralChildren) {
|
|
1926
|
+
const computed2 = win.getComputedStyle(el);
|
|
1927
|
+
const fontSizePx = parseFloat(computed2.fontSize);
|
|
1928
|
+
const lineHeightPx = parseFloat(computed2.lineHeight);
|
|
1929
|
+
const lineHeightMultiplier = fontSizePx > 0 && !isNaN(lineHeightPx) ? lineHeightPx / fontSizePx : 1.0;
|
|
1930
|
+
const textElement = {
|
|
1931
|
+
type: 'p',
|
|
1932
|
+
text: [{ text: directText, options: {} }],
|
|
1933
|
+
position: {
|
|
1934
|
+
x: pxToInch(rect.left),
|
|
1935
|
+
y: pxToInch(rect.top),
|
|
1936
|
+
w: pxToInch(rect.width),
|
|
1937
|
+
h: pxToInch(rect.height),
|
|
1938
|
+
},
|
|
1939
|
+
style: {
|
|
1940
|
+
fontSize: pxToPoints(computed2.fontSize),
|
|
1941
|
+
fontFace: computed2.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
|
|
1942
|
+
color: rgbToHex(computed2.color),
|
|
1943
|
+
bold: parseInt(computed2.fontWeight) >= 600,
|
|
1944
|
+
italic: computed2.fontStyle === 'italic',
|
|
1945
|
+
align: computed2.textAlign === 'center'
|
|
1946
|
+
? 'center'
|
|
1947
|
+
: computed2.textAlign === 'right' || computed2.textAlign === 'end'
|
|
1948
|
+
? 'right'
|
|
1949
|
+
: 'left',
|
|
1950
|
+
valign: 'middle',
|
|
1951
|
+
lineSpacing: lineHeightMultiplier * pxToPoints(computed2.fontSize),
|
|
1952
|
+
},
|
|
1953
|
+
};
|
|
1954
|
+
// Check for text transparency
|
|
1955
|
+
const textTransparency = extractAlpha(computed2.color);
|
|
1956
|
+
if (textTransparency !== null) {
|
|
1957
|
+
textElement.style.transparency = textTransparency;
|
|
1958
|
+
}
|
|
1959
|
+
// Check for letter-spacing
|
|
1960
|
+
const ls = extractLetterSpacing(computed2);
|
|
1961
|
+
if (ls !== null)
|
|
1962
|
+
textElement.style.charSpacing = ls;
|
|
1963
|
+
elements.push(textElement);
|
|
1964
|
+
processed.add(el);
|
|
1965
|
+
// Also mark <p> or <br> children as processed
|
|
1966
|
+
el.querySelectorAll('*').forEach(desc => processed.add(desc));
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1008
1971
|
}
|
|
1009
1972
|
// Extract bullet lists
|
|
1010
1973
|
if (el.tagName === 'UL' || el.tagName === 'OL') {
|
|
@@ -1017,12 +1980,37 @@ export function parseSlideHtml(doc) {
|
|
|
1017
1980
|
const ulPaddingLeftPt = pxToPoints(ulComputed.paddingLeft);
|
|
1018
1981
|
const marginLeft = ulPaddingLeftPt * 0.5;
|
|
1019
1982
|
const textIndent = ulPaddingLeftPt * 0.5;
|
|
1983
|
+
// Check if the list has explicit list-style: none — if so, the HTML is using
|
|
1984
|
+
// custom bullet decorations (e.g. icon shapes) instead of native bullets.
|
|
1985
|
+
// We should not add PptxGenJS bullets to avoid double-bullet artifacts.
|
|
1986
|
+
const listStyleType = ulComputed.listStyleType;
|
|
1987
|
+
const hasNativeBullets = listStyleType !== 'none';
|
|
1988
|
+
// Check if LI items are flex containers — in this case, each child element
|
|
1989
|
+
// (icon shapes, text nodes) should be processed individually at its actual
|
|
1990
|
+
// position rather than merged into a single list element. This handles
|
|
1991
|
+
// modern CSS patterns like icon+text list items where positioning is
|
|
1992
|
+
// determined by flexbox, not by list indentation.
|
|
1993
|
+
const hasFlexLiItems = liElements.some((li) => {
|
|
1994
|
+
const liComputed = win.getComputedStyle(li);
|
|
1995
|
+
return liComputed.display === 'flex' || liComputed.display === 'inline-flex';
|
|
1996
|
+
});
|
|
1997
|
+
// For flex list items without native bullets, skip the merged list processing.
|
|
1998
|
+
// Let the normal DOM walk handle each child element individually.
|
|
1999
|
+
// This ensures icons, shapes, and text are each positioned at their actual
|
|
2000
|
+
// rendered location rather than being merged into one text box.
|
|
2001
|
+
if (hasFlexLiItems && !hasNativeBullets) {
|
|
2002
|
+
// Don't mark anything as processed — let the DOM walk continue
|
|
2003
|
+
// and process each child element (SVGs, spans, text nodes) individually
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
1020
2006
|
liElements.forEach((li, idx) => {
|
|
1021
2007
|
const isLast = idx === liElements.length - 1;
|
|
1022
2008
|
const runs = parseInlineFormatting(li, { breakLine: false }, [], (x) => x, win);
|
|
1023
2009
|
if (runs.length > 0) {
|
|
1024
2010
|
runs[0].text = runs[0].text.replace(/^[•\-\*\u25AA\u25B8]\s*/, '');
|
|
1025
|
-
|
|
2011
|
+
if (hasNativeBullets) {
|
|
2012
|
+
runs[0].options.bullet = { indent: textIndent };
|
|
2013
|
+
}
|
|
1026
2014
|
}
|
|
1027
2015
|
if (runs.length > 0 && !isLast) {
|
|
1028
2016
|
runs[runs.length - 1].options.breakLine = true;
|
|
@@ -1066,17 +2054,42 @@ export function parseSlideHtml(doc) {
|
|
|
1066
2054
|
// Extract text elements
|
|
1067
2055
|
if (!textTags.includes(el.tagName) || el.tagName === 'SPAN')
|
|
1068
2056
|
return;
|
|
1069
|
-
|
|
1070
|
-
|
|
2057
|
+
let rect = htmlEl.getBoundingClientRect();
|
|
2058
|
+
let text = el.textContent.trim();
|
|
1071
2059
|
if (rect.width === 0 || rect.height === 0 || !text)
|
|
1072
2060
|
return;
|
|
2061
|
+
const computed = win.getComputedStyle(el);
|
|
2062
|
+
// For flex containers with multiple children (e.g., LI with icon + text),
|
|
2063
|
+
// find the actual text position using a Range on the text nodes.
|
|
2064
|
+
// This ensures text is positioned at its visual location, not the container's.
|
|
2065
|
+
const isFlexContainer = computed.display === 'flex' || computed.display === 'inline-flex';
|
|
2066
|
+
if (isFlexContainer && el.children.length > 0) {
|
|
2067
|
+
// Collect direct text nodes (not in child elements)
|
|
2068
|
+
const textNodes = [];
|
|
2069
|
+
el.childNodes.forEach((node) => {
|
|
2070
|
+
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) {
|
|
2071
|
+
textNodes.push(node);
|
|
2072
|
+
}
|
|
2073
|
+
});
|
|
2074
|
+
if (textNodes.length > 0) {
|
|
2075
|
+
// Create a Range to measure the actual text position
|
|
2076
|
+
const range = doc.createRange();
|
|
2077
|
+
range.setStartBefore(textNodes[0]);
|
|
2078
|
+
range.setEndAfter(textNodes[textNodes.length - 1]);
|
|
2079
|
+
const textRect = range.getBoundingClientRect();
|
|
2080
|
+
// Use the text's actual position if it differs from the element's
|
|
2081
|
+
if (textRect.width > 0 && textRect.height > 0) {
|
|
2082
|
+
rect = textRect;
|
|
2083
|
+
text = textNodes.map((n) => n.textContent.trim()).join(' ').trim();
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
1073
2087
|
if (el.tagName !== 'LI' &&
|
|
1074
2088
|
/^[•\-\*\u25AA\u25B8\u25CB\u25CF\u25C6\u25C7\u25A0\u25A1]\s/.test(text.trimStart())) {
|
|
1075
2089
|
errors.push(`Text element <${el.tagName.toLowerCase()}> starts with bullet symbol "${text.substring(0, 20)}...". ` +
|
|
1076
2090
|
'Use <ul> or <ol> lists instead of manual bullet symbols.');
|
|
1077
2091
|
return;
|
|
1078
2092
|
}
|
|
1079
|
-
const computed = win.getComputedStyle(el);
|
|
1080
2093
|
const rotation = getRotation(computed.transform, computed.writingMode);
|
|
1081
2094
|
const { x, y, w, h } = getPositionAndSize(htmlEl, rect, rotation);
|
|
1082
2095
|
const fontSizePx = parseFloat(computed.fontSize);
|
|
@@ -1128,6 +2141,15 @@ export function parseSlideHtml(doc) {
|
|
|
1128
2141
|
const transparency = extractAlpha(computed.color);
|
|
1129
2142
|
if (transparency !== null)
|
|
1130
2143
|
baseStyle.transparency = transparency;
|
|
2144
|
+
const letterSpacing = extractLetterSpacing(computed);
|
|
2145
|
+
if (letterSpacing !== null)
|
|
2146
|
+
baseStyle.charSpacing = letterSpacing;
|
|
2147
|
+
// Extract text-shadow → glow and/or shadow
|
|
2148
|
+
const textShadowResult = parseTextShadow(computed.textShadow);
|
|
2149
|
+
if (textShadowResult.glow)
|
|
2150
|
+
baseStyle.glow = textShadowResult.glow;
|
|
2151
|
+
if (textShadowResult.shadow)
|
|
2152
|
+
baseStyle.textShadow = textShadowResult.shadow;
|
|
1131
2153
|
if (rotation !== null)
|
|
1132
2154
|
baseStyle.rotate = rotation;
|
|
1133
2155
|
const bgClip = computed.webkitBackgroundClip || computed.backgroundClip;
|
|
@@ -1149,7 +2171,7 @@ export function parseSlideHtml(doc) {
|
|
|
1149
2171
|
}
|
|
1150
2172
|
}
|
|
1151
2173
|
}
|
|
1152
|
-
const hasFormatting = el.querySelector('b, i, u, strong, em, span, br');
|
|
2174
|
+
const hasFormatting = el.querySelector('b, i, u, strong, em, span, br, code, a, mark, sub, sup, small, s, del, ins, abbr, time, cite, q, dfn, kbd, samp, var');
|
|
1153
2175
|
if (hasFormatting) {
|
|
1154
2176
|
const transformStr = computed.textTransform;
|
|
1155
2177
|
const runs = parseInlineFormatting(el, {}, [], (str) => applyTextTransform(str, transformStr), win);
|
|
@@ -1180,6 +2202,30 @@ export function parseSlideHtml(doc) {
|
|
|
1180
2202
|
}
|
|
1181
2203
|
processed.add(el);
|
|
1182
2204
|
});
|
|
2205
|
+
// Second pass: extract pseudo-elements for all processed elements
|
|
2206
|
+
// This must be done after the main pass so we know which elements have been processed
|
|
2207
|
+
processed.forEach((processedEl) => {
|
|
2208
|
+
const htmlEl = processedEl;
|
|
2209
|
+
if (htmlEl.tagName === 'BODY')
|
|
2210
|
+
return; // Already handled above
|
|
2211
|
+
const pseudoElements = extractPseudoElements(htmlEl, win);
|
|
2212
|
+
elements.push(...pseudoElements);
|
|
2213
|
+
});
|
|
2214
|
+
// Third pass: extract pseudo-elements from unprocessed container DIVs
|
|
2215
|
+
// Some DIVs (e.g., .image-side with overflow:hidden) have no bg/border/gradient
|
|
2216
|
+
// themselves but have ::before/::after pseudo-elements with gradient overlays.
|
|
2217
|
+
doc.querySelectorAll('div').forEach((divEl) => {
|
|
2218
|
+
if (processed.has(divEl))
|
|
2219
|
+
return; // Already handled in second pass
|
|
2220
|
+
const htmlDiv = divEl;
|
|
2221
|
+
if (htmlDiv === body)
|
|
2222
|
+
return; // Body already handled
|
|
2223
|
+
const rect = htmlDiv.getBoundingClientRect();
|
|
2224
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
2225
|
+
return;
|
|
2226
|
+
const pseudoElements = extractPseudoElements(htmlDiv, win);
|
|
2227
|
+
elements.push(...pseudoElements);
|
|
2228
|
+
});
|
|
1183
2229
|
return { background, elements, placeholders, errors };
|
|
1184
2230
|
}
|
|
1185
2231
|
//# sourceMappingURL=parse.js.map
|