main
1/**
2 * html2pptx - Convert HTML slide to pptxgenjs slide with positioned elements
3 *
4 * USAGE:
5 * const pptx = new pptxgen();
6 * pptx.layout = 'LAYOUT_16x9'; // Must match HTML body dimensions
7 *
8 * const { slide, placeholders } = await html2pptx('slide.html', pptx);
9 * slide.addChart(pptx.charts.LINE, data, placeholders[0]);
10 *
11 * await pptx.writeFile('output.pptx');
12 *
13 * FEATURES:
14 * - Converts HTML to PowerPoint with accurate positioning
15 * - Supports text, images, shapes, and bullet lists
16 * - Extracts placeholder elements (class="placeholder") with positions
17 * - Handles CSS gradients, borders, and margins
18 *
19 * VALIDATION:
20 * - Uses body width/height from HTML for viewport sizing
21 * - Throws error if HTML dimensions don't match presentation layout
22 * - Throws error if content overflows body (with overflow details)
23 *
24 * RETURNS:
25 * { slide, placeholders } where placeholders is an array of { id, x, y, w, h }
26 */
27
28const { chromium } = require('playwright');
29const path = require('path');
30const sharp = require('sharp');
31
32const PT_PER_PX = 0.75;
33const PX_PER_IN = 96;
34const EMU_PER_IN = 914400;
35
36// Helper: Get body dimensions and check for overflow
37async function getBodyDimensions(page) {
38 const bodyDimensions = await page.evaluate(() => {
39 const body = document.body;
40 const style = window.getComputedStyle(body);
41
42 return {
43 width: parseFloat(style.width),
44 height: parseFloat(style.height),
45 scrollWidth: body.scrollWidth,
46 scrollHeight: body.scrollHeight
47 };
48 });
49
50 const errors = [];
51 const widthOverflowPx = Math.max(0, bodyDimensions.scrollWidth - bodyDimensions.width - 1);
52 const heightOverflowPx = Math.max(0, bodyDimensions.scrollHeight - bodyDimensions.height - 1);
53
54 const widthOverflowPt = widthOverflowPx * PT_PER_PX;
55 const heightOverflowPt = heightOverflowPx * PT_PER_PX;
56
57 if (widthOverflowPt > 0 || heightOverflowPt > 0) {
58 const directions = [];
59 if (widthOverflowPt > 0) directions.push(`${widthOverflowPt.toFixed(1)}pt horizontally`);
60 if (heightOverflowPt > 0) directions.push(`${heightOverflowPt.toFixed(1)}pt vertically`);
61 const reminder = heightOverflowPt > 0 ? ' (Remember: leave 0.5" margin at bottom of slide)' : '';
62 errors.push(`HTML content overflows body by ${directions.join(' and ')}${reminder}`);
63 }
64
65 return { ...bodyDimensions, errors };
66}
67
68// Helper: Validate dimensions match presentation layout
69function validateDimensions(bodyDimensions, pres) {
70 const errors = [];
71 const widthInches = bodyDimensions.width / PX_PER_IN;
72 const heightInches = bodyDimensions.height / PX_PER_IN;
73
74 if (pres.presLayout) {
75 const layoutWidth = pres.presLayout.width / EMU_PER_IN;
76 const layoutHeight = pres.presLayout.height / EMU_PER_IN;
77
78 if (Math.abs(layoutWidth - widthInches) > 0.1 || Math.abs(layoutHeight - heightInches) > 0.1) {
79 errors.push(
80 `HTML dimensions (${widthInches.toFixed(1)}" × ${heightInches.toFixed(1)}") ` +
81 `don't match presentation layout (${layoutWidth.toFixed(1)}" × ${layoutHeight.toFixed(1)}")`
82 );
83 }
84 }
85 return errors;
86}
87
88function validateTextBoxPosition(slideData, bodyDimensions) {
89 const errors = [];
90 const slideHeightInches = bodyDimensions.height / PX_PER_IN;
91 const minBottomMargin = 0.5; // 0.5 inches from bottom
92
93 for (const el of slideData.elements) {
94 // Check text elements (p, h1-h6, list)
95 if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'list'].includes(el.type)) {
96 const fontSize = el.style?.fontSize || 0;
97 const bottomEdge = el.position.y + el.position.h;
98 const distanceFromBottom = slideHeightInches - bottomEdge;
99
100 if (fontSize > 12 && distanceFromBottom < minBottomMargin) {
101 const getText = () => {
102 if (typeof el.text === 'string') return el.text;
103 if (Array.isArray(el.text)) return el.text.find(t => t.text)?.text || '';
104 if (Array.isArray(el.items)) return el.items.find(item => item.text)?.text || '';
105 return '';
106 };
107 const textPrefix = getText().substring(0, 50) + (getText().length > 50 ? '...' : '');
108
109 errors.push(
110 `Text box "${textPrefix}" ends too close to bottom edge ` +
111 `(${distanceFromBottom.toFixed(2)}" from bottom, minimum ${minBottomMargin}" required)`
112 );
113 }
114 }
115 }
116
117 return errors;
118}
119
120// Helper: Add background to slide
121async function addBackground(slideData, targetSlide, tmpDir) {
122 if (slideData.background.type === 'image' && slideData.background.path) {
123 let imagePath = slideData.background.path.startsWith('file://')
124 ? slideData.background.path.replace('file://', '')
125 : slideData.background.path;
126 targetSlide.background = { path: imagePath };
127 } else if (slideData.background.type === 'color' && slideData.background.value) {
128 targetSlide.background = { color: slideData.background.value };
129 }
130}
131
132// Helper: Add elements to slide
133function addElements(slideData, targetSlide, pres) {
134 for (const el of slideData.elements) {
135 if (el.type === 'image') {
136 let imagePath = el.src.startsWith('file://') ? el.src.replace('file://', '') : el.src;
137 targetSlide.addImage({
138 path: imagePath,
139 x: el.position.x,
140 y: el.position.y,
141 w: el.position.w,
142 h: el.position.h
143 });
144 } else if (el.type === 'line') {
145 targetSlide.addShape(pres.ShapeType.line, {
146 x: el.x1,
147 y: el.y1,
148 w: el.x2 - el.x1,
149 h: el.y2 - el.y1,
150 line: { color: el.color, width: el.width }
151 });
152 } else if (el.type === 'shape') {
153 const shapeOptions = {
154 x: el.position.x,
155 y: el.position.y,
156 w: el.position.w,
157 h: el.position.h,
158 shape: el.shape.rectRadius > 0 ? pres.ShapeType.roundRect : pres.ShapeType.rect
159 };
160
161 if (el.shape.fill) {
162 shapeOptions.fill = { color: el.shape.fill };
163 if (el.shape.transparency != null) shapeOptions.fill.transparency = el.shape.transparency;
164 }
165 if (el.shape.line) shapeOptions.line = el.shape.line;
166 if (el.shape.rectRadius > 0) shapeOptions.rectRadius = el.shape.rectRadius;
167 if (el.shape.shadow) shapeOptions.shadow = el.shape.shadow;
168
169 targetSlide.addText(el.text || '', shapeOptions);
170 } else if (el.type === 'list') {
171 const listOptions = {
172 x: el.position.x,
173 y: el.position.y,
174 w: el.position.w,
175 h: el.position.h,
176 fontSize: el.style.fontSize,
177 fontFace: el.style.fontFace,
178 color: el.style.color,
179 align: el.style.align,
180 valign: 'top',
181 lineSpacing: el.style.lineSpacing,
182 paraSpaceBefore: el.style.paraSpaceBefore,
183 paraSpaceAfter: el.style.paraSpaceAfter,
184 margin: el.style.margin
185 };
186 if (el.style.margin) listOptions.margin = el.style.margin;
187 targetSlide.addText(el.items, listOptions);
188 } else {
189 // Check if text is single-line (height suggests one line)
190 const lineHeight = el.style.lineSpacing || el.style.fontSize * 1.2;
191 const isSingleLine = el.position.h <= lineHeight * 1.5;
192
193 let adjustedX = el.position.x;
194 let adjustedW = el.position.w;
195
196 // Make single-line text 2% wider to account for underestimate
197 if (isSingleLine) {
198 const widthIncrease = el.position.w * 0.02;
199 const align = el.style.align;
200
201 if (align === 'center') {
202 // Center: expand both sides
203 adjustedX = el.position.x - (widthIncrease / 2);
204 adjustedW = el.position.w + widthIncrease;
205 } else if (align === 'right') {
206 // Right: expand to the left
207 adjustedX = el.position.x - widthIncrease;
208 adjustedW = el.position.w + widthIncrease;
209 } else {
210 // Left (default): expand to the right
211 adjustedW = el.position.w + widthIncrease;
212 }
213 }
214
215 const textOptions = {
216 x: adjustedX,
217 y: el.position.y,
218 w: adjustedW,
219 h: el.position.h,
220 fontSize: el.style.fontSize,
221 fontFace: el.style.fontFace,
222 color: el.style.color,
223 bold: el.style.bold,
224 italic: el.style.italic,
225 underline: el.style.underline,
226 valign: 'top',
227 lineSpacing: el.style.lineSpacing,
228 paraSpaceBefore: el.style.paraSpaceBefore,
229 paraSpaceAfter: el.style.paraSpaceAfter,
230 inset: 0 // Remove default PowerPoint internal padding
231 };
232
233 if (el.style.align) textOptions.align = el.style.align;
234 if (el.style.margin) textOptions.margin = el.style.margin;
235 if (el.style.rotate !== undefined) textOptions.rotate = el.style.rotate;
236 if (el.style.transparency !== null && el.style.transparency !== undefined) textOptions.transparency = el.style.transparency;
237
238 targetSlide.addText(el.text, textOptions);
239 }
240 }
241}
242
243// Helper: Extract slide data from HTML page
244async function extractSlideData(page) {
245 return await page.evaluate(() => {
246 const PT_PER_PX = 0.75;
247 const PX_PER_IN = 96;
248
249 // Fonts that are single-weight and should not have bold applied
250 // (applying bold causes PowerPoint to use faux bold which makes text wider)
251 const SINGLE_WEIGHT_FONTS = ['impact'];
252
253 // Helper: Check if a font should skip bold formatting
254 const shouldSkipBold = (fontFamily) => {
255 if (!fontFamily) return false;
256 const normalizedFont = fontFamily.toLowerCase().replace(/['"]/g, '').split(',')[0].trim();
257 return SINGLE_WEIGHT_FONTS.includes(normalizedFont);
258 };
259
260 // Unit conversion helpers
261 const pxToInch = (px) => px / PX_PER_IN;
262 const pxToPoints = (pxStr) => parseFloat(pxStr) * PT_PER_PX;
263 const rgbToHex = (rgbStr) => {
264 // Handle transparent backgrounds by defaulting to white
265 if (rgbStr === 'rgba(0, 0, 0, 0)' || rgbStr === 'transparent') return 'FFFFFF';
266
267 const match = rgbStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
268 if (!match) return 'FFFFFF';
269 return match.slice(1).map(n => parseInt(n).toString(16).padStart(2, '0')).join('');
270 };
271
272 const extractAlpha = (rgbStr) => {
273 const match = rgbStr.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/);
274 if (!match || !match[4]) return null;
275 const alpha = parseFloat(match[4]);
276 return Math.round((1 - alpha) * 100);
277 };
278
279 const applyTextTransform = (text, textTransform) => {
280 if (textTransform === 'uppercase') return text.toUpperCase();
281 if (textTransform === 'lowercase') return text.toLowerCase();
282 if (textTransform === 'capitalize') {
283 return text.replace(/\b\w/g, c => c.toUpperCase());
284 }
285 return text;
286 };
287
288 // Extract rotation angle from CSS transform and writing-mode
289 const getRotation = (transform, writingMode) => {
290 let angle = 0;
291
292 // Handle writing-mode first
293 // PowerPoint: 90° = text rotated 90° clockwise (reads top to bottom, letters upright)
294 // PowerPoint: 270° = text rotated 270° clockwise (reads bottom to top, letters upright)
295 if (writingMode === 'vertical-rl') {
296 // vertical-rl alone = text reads top to bottom = 90° in PowerPoint
297 angle = 90;
298 } else if (writingMode === 'vertical-lr') {
299 // vertical-lr alone = text reads bottom to top = 270° in PowerPoint
300 angle = 270;
301 }
302
303 // Then add any transform rotation
304 if (transform && transform !== 'none') {
305 // Try to match rotate() function
306 const rotateMatch = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
307 if (rotateMatch) {
308 angle += parseFloat(rotateMatch[1]);
309 } else {
310 // Browser may compute as matrix - extract rotation from matrix
311 const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
312 if (matrixMatch) {
313 const values = matrixMatch[1].split(',').map(parseFloat);
314 // matrix(a, b, c, d, e, f) where rotation = atan2(b, a)
315 const matrixAngle = Math.atan2(values[1], values[0]) * (180 / Math.PI);
316 angle += Math.round(matrixAngle);
317 }
318 }
319 }
320
321 // Normalize to 0-359 range
322 angle = angle % 360;
323 if (angle < 0) angle += 360;
324
325 return angle === 0 ? null : angle;
326 };
327
328 // Get position/dimensions accounting for rotation
329 const getPositionAndSize = (el, rect, rotation) => {
330 if (rotation === null) {
331 return { x: rect.left, y: rect.top, w: rect.width, h: rect.height };
332 }
333
334 // For 90° or 270° rotations, swap width and height
335 // because PowerPoint applies rotation to the original (unrotated) box
336 const isVertical = rotation === 90 || rotation === 270;
337
338 if (isVertical) {
339 // The browser shows us the rotated dimensions (tall box for vertical text)
340 // But PowerPoint needs the pre-rotation dimensions (wide box that will be rotated)
341 // So we swap: browser's height becomes PPT's width, browser's width becomes PPT's height
342 const centerX = rect.left + rect.width / 2;
343 const centerY = rect.top + rect.height / 2;
344
345 return {
346 x: centerX - rect.height / 2,
347 y: centerY - rect.width / 2,
348 w: rect.height,
349 h: rect.width
350 };
351 }
352
353 // For other rotations, use element's offset dimensions
354 const centerX = rect.left + rect.width / 2;
355 const centerY = rect.top + rect.height / 2;
356 return {
357 x: centerX - el.offsetWidth / 2,
358 y: centerY - el.offsetHeight / 2,
359 w: el.offsetWidth,
360 h: el.offsetHeight
361 };
362 };
363
364 // Parse CSS box-shadow into PptxGenJS shadow properties
365 const parseBoxShadow = (boxShadow) => {
366 if (!boxShadow || boxShadow === 'none') return null;
367
368 // Browser computed style format: "rgba(0, 0, 0, 0.3) 2px 2px 8px 0px [inset]"
369 // CSS format: "[inset] 2px 2px 8px 0px rgba(0, 0, 0, 0.3)"
370
371 const insetMatch = boxShadow.match(/inset/);
372
373 // IMPORTANT: PptxGenJS/PowerPoint doesn't properly support inset shadows
374 // Only process outer shadows to avoid file corruption
375 if (insetMatch) return null;
376
377 // Extract color first (rgba or rgb at start)
378 const colorMatch = boxShadow.match(/rgba?\([^)]+\)/);
379
380 // Extract numeric values (handles both px and pt units)
381 const parts = boxShadow.match(/([-\d.]+)(px|pt)/g);
382
383 if (!parts || parts.length < 2) return null;
384
385 const offsetX = parseFloat(parts[0]);
386 const offsetY = parseFloat(parts[1]);
387 const blur = parts.length > 2 ? parseFloat(parts[2]) : 0;
388
389 // Calculate angle from offsets (in degrees, 0 = right, 90 = down)
390 let angle = 0;
391 if (offsetX !== 0 || offsetY !== 0) {
392 angle = Math.atan2(offsetY, offsetX) * (180 / Math.PI);
393 if (angle < 0) angle += 360;
394 }
395
396 // Calculate offset distance (hypotenuse)
397 const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY) * PT_PER_PX;
398
399 // Extract opacity from rgba
400 let opacity = 0.5;
401 if (colorMatch) {
402 const opacityMatch = colorMatch[0].match(/[\d.]+\)$/);
403 if (opacityMatch) {
404 opacity = parseFloat(opacityMatch[0].replace(')', ''));
405 }
406 }
407
408 return {
409 type: 'outer',
410 angle: Math.round(angle),
411 blur: blur * 0.75, // Convert to points
412 color: colorMatch ? rgbToHex(colorMatch[0]) : '000000',
413 offset: offset,
414 opacity
415 };
416 };
417
418 // Parse inline formatting tags (<b>, <i>, <u>, <strong>, <em>, <span>) into text runs
419 const parseInlineFormatting = (element, baseOptions = {}, runs = [], baseTextTransform = (x) => x) => {
420 let prevNodeIsText = false;
421
422 element.childNodes.forEach((node) => {
423 let textTransform = baseTextTransform;
424
425 const isText = node.nodeType === Node.TEXT_NODE || node.tagName === 'BR';
426 if (isText) {
427 const text = node.tagName === 'BR' ? '\n' : textTransform(node.textContent.replace(/\s+/g, ' '));
428 const prevRun = runs[runs.length - 1];
429 if (prevNodeIsText && prevRun) {
430 prevRun.text += text;
431 } else {
432 runs.push({ text, options: { ...baseOptions } });
433 }
434
435 } else if (node.nodeType === Node.ELEMENT_NODE && node.textContent.trim()) {
436 const options = { ...baseOptions };
437 const computed = window.getComputedStyle(node);
438
439 // Handle inline elements with computed styles
440 if (node.tagName === 'SPAN' || node.tagName === 'B' || node.tagName === 'STRONG' || node.tagName === 'I' || node.tagName === 'EM' || node.tagName === 'U') {
441 const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600;
442 if (isBold && !shouldSkipBold(computed.fontFamily)) options.bold = true;
443 if (computed.fontStyle === 'italic') options.italic = true;
444 if (computed.textDecoration && computed.textDecoration.includes('underline')) options.underline = true;
445 if (computed.color && computed.color !== 'rgb(0, 0, 0)') {
446 options.color = rgbToHex(computed.color);
447 const transparency = extractAlpha(computed.color);
448 if (transparency !== null) options.transparency = transparency;
449 }
450 if (computed.fontSize) options.fontSize = pxToPoints(computed.fontSize);
451
452 // Apply text-transform on the span element itself
453 if (computed.textTransform && computed.textTransform !== 'none') {
454 const transformStr = computed.textTransform;
455 textTransform = (text) => applyTextTransform(text, transformStr);
456 }
457
458 // Validate: Check for margins on inline elements
459 if (computed.marginLeft && parseFloat(computed.marginLeft) > 0) {
460 errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-left which is not supported in PowerPoint. Remove margin from inline elements.`);
461 }
462 if (computed.marginRight && parseFloat(computed.marginRight) > 0) {
463 errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-right which is not supported in PowerPoint. Remove margin from inline elements.`);
464 }
465 if (computed.marginTop && parseFloat(computed.marginTop) > 0) {
466 errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-top which is not supported in PowerPoint. Remove margin from inline elements.`);
467 }
468 if (computed.marginBottom && parseFloat(computed.marginBottom) > 0) {
469 errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-bottom which is not supported in PowerPoint. Remove margin from inline elements.`);
470 }
471
472 // Recursively process the child node. This will flatten nested spans into multiple runs.
473 parseInlineFormatting(node, options, runs, textTransform);
474 }
475 }
476
477 prevNodeIsText = isText;
478 });
479
480 // Trim leading space from first run and trailing space from last run
481 if (runs.length > 0) {
482 runs[0].text = runs[0].text.replace(/^\s+/, '');
483 runs[runs.length - 1].text = runs[runs.length - 1].text.replace(/\s+$/, '');
484 }
485
486 return runs.filter(r => r.text.length > 0);
487 };
488
489 // Extract background from body (image or color)
490 const body = document.body;
491 const bodyStyle = window.getComputedStyle(body);
492 const bgImage = bodyStyle.backgroundImage;
493 const bgColor = bodyStyle.backgroundColor;
494
495 // Collect validation errors
496 const errors = [];
497
498 // Validate: Check for CSS gradients
499 if (bgImage && (bgImage.includes('linear-gradient') || bgImage.includes('radial-gradient'))) {
500 errors.push(
501 'CSS gradients are not supported. Use Sharp to rasterize gradients as PNG images first, ' +
502 'then reference with background-image: url(\'gradient.png\')'
503 );
504 }
505
506 let background;
507 if (bgImage && bgImage !== 'none') {
508 // Extract URL from url("...") or url(...)
509 const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/);
510 if (urlMatch) {
511 background = {
512 type: 'image',
513 path: urlMatch[1]
514 };
515 } else {
516 background = {
517 type: 'color',
518 value: rgbToHex(bgColor)
519 };
520 }
521 } else {
522 background = {
523 type: 'color',
524 value: rgbToHex(bgColor)
525 };
526 }
527
528 // Process all elements
529 const elements = [];
530 const placeholders = [];
531 const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI'];
532 const processed = new Set();
533
534 document.querySelectorAll('*').forEach((el) => {
535 if (processed.has(el)) return;
536
537 // Validate text elements don't have backgrounds, borders, or shadows
538 if (textTags.includes(el.tagName)) {
539 const computed = window.getComputedStyle(el);
540 const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
541 const hasBorder = (computed.borderWidth && parseFloat(computed.borderWidth) > 0) ||
542 (computed.borderTopWidth && parseFloat(computed.borderTopWidth) > 0) ||
543 (computed.borderRightWidth && parseFloat(computed.borderRightWidth) > 0) ||
544 (computed.borderBottomWidth && parseFloat(computed.borderBottomWidth) > 0) ||
545 (computed.borderLeftWidth && parseFloat(computed.borderLeftWidth) > 0);
546 const hasShadow = computed.boxShadow && computed.boxShadow !== 'none';
547
548 if (hasBg || hasBorder || hasShadow) {
549 errors.push(
550 `Text element <${el.tagName.toLowerCase()}> has ${hasBg ? 'background' : hasBorder ? 'border' : 'shadow'}. ` +
551 'Backgrounds, borders, and shadows are only supported on <div> elements, not text elements.'
552 );
553 return;
554 }
555 }
556
557 // Extract placeholder elements (for charts, etc.)
558 if (el.className && el.className.includes('placeholder')) {
559 const rect = el.getBoundingClientRect();
560 if (rect.width === 0 || rect.height === 0) {
561 errors.push(
562 `Placeholder "${el.id || 'unnamed'}" has ${rect.width === 0 ? 'width: 0' : 'height: 0'}. Check the layout CSS.`
563 );
564 } else {
565 placeholders.push({
566 id: el.id || `placeholder-${placeholders.length}`,
567 x: pxToInch(rect.left),
568 y: pxToInch(rect.top),
569 w: pxToInch(rect.width),
570 h: pxToInch(rect.height)
571 });
572 }
573 processed.add(el);
574 return;
575 }
576
577 // Extract images
578 if (el.tagName === 'IMG') {
579 const rect = el.getBoundingClientRect();
580 if (rect.width > 0 && rect.height > 0) {
581 elements.push({
582 type: 'image',
583 src: el.src,
584 position: {
585 x: pxToInch(rect.left),
586 y: pxToInch(rect.top),
587 w: pxToInch(rect.width),
588 h: pxToInch(rect.height)
589 }
590 });
591 processed.add(el);
592 return;
593 }
594 }
595
596 // Extract DIVs with backgrounds/borders as shapes
597 const isContainer = el.tagName === 'DIV' && !textTags.includes(el.tagName);
598 if (isContainer) {
599 const computed = window.getComputedStyle(el);
600 const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
601
602 // Validate: Check for unwrapped text content in DIV
603 for (const node of el.childNodes) {
604 if (node.nodeType === Node.TEXT_NODE) {
605 const text = node.textContent.trim();
606 if (text) {
607 errors.push(
608 `DIV element contains unwrapped text "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}". ` +
609 'All text must be wrapped in <p>, <h1>-<h6>, <ul>, or <ol> tags to appear in PowerPoint.'
610 );
611 }
612 }
613 }
614
615 // Check for background images on shapes
616 const bgImage = computed.backgroundImage;
617 if (bgImage && bgImage !== 'none') {
618 errors.push(
619 'Background images on DIV elements are not supported. ' +
620 'Use solid colors or borders for shapes, or use slide.addImage() in PptxGenJS to layer images.'
621 );
622 return;
623 }
624
625 // Check for borders - both uniform and partial
626 const borderTop = computed.borderTopWidth;
627 const borderRight = computed.borderRightWidth;
628 const borderBottom = computed.borderBottomWidth;
629 const borderLeft = computed.borderLeftWidth;
630 const borders = [borderTop, borderRight, borderBottom, borderLeft].map(b => parseFloat(b) || 0);
631 const hasBorder = borders.some(b => b > 0);
632 const hasUniformBorder = hasBorder && borders.every(b => b === borders[0]);
633 const borderLines = [];
634
635 if (hasBorder && !hasUniformBorder) {
636 const rect = el.getBoundingClientRect();
637 const x = pxToInch(rect.left);
638 const y = pxToInch(rect.top);
639 const w = pxToInch(rect.width);
640 const h = pxToInch(rect.height);
641
642 // Collect lines to add after shape (inset by half the line width to center on edge)
643 if (parseFloat(borderTop) > 0) {
644 const widthPt = pxToPoints(borderTop);
645 const inset = (widthPt / 72) / 2; // Convert points to inches, then half
646 borderLines.push({
647 type: 'line',
648 x1: x, y1: y + inset, x2: x + w, y2: y + inset,
649 width: widthPt,
650 color: rgbToHex(computed.borderTopColor)
651 });
652 }
653 if (parseFloat(borderRight) > 0) {
654 const widthPt = pxToPoints(borderRight);
655 const inset = (widthPt / 72) / 2;
656 borderLines.push({
657 type: 'line',
658 x1: x + w - inset, y1: y, x2: x + w - inset, y2: y + h,
659 width: widthPt,
660 color: rgbToHex(computed.borderRightColor)
661 });
662 }
663 if (parseFloat(borderBottom) > 0) {
664 const widthPt = pxToPoints(borderBottom);
665 const inset = (widthPt / 72) / 2;
666 borderLines.push({
667 type: 'line',
668 x1: x, y1: y + h - inset, x2: x + w, y2: y + h - inset,
669 width: widthPt,
670 color: rgbToHex(computed.borderBottomColor)
671 });
672 }
673 if (parseFloat(borderLeft) > 0) {
674 const widthPt = pxToPoints(borderLeft);
675 const inset = (widthPt / 72) / 2;
676 borderLines.push({
677 type: 'line',
678 x1: x + inset, y1: y, x2: x + inset, y2: y + h,
679 width: widthPt,
680 color: rgbToHex(computed.borderLeftColor)
681 });
682 }
683 }
684
685 if (hasBg || hasBorder) {
686 const rect = el.getBoundingClientRect();
687 if (rect.width > 0 && rect.height > 0) {
688 const shadow = parseBoxShadow(computed.boxShadow);
689
690 // Only add shape if there's background or uniform border
691 if (hasBg || hasUniformBorder) {
692 elements.push({
693 type: 'shape',
694 text: '', // Shape only - child text elements render on top
695 position: {
696 x: pxToInch(rect.left),
697 y: pxToInch(rect.top),
698 w: pxToInch(rect.width),
699 h: pxToInch(rect.height)
700 },
701 shape: {
702 fill: hasBg ? rgbToHex(computed.backgroundColor) : null,
703 transparency: hasBg ? extractAlpha(computed.backgroundColor) : null,
704 line: hasUniformBorder ? {
705 color: rgbToHex(computed.borderColor),
706 width: pxToPoints(computed.borderWidth)
707 } : null,
708 // Convert border-radius to rectRadius (in inches)
709 // % values: 50%+ = circle (1), <50% = percentage of min dimension
710 // pt values: divide by 72 (72pt = 1 inch)
711 // px values: divide by 96 (96px = 1 inch)
712 rectRadius: (() => {
713 const radius = computed.borderRadius;
714 const radiusValue = parseFloat(radius);
715 if (radiusValue === 0) return 0;
716
717 if (radius.includes('%')) {
718 if (radiusValue >= 50) return 1;
719 // Calculate percentage of smaller dimension
720 const minDim = Math.min(rect.width, rect.height);
721 return (radiusValue / 100) * pxToInch(minDim);
722 }
723
724 if (radius.includes('pt')) return radiusValue / 72;
725 return radiusValue / PX_PER_IN;
726 })(),
727 shadow: shadow
728 }
729 });
730 }
731
732 // Add partial border lines
733 elements.push(...borderLines);
734
735 processed.add(el);
736 return;
737 }
738 }
739 }
740
741 // Extract bullet lists as single text block
742 if (el.tagName === 'UL' || el.tagName === 'OL') {
743 const rect = el.getBoundingClientRect();
744 if (rect.width === 0 || rect.height === 0) return;
745
746 const liElements = Array.from(el.querySelectorAll('li'));
747 const items = [];
748 const ulComputed = window.getComputedStyle(el);
749 const ulPaddingLeftPt = pxToPoints(ulComputed.paddingLeft);
750
751 // Split: margin-left for bullet position, indent for text position
752 // margin-left + indent = ul padding-left
753 const marginLeft = ulPaddingLeftPt * 0.5;
754 const textIndent = ulPaddingLeftPt * 0.5;
755
756 liElements.forEach((li, idx) => {
757 const isLast = idx === liElements.length - 1;
758 const runs = parseInlineFormatting(li, { breakLine: false });
759 // Clean manual bullets from first run
760 if (runs.length > 0) {
761 runs[0].text = runs[0].text.replace(/^[•\-\*▪▸]\s*/, '');
762 runs[0].options.bullet = { indent: textIndent };
763 }
764 // Set breakLine on last run
765 if (runs.length > 0 && !isLast) {
766 runs[runs.length - 1].options.breakLine = true;
767 }
768 items.push(...runs);
769 });
770
771 const computed = window.getComputedStyle(liElements[0] || el);
772
773 elements.push({
774 type: 'list',
775 items: items,
776 position: {
777 x: pxToInch(rect.left),
778 y: pxToInch(rect.top),
779 w: pxToInch(rect.width),
780 h: pxToInch(rect.height)
781 },
782 style: {
783 fontSize: pxToPoints(computed.fontSize),
784 fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
785 color: rgbToHex(computed.color),
786 transparency: extractAlpha(computed.color),
787 align: computed.textAlign === 'start' ? 'left' : computed.textAlign,
788 lineSpacing: computed.lineHeight && computed.lineHeight !== 'normal' ? pxToPoints(computed.lineHeight) : null,
789 paraSpaceBefore: 0,
790 paraSpaceAfter: pxToPoints(computed.marginBottom),
791 // PptxGenJS margin array is [left, right, bottom, top]
792 margin: [marginLeft, 0, 0, 0]
793 }
794 });
795
796 liElements.forEach(li => processed.add(li));
797 processed.add(el);
798 return;
799 }
800
801 // Extract text elements (P, H1, H2, etc.)
802 if (!textTags.includes(el.tagName)) return;
803
804 const rect = el.getBoundingClientRect();
805 const text = el.textContent.trim();
806 if (rect.width === 0 || rect.height === 0 || !text) return;
807
808 // Validate: Check for manual bullet symbols in text elements (not in lists)
809 if (el.tagName !== 'LI' && /^[•\-\*▪▸○●◆◇■□]\s/.test(text.trimStart())) {
810 errors.push(
811 `Text element <${el.tagName.toLowerCase()}> starts with bullet symbol "${text.substring(0, 20)}...". ` +
812 'Use <ul> or <ol> lists instead of manual bullet symbols.'
813 );
814 return;
815 }
816
817 const computed = window.getComputedStyle(el);
818 const rotation = getRotation(computed.transform, computed.writingMode);
819 const { x, y, w, h } = getPositionAndSize(el, rect, rotation);
820
821 const baseStyle = {
822 fontSize: pxToPoints(computed.fontSize),
823 fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(),
824 color: rgbToHex(computed.color),
825 align: computed.textAlign === 'start' ? 'left' : computed.textAlign,
826 lineSpacing: pxToPoints(computed.lineHeight),
827 paraSpaceBefore: pxToPoints(computed.marginTop),
828 paraSpaceAfter: pxToPoints(computed.marginBottom),
829 // PptxGenJS margin array is [left, right, bottom, top] (not [top, right, bottom, left] as documented)
830 margin: [
831 pxToPoints(computed.paddingLeft),
832 pxToPoints(computed.paddingRight),
833 pxToPoints(computed.paddingBottom),
834 pxToPoints(computed.paddingTop)
835 ]
836 };
837
838 const transparency = extractAlpha(computed.color);
839 if (transparency !== null) baseStyle.transparency = transparency;
840
841 if (rotation !== null) baseStyle.rotate = rotation;
842
843 const hasFormatting = el.querySelector('b, i, u, strong, em, span, br');
844
845 if (hasFormatting) {
846 // Text with inline formatting
847 const transformStr = computed.textTransform;
848 const runs = parseInlineFormatting(el, {}, [], (str) => applyTextTransform(str, transformStr));
849
850 // Adjust lineSpacing based on largest fontSize in runs
851 const adjustedStyle = { ...baseStyle };
852 if (adjustedStyle.lineSpacing) {
853 const maxFontSize = Math.max(
854 adjustedStyle.fontSize,
855 ...runs.map(r => r.options?.fontSize || 0)
856 );
857 if (maxFontSize > adjustedStyle.fontSize) {
858 const lineHeightMultiplier = adjustedStyle.lineSpacing / adjustedStyle.fontSize;
859 adjustedStyle.lineSpacing = maxFontSize * lineHeightMultiplier;
860 }
861 }
862
863 elements.push({
864 type: el.tagName.toLowerCase(),
865 text: runs,
866 position: { x: pxToInch(x), y: pxToInch(y), w: pxToInch(w), h: pxToInch(h) },
867 style: adjustedStyle
868 });
869 } else {
870 // Plain text - inherit CSS formatting
871 const textTransform = computed.textTransform;
872 const transformedText = applyTextTransform(text, textTransform);
873
874 const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600;
875
876 elements.push({
877 type: el.tagName.toLowerCase(),
878 text: transformedText,
879 position: { x: pxToInch(x), y: pxToInch(y), w: pxToInch(w), h: pxToInch(h) },
880 style: {
881 ...baseStyle,
882 bold: isBold && !shouldSkipBold(computed.fontFamily),
883 italic: computed.fontStyle === 'italic',
884 underline: computed.textDecoration.includes('underline')
885 }
886 });
887 }
888
889 processed.add(el);
890 });
891
892 return { background, elements, placeholders, errors };
893 });
894}
895
896async function html2pptx(htmlFile, pres, options = {}) {
897 const {
898 tmpDir = process.env.TMPDIR || '/tmp',
899 slide = null
900 } = options;
901
902 try {
903 // Use Chrome on macOS, default Chromium on Unix
904 const launchOptions = { env: { TMPDIR: tmpDir } };
905 if (process.platform === 'darwin') {
906 launchOptions.channel = 'chrome';
907 }
908
909 const browser = await chromium.launch(launchOptions);
910
911 let bodyDimensions;
912 let slideData;
913
914 const filePath = path.isAbsolute(htmlFile) ? htmlFile : path.join(process.cwd(), htmlFile);
915 const validationErrors = [];
916
917 try {
918 const page = await browser.newPage();
919 page.on('console', (msg) => {
920 // Log the message text to your test runner's console
921 console.log(`Browser console: ${msg.text()}`);
922 });
923
924 await page.goto(`file://${filePath}`);
925
926 bodyDimensions = await getBodyDimensions(page);
927
928 await page.setViewportSize({
929 width: Math.round(bodyDimensions.width),
930 height: Math.round(bodyDimensions.height)
931 });
932
933 slideData = await extractSlideData(page);
934 } finally {
935 await browser.close();
936 }
937
938 // Collect all validation errors
939 if (bodyDimensions.errors && bodyDimensions.errors.length > 0) {
940 validationErrors.push(...bodyDimensions.errors);
941 }
942
943 const dimensionErrors = validateDimensions(bodyDimensions, pres);
944 if (dimensionErrors.length > 0) {
945 validationErrors.push(...dimensionErrors);
946 }
947
948 const textBoxPositionErrors = validateTextBoxPosition(slideData, bodyDimensions);
949 if (textBoxPositionErrors.length > 0) {
950 validationErrors.push(...textBoxPositionErrors);
951 }
952
953 if (slideData.errors && slideData.errors.length > 0) {
954 validationErrors.push(...slideData.errors);
955 }
956
957 // Throw all errors at once if any exist
958 if (validationErrors.length > 0) {
959 const errorMessage = validationErrors.length === 1
960 ? validationErrors[0]
961 : `Multiple validation errors found:\n${validationErrors.map((e, i) => ` ${i + 1}. ${e}`).join('\n')}`;
962 throw new Error(errorMessage);
963 }
964
965 const targetSlide = slide || pres.addSlide();
966
967 await addBackground(slideData, targetSlide, tmpDir);
968 addElements(slideData, targetSlide, pres);
969
970 return { slide: targetSlide, placeholders: slideData.placeholders };
971 } catch (error) {
972 if (!error.message.startsWith(htmlFile)) {
973 throw new Error(`${htmlFile}: ${error.message}`);
974 }
975 throw error;
976 }
977}
978
979module.exports = html2pptx;