docgen-utils 1.0.9 → 1.0.11

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.
@@ -13,9 +13,121 @@ const TARGET_WIDTH = 1280;
13
13
  const TARGET_HEIGHT = 720;
14
14
  // EMU (English Metric Units) to pixels: 1 inch = 914400 EMU, 96 DPI
15
15
  const EMU_PER_PX = 914400 / 96;
16
+ // Font fallback mapping: maps fonts that may not be installed to the closest
17
+ // available alternatives. This improves rendering fidelity when exact fonts
18
+ // are not present on the system. Each value is a comma-separated CSS font stack.
19
+ const FONT_FALLBACK_MAP = {
20
+ // Microsoft Office fonts → metrically compatible open-source alternatives
21
+ "Calibri": "'Calibri','Carlito','Helvetica Neue',Helvetica,Arial,sans-serif",
22
+ "Calibri Light": "'Calibri Light','Carlito','Helvetica Neue Light','Helvetica Neue',Arial,sans-serif",
23
+ "Cambria": "'Cambria','Caladea','Times New Roman',Georgia,serif",
24
+ "Cambria Math": "'Cambria Math','Caladea','Times New Roman',serif",
25
+ "Consolas": "'Consolas','Courier New',monospace",
26
+ "Aptos": "'Aptos','Carlito','Helvetica Neue',Arial,sans-serif",
27
+ "Times New Roman": "'Times New Roman','Liberation Serif',Georgia,serif",
28
+ // Handwriting / decorative fonts
29
+ "MV Boli": "'MV Boli','Comic Sans MS','Marker Felt',cursive",
30
+ "Kristen ITC": "'Kristen ITC','Comic Sans MS','Marker Felt',cursive",
31
+ "Stylus BT": "'Stylus BT','Brush Script MT','Snell Roundhand',cursive",
32
+ // Japanese sans-serif fonts → Noto Sans CJK JP (commonly installed)
33
+ "MS Gothic": "'MS Gothic','Noto Sans CJK JP','Hiragino Kaku Gothic ProN','Yu Gothic',sans-serif",
34
+ "MS PGothic": "'MS PGothic','Noto Sans CJK JP','Hiragino Kaku Gothic ProN','Yu Gothic',sans-serif",
35
+ "MS Pゴシック": "'MS Pゴシック','Noto Sans CJK JP','Hiragino Kaku Gothic ProN','Yu Gothic',sans-serif",
36
+ "MS ゴシック": "'MS ゴシック','Noto Sans CJK JP','Hiragino Kaku Gothic ProN','Yu Gothic',sans-serif",
37
+ "Kozuka Gothic Pro R": "'Kozuka Gothic Pro R','Noto Sans CJK JP','Hiragino Kaku Gothic ProN',sans-serif",
38
+ "Kozuka Gothic Pro B": "'Kozuka Gothic Pro B','Noto Sans CJK JP Bold','Hiragino Kaku Gothic ProN',sans-serif",
39
+ "Kozuka Gothic Pro M": "'Kozuka Gothic Pro M','Noto Sans CJK JP Medium','Hiragino Kaku Gothic ProN',sans-serif",
40
+ "Kozuka Gothic Pro EL": "'Kozuka Gothic Pro EL','Noto Sans CJK JP Light','Hiragino Kaku Gothic ProN',sans-serif",
41
+ "HGSKyokashotai": "'HGSKyokashotai','Noto Sans CJK JP','Hiragino Kaku Gothic ProN',sans-serif",
42
+ // Japanese serif fonts → Noto Serif CJK JP
43
+ "MS Mincho": "'MS Mincho','Noto Serif CJK JP','Hiragino Mincho ProN','Yu Mincho',serif",
44
+ "MS PMincho": "'MS PMincho','Noto Serif CJK JP','Hiragino Mincho ProN','Yu Mincho',serif",
45
+ // CJK fonts
46
+ "宋体": "'宋体','Noto Serif CJK SC','Songti SC','STSong',serif",
47
+ "新細明體": "'新細明體','Noto Serif CJK TC','Songti TC',serif",
48
+ "黑体": "'黑体','Noto Sans CJK SC','Heiti SC','STHeiti',sans-serif",
49
+ "微軟正黑體": "'微軟正黑體','Noto Sans CJK TC','Heiti TC',sans-serif",
50
+ "맑은 고딕": "'맑은 고딕','Apple SD Gothic Neo',sans-serif",
51
+ // UI / system fonts
52
+ "Lucida Sans Unicode": "'Lucida Sans Unicode','Lucida Grande','Lucida Sans',sans-serif",
53
+ "Segoe UI": "'Segoe UI','-apple-system','Helvetica Neue',sans-serif",
54
+ "Segoe Sans Display": "'Segoe Sans Display','-apple-system','Helvetica Neue',Arial,sans-serif",
55
+ "Segoe Sans Display Semibold": "'Segoe Sans Display Semibold','-apple-system','Helvetica Neue',Arial,sans-serif",
56
+ // Google Fonts / web fonts commonly used in presentations
57
+ "Inter": "'Inter','Helvetica Neue',Helvetica,Arial,sans-serif",
58
+ "Inter Light": "'Inter Light','Inter','Helvetica Neue',Arial,sans-serif",
59
+ "Inter ExtraBold": "'Inter ExtraBold','Inter','Helvetica Neue',Arial,sans-serif",
60
+ "Space Grotesk": "'Space Grotesk','Helvetica Neue',Helvetica,Arial,sans-serif",
61
+ "Montserrat": "'Montserrat','Helvetica Neue',Helvetica,Arial,sans-serif",
62
+ "Open Sans": "'Open Sans','Helvetica Neue',Helvetica,Arial,sans-serif",
63
+ "Lato": "'Lato','Helvetica Neue',Helvetica,Arial,sans-serif",
64
+ "Oswald": "'Oswald','Helvetica Neue',Helvetica,Arial,sans-serif",
65
+ "Archivo": "'Archivo','Helvetica Neue',Helvetica,Arial,sans-serif",
66
+ "Economica": "'Economica','Helvetica Neue',Arial,sans-serif",
67
+ "Libre Baskerville": "'Libre Baskerville','Georgia','Times New Roman',serif",
68
+ // GitHub Copilot fonts
69
+ "Ginto Copilot": "'Ginto Copilot','Helvetica Neue',Helvetica,Arial,sans-serif",
70
+ "Ginto Copilot Light": "'Ginto Copilot Light','Helvetica Neue Light','Helvetica Neue',Arial,sans-serif",
71
+ "Ginto Copilot 400": "'Ginto Copilot 400','Helvetica Neue',Helvetica,Arial,sans-serif",
72
+ // Google Fonts / decorative (serif & display)
73
+ "Playfair Display": "'Playfair Display','Georgia','Times New Roman',serif",
74
+ "Playfair Display SemiBold": "'Playfair Display SemiBold','Playfair Display','Georgia',serif",
75
+ "Caveat": "'Caveat','Comic Sans MS','Marker Felt',cursive",
76
+ "Syncopate": "'Syncopate','Helvetica Neue',Helvetica,Arial,sans-serif",
77
+ // Montserrat weight variants
78
+ "Montserrat SemiBold": "'Montserrat SemiBold','Montserrat','Helvetica Neue',Arial,sans-serif",
79
+ "Montserrat ExtraBold": "'Montserrat ExtraBold','Montserrat','Helvetica Neue',Arial,sans-serif",
80
+ // GitHub Copilot font variants
81
+ "Ginto Copilot Medium": "'Ginto Copilot Medium','Ginto Copilot','Helvetica Neue',Arial,sans-serif",
82
+ "Ginto Copilot Black": "'Ginto Copilot Black','Ginto Copilot','Helvetica Neue',Arial,sans-serif",
83
+ "Ginto Copilot Thin": "'Ginto Copilot Thin','Ginto Copilot Light','Helvetica Neue',Arial,sans-serif",
84
+ // Microsoft UI fonts
85
+ "Segoe Sans Small Regular": "'Segoe Sans Small Regular','Segoe UI','-apple-system','Helvetica Neue',sans-serif",
86
+ "Segoe Sans Text Regular": "'Segoe Sans Text Regular','Segoe UI','-apple-system','Helvetica Neue',sans-serif",
87
+ "Grandview": "'Grandview','Helvetica Neue',Arial,sans-serif",
88
+ "Nirmala UI": "'Nirmala UI','Helvetica Neue',Arial,sans-serif",
89
+ "Ebrima": "'Ebrima','Helvetica Neue',Arial,sans-serif",
90
+ // Common system fonts with fallbacks
91
+ "Arial": "Arial,'Helvetica Neue',Helvetica,sans-serif",
92
+ "Arial Black": "'Arial Black','Helvetica Neue',Helvetica,Arial,sans-serif",
93
+ "Georgia": "Georgia,'Times New Roman',serif",
94
+ "Georgia Regular": "'Georgia Regular',Georgia,'Times New Roman',serif",
95
+ "Courier New": "'Courier New',Courier,monospace",
96
+ "Times": "Times,'Times New Roman',serif",
97
+ // Symbol fonts (preserved as-is with system fallback)
98
+ "Wingdings": "'Wingdings','Zapf Dingbats',sans-serif",
99
+ "Wingdings 2": "'Wingdings 2','Zapf Dingbats',sans-serif",
100
+ "Wingdings 3": "'Wingdings 3','Zapf Dingbats',sans-serif",
101
+ // Common fallbacks
102
+ "Tahoma": "'Tahoma','Verdana','Geneva',sans-serif",
103
+ "Verdana": "'Verdana','Geneva',sans-serif",
104
+ "Helvetica Neue Light": "'Helvetica Neue Light','Helvetica Neue',Helvetica,Arial,sans-serif",
105
+ };
16
106
  // ============================================================================
17
107
  // Utility Functions
18
108
  // ============================================================================
109
+ /**
110
+ * Returns a CSS font-family value for the given font name.
111
+ * Uses FONT_FALLBACK_MAP to provide platform-appropriate fallbacks
112
+ * for fonts that may not be installed on the system.
113
+ */
114
+ function cssFontFamily(fontName) {
115
+ const mapped = FONT_FALLBACK_MAP[fontName];
116
+ if (mapped)
117
+ return mapped;
118
+ // Handle malformed typeface attributes that already contain commas (e.g. "Arial,Sans-Serif")
119
+ if (fontName.includes(',')) {
120
+ return fontName.split(',').map(f => {
121
+ const trimmed = f.trim();
122
+ const lower = trimmed.toLowerCase();
123
+ if (lower === 'sans-serif' || lower === 'serif' || lower === 'monospace' || lower === 'cursive' || lower === 'fantasy') {
124
+ return lower;
125
+ }
126
+ return `'${trimmed}'`;
127
+ }).join(',');
128
+ }
129
+ return `'${fontName}',sans-serif`;
130
+ }
19
131
  function emuToPx(emu) {
20
132
  return emu / EMU_PER_PX;
21
133
  }
@@ -95,6 +207,25 @@ function extractGradientFill(parent, themeColors) {
95
207
  }
96
208
  if (stops.length === 0)
97
209
  return undefined;
210
+ // Check for radial gradient (path element with path="circle")
211
+ const pathEl = findChild(gradFill, "path");
212
+ if (pathEl && pathEl.getAttribute("path") === "circle") {
213
+ // Extract center position from fillToRect
214
+ const fillToRect = findChild(pathEl, "fillToRect");
215
+ let centerX = 50;
216
+ let centerY = 50;
217
+ if (fillToRect) {
218
+ // OOXML uses l/t/r/b as percentages * 1000 from edges
219
+ // l=50000, t=50000 means center is at 50%, 50%
220
+ const l = parseInt(fillToRect.getAttribute("l") ?? "50000", 10) / 1000;
221
+ const t = parseInt(fillToRect.getAttribute("t") ?? "50000", 10) / 1000;
222
+ centerX = l;
223
+ centerY = t;
224
+ }
225
+ const stopStr = stops.map((s) => `${s.color} ${s.pos}%`).join(",");
226
+ return `radial-gradient(ellipse at ${centerX}% ${centerY}%,${stopStr})`;
227
+ }
228
+ // Linear gradient
98
229
  const lin = findChild(gradFill, "lin");
99
230
  const angAttr = lin?.getAttribute("ang");
100
231
  const cssDeg = angAttr ? ooxmlAngleToCss(parseInt(angAttr, 10)) : 180;
@@ -126,12 +257,88 @@ function extractRunProps(rPr, scale, themeColors) {
126
257
  if (color)
127
258
  result.color = color;
128
259
  }
260
+ // Handle gradient fill for text - use CSS background-clip:text technique
261
+ if (!result.color) {
262
+ const gradFill = findChild(rPr, "gradFill");
263
+ if (gradFill) {
264
+ const gsLst = findChild(gradFill, "gsLst");
265
+ if (gsLst) {
266
+ const gsEls = findChildren(gsLst, "gs");
267
+ if (gsEls.length > 0) {
268
+ // Extract gradient stops
269
+ const stops = [];
270
+ for (const gs of gsEls) {
271
+ const pos = parseInt(gs.getAttribute("pos") ?? "0", 10) / 1000;
272
+ const color = resolveColor(gs, themeColors);
273
+ if (color)
274
+ stops.push({ pos, color });
275
+ }
276
+ if (stops.length >= 2) {
277
+ // Extract angle from lin element
278
+ const lin = findChild(gradFill, "lin");
279
+ const angAttr = lin?.getAttribute("ang");
280
+ const cssDeg = angAttr ? ooxmlAngleToCss(parseInt(angAttr, 10)) : 135;
281
+ const stopStr = stops.map((s) => `${s.color} ${s.pos}%`).join(",");
282
+ result.gradientFill = `linear-gradient(${cssDeg}deg,${stopStr})`;
283
+ }
284
+ // Also set color as fallback (first gradient stop)
285
+ const firstColor = resolveColor(gsEls[0], themeColors);
286
+ if (firstColor)
287
+ result.color = firstColor;
288
+ }
289
+ }
290
+ }
291
+ }
129
292
  const latin = findChild(rPr, "latin");
130
293
  if (latin) {
131
294
  const typeface = latin.getAttribute("typeface");
132
295
  if (typeface)
133
296
  result.fontFamily = typeface;
134
297
  }
298
+ // Extract glow effect from effectLst
299
+ const effectLst = findChild(rPr, "effectLst");
300
+ if (effectLst) {
301
+ const glow = findChild(effectLst, "glow");
302
+ if (glow) {
303
+ // rad is in EMUs (English Metric Units)
304
+ const radAttr = glow.getAttribute("rad");
305
+ const radiusEmu = radAttr ? parseInt(radAttr, 10) : 0;
306
+ // Convert EMU to pixels: 914400 EMU = 1 inch = 96 pixels
307
+ const radiusPx = Math.round(emuToPx(radiusEmu) * scale);
308
+ // Get glow color
309
+ const glowColor = resolveColor(glow, themeColors);
310
+ if (glowColor && radiusPx > 0) {
311
+ // During export, CSS text-shadow is scaled for OOXML visual equivalence:
312
+ // - Size scaled by 2.5x (CSS blur spreads wider than OOXML glow)
313
+ // - Opacity scaled by 0.3x (OOXML glow appears more intense)
314
+ //
315
+ // During import, apply inverse scaling to restore CSS-like values:
316
+ const INVERSE_GLOW_SIZE_SCALE = 0.4; // 1/2.5
317
+ const INVERSE_GLOW_OPACITY_SCALE = 3.33; // 1/0.3
318
+ const cssRadius = Math.round(radiusPx * INVERSE_GLOW_SIZE_SCALE);
319
+ // Extract and scale opacity from rgba color
320
+ const rgbaMatch = glowColor.match(/rgba?\((\d+),(\d+),(\d+)(?:,([0-9.]+))?\)/);
321
+ if (rgbaMatch) {
322
+ const r = rgbaMatch[1];
323
+ const g = rgbaMatch[2];
324
+ const b = rgbaMatch[3];
325
+ const originalAlpha = rgbaMatch[4] ? parseFloat(rgbaMatch[4]) : 1;
326
+ const scaledAlpha = Math.min(originalAlpha * INVERSE_GLOW_OPACITY_SCALE, 1.0);
327
+ result.textShadow = `0 0 ${cssRadius}px rgba(${r},${g},${b},${scaledAlpha.toFixed(2)})`;
328
+ }
329
+ else if (glowColor.startsWith('#')) {
330
+ // Hex color - assume full opacity, apply scaling
331
+ const r = parseInt(glowColor.slice(1, 3), 16);
332
+ const g = parseInt(glowColor.slice(3, 5), 16);
333
+ const b = parseInt(glowColor.slice(5, 7), 16);
334
+ result.textShadow = `0 0 ${cssRadius}px rgba(${r},${g},${b},1)`;
335
+ }
336
+ else {
337
+ result.textShadow = `0 0 ${cssRadius}px ${glowColor}`;
338
+ }
339
+ }
340
+ }
341
+ }
135
342
  return result;
136
343
  }
137
344
  // ============================================================================
@@ -158,9 +365,10 @@ function parseShape(sp, scale, themeColors) {
158
365
  fill: extractFill(spPr, themeColors),
159
366
  paragraphs: [],
160
367
  };
161
- // Border radius from prstGeom roundRect
368
+ // Border radius from prstGeom roundRect or ellipse
162
369
  const prstGeom = findChild(spPr, "prstGeom");
163
- if (prstGeom?.getAttribute("prst") === "roundRect") {
370
+ const prstType = prstGeom?.getAttribute("prst");
371
+ if (prstType === "roundRect" && prstGeom) {
164
372
  const avLst = findChild(prstGeom, "avLst");
165
373
  const gd = avLst ? findChild(avLst, "gd") : null;
166
374
  const adjVal = gd
@@ -170,6 +378,10 @@ function parseShape(sp, scale, themeColors) {
170
378
  const radiusEmu = (minDim * Math.min(adjVal, 50000)) / 100000;
171
379
  shape.borderRadius = Math.round(emuToPx(radiusEmu) * scale);
172
380
  }
381
+ else if (prstType === "ellipse") {
382
+ // Ellipse shapes should have border-radius: 50% to render as circles
383
+ shape.isEllipse = true;
384
+ }
173
385
  // Border from a:ln
174
386
  const ln = findChild(spPr, "ln");
175
387
  if (ln) {
@@ -180,6 +392,14 @@ function parseShape(sp, scale, themeColors) {
180
392
  if (lnFill) {
181
393
  shape.borderColor = resolveColor(lnFill, themeColors);
182
394
  }
395
+ // Extract dash type from prstDash
396
+ const prstDash = findChild(ln, "prstDash");
397
+ if (prstDash) {
398
+ const dashVal = prstDash.getAttribute("val");
399
+ if (dashVal && dashVal !== "solid") {
400
+ shape.borderDashType = dashVal;
401
+ }
402
+ }
183
403
  }
184
404
  }
185
405
  // Body properties: vertical alignment and padding
@@ -261,21 +481,42 @@ function parseShape(sp, scale, themeColors) {
261
481
  para.bulletChar = buChar.getAttribute("char") ?? undefined;
262
482
  }
263
483
  }
264
- const runs = findChildren(p, "r");
265
- for (const r of runs) {
266
- const rPr = findChild(r, "rPr");
267
- const props = extractRunProps(rPr, scale, themeColors);
268
- const tEls = findChildren(r, "t");
269
- const text = tEls.map((t) => t.textContent ?? "").join("");
270
- if (text) {
271
- para.runs.push({
272
- text,
273
- bold: props.bold ?? defaults?.bold,
274
- italic: props.italic ?? defaults?.italic,
275
- fontSize: props.fontSize ?? defaults?.fontSize,
276
- color: props.color ?? defaults?.color,
277
- fontFamily: props.fontFamily ?? defaults?.fontFamily,
278
- });
484
+ // Iterate over all children to handle both text runs (<a:r>) and line breaks (<a:br>)
485
+ // This ensures proper spacing when text spans multiple lines
486
+ for (const child of Array.from(p.childNodes)) {
487
+ if (child.nodeType !== 1)
488
+ continue; // ELEMENT_NODE = 1
489
+ const el = child;
490
+ const localName = el.localName || el.nodeName.split(':').pop();
491
+ if (localName === 'r') {
492
+ // Text run
493
+ const rPr = findChild(el, "rPr");
494
+ const props = extractRunProps(rPr, scale, themeColors);
495
+ const tEls = findChildren(el, "t");
496
+ const text = tEls.map((t) => t.textContent ?? "").join("");
497
+ if (text) {
498
+ para.runs.push({
499
+ text,
500
+ bold: props.bold ?? defaults?.bold,
501
+ italic: props.italic ?? defaults?.italic,
502
+ fontSize: props.fontSize ?? defaults?.fontSize,
503
+ color: props.color ?? defaults?.color,
504
+ fontFamily: props.fontFamily ?? defaults?.fontFamily,
505
+ textShadow: props.textShadow ?? defaults?.textShadow,
506
+ gradientFill: props.gradientFill ?? defaults?.gradientFill,
507
+ });
508
+ }
509
+ }
510
+ else if (localName === 'br') {
511
+ // Line break - add a newline to the previous run or create a new run with just newline
512
+ if (para.runs.length > 0) {
513
+ // Append newline to the last run's text
514
+ para.runs[para.runs.length - 1].text += '\n';
515
+ }
516
+ else {
517
+ // No previous run, create a new one with just newline
518
+ para.runs.push({ text: '\n' });
519
+ }
279
520
  }
280
521
  }
281
522
  if (para.runs.length > 0) {
@@ -302,8 +543,34 @@ function parsePicture(pic, scale, imageMap) {
302
543
  const blip = findChild(blipFill, "blip");
303
544
  if (!blip)
304
545
  return null;
305
- const rEmbed = blip.getAttribute("r:embed") ??
306
- blip.getAttributeNS("http://schemas.openxmlformats.org/officeDocument/2006/relationships", "embed");
546
+ // First, try to get SVG from asvg:svgBlip (higher quality)
547
+ let rEmbed = null;
548
+ const extLst = findChild(blip, "extLst");
549
+ if (extLst) {
550
+ for (let i = 0; i < extLst.children.length; i++) {
551
+ const ext = extLst.children[i];
552
+ if (ext.localName === "ext") {
553
+ // Look for svgBlip in extension
554
+ for (let j = 0; j < ext.children.length; j++) {
555
+ const child = ext.children[j];
556
+ if (child.localName === "svgBlip") {
557
+ rEmbed =
558
+ child.getAttribute("r:embed") ??
559
+ child.getAttributeNS("http://schemas.openxmlformats.org/officeDocument/2006/relationships", "embed");
560
+ break;
561
+ }
562
+ }
563
+ if (rEmbed)
564
+ break;
565
+ }
566
+ }
567
+ }
568
+ // Fallback to regular blip if no SVG found
569
+ if (!rEmbed) {
570
+ rEmbed =
571
+ blip.getAttribute("r:embed") ??
572
+ blip.getAttributeNS("http://schemas.openxmlformats.org/officeDocument/2006/relationships", "embed");
573
+ }
307
574
  if (!rEmbed)
308
575
  return null;
309
576
  const dataUri = imageMap.get(rEmbed);
@@ -312,6 +579,34 @@ function parsePicture(pic, scale, imageMap) {
312
579
  const nvPicPr = findChild(pic, "nvPicPr");
313
580
  const cNvPr = nvPicPr ? findChild(nvPicPr, "cNvPr") : null;
314
581
  const alt = cNvPr?.getAttribute("descr") ?? undefined;
582
+ // Check for srcRect cropping - indicates the image should use object-fit:cover
583
+ const srcRect = findChild(blipFill, "srcRect");
584
+ let hasCrop = false;
585
+ if (srcRect) {
586
+ const l = parseInt(srcRect.getAttribute("l") ?? "0", 10);
587
+ const r = parseInt(srcRect.getAttribute("r") ?? "0", 10);
588
+ const t = parseInt(srcRect.getAttribute("t") ?? "0", 10);
589
+ const b = parseInt(srcRect.getAttribute("b") ?? "0", 10);
590
+ // If any cropping is applied, the original image used object-fit:cover
591
+ hasCrop = l > 0 || r > 0 || t > 0 || b > 0;
592
+ }
593
+ // Extract border-radius from roundRect preset geometry
594
+ let borderRadius;
595
+ const prstGeom = findChild(spPr, "prstGeom");
596
+ if (prstGeom?.getAttribute("prst") === "roundRect") {
597
+ const avLst = findChild(prstGeom, "avLst");
598
+ const gd = avLst ? findChild(avLst, "gd") : null;
599
+ // Default adj value for roundRect is 16667 (1/6 of 100000)
600
+ const adjVal = gd
601
+ ? parseInt(gd.getAttribute("fmla")?.replace("val ", "") ?? "16667", 10)
602
+ : 16667;
603
+ const wEmu = parseInt(ext.getAttribute("cx") ?? "0", 10);
604
+ const hEmu = parseInt(ext.getAttribute("cy") ?? "0", 10);
605
+ const minDim = Math.min(wEmu, hEmu);
606
+ // The adjustment value is a percentage where 50000 = 50% = max possible radius
607
+ const radiusEmu = (minDim * Math.min(adjVal, 50000)) / 100000;
608
+ borderRadius = Math.round(emuToPx(radiusEmu) * scale);
609
+ }
315
610
  return {
316
611
  x: Math.round(emuToPx(parseInt(off.getAttribute("x") ?? "0", 10)) * scale),
317
612
  y: Math.round(emuToPx(parseInt(off.getAttribute("y") ?? "0", 10)) * scale),
@@ -319,6 +614,8 @@ function parsePicture(pic, scale, imageMap) {
319
614
  h: Math.round(emuToPx(parseInt(ext.getAttribute("cy") ?? "0", 10)) * scale),
320
615
  dataUri,
321
616
  alt,
617
+ borderRadius,
618
+ hasCrop,
322
619
  };
323
620
  }
324
621
  // ============================================================================
@@ -331,14 +628,25 @@ function renderSlideHtml(elements, bgColor) {
331
628
  const elId = `el-${elementIndex++}`;
332
629
  if (el.kind === "image") {
333
630
  const img = el.data;
334
- const styles = [
631
+ // Detect images that should use object-fit:cover:
632
+ // 1. Fullbleed images (covering entire slide)
633
+ // 2. Images with srcRect cropping applied (hasCrop)
634
+ const isFullbleed = img.x <= 10 && img.y <= 10 &&
635
+ img.w >= TARGET_WIDTH - 20 && img.h >= TARGET_HEIGHT - 20;
636
+ const objectFit = isFullbleed || img.hasCrop ? "cover" : "contain";
637
+ const stylesList = [
335
638
  "position:absolute",
336
639
  `left:${img.x}px`,
337
640
  `top:${img.y}px`,
338
641
  `width:${img.w}px`,
339
642
  `height:${img.h}px`,
340
- "object-fit:contain",
341
- ].join(";");
643
+ `object-fit:${objectFit}`,
644
+ ];
645
+ // Add border-radius if present
646
+ if (img.borderRadius && img.borderRadius > 0) {
647
+ stylesList.push(`border-radius:${img.borderRadius}px`);
648
+ }
649
+ const styles = stylesList.join(";");
342
650
  const altAttr = img.alt
343
651
  ? ` alt="${img.alt.replace(/"/g, "&quot;")}"`
344
652
  : "";
@@ -360,8 +668,27 @@ function renderSlideHtml(elements, bgColor) {
360
668
  if (shape.borderRadius) {
361
669
  styles.push(`border-radius:${shape.borderRadius}px`);
362
670
  }
671
+ else if (shape.isEllipse) {
672
+ // Ellipse shapes render as circles/ovals with 50% border-radius
673
+ styles.push(`border-radius:50%`);
674
+ }
363
675
  if (shape.borderWidth && shape.borderColor) {
364
- styles.push(`border:${shape.borderWidth}px solid ${shape.borderColor}`);
676
+ // Convert PPTX dashType to CSS border-style
677
+ let borderStyle = "solid";
678
+ if (shape.borderDashType) {
679
+ switch (shape.borderDashType) {
680
+ case "dash":
681
+ case "lgDash":
682
+ case "sysDash":
683
+ borderStyle = "dashed";
684
+ break;
685
+ case "sysDot":
686
+ case "dot":
687
+ borderStyle = "dotted";
688
+ break;
689
+ }
690
+ }
691
+ styles.push(`border:${shape.borderWidth}px ${borderStyle} ${shape.borderColor}`);
365
692
  styles.push("box-sizing:border-box");
366
693
  }
367
694
  if (shape.padding) {
@@ -402,14 +729,24 @@ function renderSlideHtml(elements, bgColor) {
402
729
  const rStyles = [];
403
730
  if (run.fontSize)
404
731
  rStyles.push(`font-size:${run.fontSize}px`);
405
- if (run.color)
732
+ if (run.gradientFill) {
733
+ // CSS gradient text using background-clip technique
734
+ rStyles.push(`background:${run.gradientFill}`);
735
+ rStyles.push("-webkit-background-clip:text");
736
+ rStyles.push("-webkit-text-fill-color:transparent");
737
+ rStyles.push("background-clip:text");
738
+ }
739
+ else if (run.color) {
406
740
  rStyles.push(`color:${run.color}`);
741
+ }
407
742
  if (run.bold)
408
743
  rStyles.push("font-weight:bold");
409
744
  if (run.italic)
410
745
  rStyles.push("font-style:italic");
411
746
  if (run.fontFamily)
412
- rStyles.push(`font-family:'${run.fontFamily}',sans-serif`);
747
+ rStyles.push(`font-family:${cssFontFamily(run.fontFamily)}`);
748
+ if (run.textShadow)
749
+ rStyles.push(`text-shadow:${run.textShadow}`);
413
750
  const escapedText = run.text
414
751
  .replace(/&/g, "&amp;")
415
752
  .replace(/</g, "&lt;")
@@ -544,6 +881,28 @@ export default async function importPptx(arrayBuffer) {
544
881
  }
545
882
  }
546
883
  }
884
+ // Parse embedded fonts from presentation.xml
885
+ // PPTX files can embed TrueType fonts in ppt/fonts/ as .fntdata (EOT format)
886
+ const embeddedFontNames = new Set();
887
+ const embeddedFontEls = presDoc.getElementsByTagName("p:embeddedFont");
888
+ for (let i = 0; i < embeddedFontEls.length; i++) {
889
+ const fontEl = embeddedFontEls[i].getElementsByTagName("p:font")[0];
890
+ if (fontEl) {
891
+ const typeface = fontEl.getAttribute("typeface");
892
+ if (typeface)
893
+ embeddedFontNames.add(typeface);
894
+ }
895
+ }
896
+ // Build Google Fonts link for embedded font families
897
+ // EOT files in PPTX are compressed and not usable in modern browsers,
898
+ // so we load them via Google Fonts CDN if available.
899
+ let fontStyleBlock = "";
900
+ if (embeddedFontNames.size > 0) {
901
+ const fontFamilies = Array.from(embeddedFontNames)
902
+ .map((name) => name.replace(/ /g, "+") + ":ital,wght@0,400;0,700;1,400;1,700")
903
+ .join("&family=");
904
+ fontStyleBlock = `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${fontFamilies}&display=swap">`;
905
+ }
547
906
  const slides = [];
548
907
  for (const slideFile of slideFiles) {
549
908
  const slideXml = await zip.file(slideFile)?.async("text");
@@ -614,6 +973,10 @@ export default async function importPptx(arrayBuffer) {
614
973
  }
615
974
  slides.push(renderSlideHtml(elements, slideBg));
616
975
  }
976
+ // Prepend font style block to first slide so embedded fonts load via Google Fonts
977
+ if (fontStyleBlock && slides.length > 0) {
978
+ slides[0] = fontStyleBlock + slides[0];
979
+ }
617
980
  return slides;
618
981
  }
619
982
  //# sourceMappingURL=import-pptx.js.map