Files
AOS/skills/pptx/html2pptx1.js
T
Z User e45b68bb8b feat: IMAP mail sync with thread-to-contact matching (PRD §6.1)
Real IMAP connection via imapflow library:
- Connects to IMAP server (IMAPFlow, TLS on port 993)
- Fetches recent messages (configurable limit, default 20)
- Extracts: from, fromEmail, subject, date, snippet (200 chars)
- Thread-to-contact matching (PRD §6.1):
  1. Match sender email against persons table
  2. If no person match, extract domain and match against company.website
  3. Returns matchedCompany + matchedPerson for each email
- Audit-logged (action: crm.mailsync:imap)
- Zod validated (host, port, user, password, folder, limit)
- 502 on IMAP connection failure with detail
- 400 on validation failure

Tested: invalid creds → 502 'IMAP connection failed', missing host → 400
2026-10-27 22:50:50 +00:00

5046 lines
255 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* html2pptx v4 — Convert HTML slide to pptxgenjs slide with positioned elements
*
* v4 Changes (2026-05-07) — broader & more faithful HTML coverage:
* ── NEW ELEMENT SUPPORT ─────────────────────────────────────────
* - <table>: full table extraction with cells, header row, alignment & basic styling
* - <hr>: extracted as a horizontal line shape
* - <svg>: inline SVG serialized & rasterized to PNG, then placed as image
* - <a>: hyperlink preserved on text runs (url + tooltip)
* - <sub>/<sup>: rendered with PptxGenJS subscript/superscript run option
* ── COLOR / FILL ────────────────────────────────────────────────
* - HSL / HSLA color parsing in addition to RGB / RGBA / hex / named
* - Linear gradients now emit real PptxGenJS gradient fill on shapes (best-effort)
* - background-image: url() on containers preserved as a layered image (under text)
* ── SHAPE / IMAGE ───────────────────────────────────────────────
* - border-radius: 50% on square boxes → ellipse (oval) shape
* - <img> object-fit: cover/contain/fill respected via sizing & cropping
* - <img> border-radius now produces a rounded image clip (approximation)
* - <img> opacity / box-shadow forwarded to the inserted picture
* ── TEXT ────────────────────────────────────────────────────────
* - text-shadow → run-level glow / shadow approximation
* - text-align: justify mapped to PptxGenJS 'justify'
* - per-run text-decoration: line-through preserved as strike
* ── ROBUSTNESS ──────────────────────────────────────────────────
* - z-index 'auto' no longer collapses to 0 incorrectly
* - placeholders carry z-index so chart insertions layer correctly
* - color parsing falls back gracefully on unknown values
* - non-finite numeric CSS values default safely
* - <iframe>, <video>, <audio>, <canvas> are silently skipped (won't error)
*
* v3 features carried forward:
* - Smart font mapping (PPT-safe pass-through, macOS/web fonts auto-mapped)
* - Adaptive width / height compensation for text frames
* - Element boundary, font size, overlap, vertical-balance validation
*
* USAGE:
* const pptx = new pptxgen();
* pptx.layout = 'LAYOUT_16x9';
* const { slide, placeholders, warnings } = await html2pptx('slide.html', pptx, { fontConfig });
* // warnings prefixed with 🚨 CRITICAL must be fixed before finalizing
* await pptx.writeFile('output.pptx');
*
* VALIDATION (all non-blocking — returned in warnings array):
* - 🚨 CRITICAL OVERFLOW: HTML content overflows slide canvas by >12pt
* - ⚠ BOUNDS: element extends beyond slide edges after PPT compensation
* - ⚠ FONT: text element below minimum font size (11pt)
* - ⚠ OVERLAP: two text elements overlap each other
* - ⚠ LAYOUT: content clustered in top portion of slide
*
* RETURNS:
* { slide, placeholders, warnings } where placeholders is an array of { id, x, y, w, h, zIndex }
*/
const { chromium } = require('playwright');
const path = require('path');
const sharp = require('sharp');
const fs = require('fs');
const http = require('http');
const https = require('https');
const PT_PER_PX = 0.75;
const PX_PER_IN = 96;
const EMU_PER_IN = 914400;
const DEFAULT_FONT_CONFIG = {
cjk: "Microsoft YaHei",
latin: "Carlito",
emphasis: "Liberation Sans Narrow",
display: "Microsoft YaHei",
symbol: "Segoe UI Symbol"
};
// ── v3: Compensation factors ──
const COMPENSATION = {
HEADING_WIDTH: 0.10, SINGLE_LINE_NARROW: 0.10, SINGLE_LINE_NORMAL: 0.10,
MULTI_LINE: 0.05, SHORT_TEXT_EXTRA: 0.18, NUMERIC_TEXT_EXTRA: 0.08,
// Very-short text (1–5 chars) — single labels like "学", "Apply", "→"
// are the most likely to wrap when a wider PPT font substitutes the
// browser-measured glyph. Stack this on top of SHORT_TEXT_EXTRA.
VERY_SHORT_TEXT_EXTRA: 0.30,
NOWRAP_EXTRA: 0.15, MAX_WIDTH_FACTOR: 0.60,
TEXT_HEIGHT: 0.08, LIST_HEIGHT: 0.06,
MIN_FONT_SIZE_PT: 11,
VERTICAL_BALANCE_THRESHOLD: 0.65, OVERLAP_TOLERANCE_IN: 0.05, BOUNDS_TOLERANCE_IN: 0.02,
AUTO_SHORT_TEXT_THRESHOLD: 22, // chars — auto-detect short text even without explicit nowrap
};
// ── v3: Validation helpers (Node.js scope) ──
function extractTextContent(el) {
if (typeof el.text === 'string') return el.text;
if (Array.isArray(el.text)) return el.text.map(r => r.text || '').join('');
if (Array.isArray(el.items)) return el.items.map(r => r.text || '').join('');
// Tables: flatten cells across all rows
if (Array.isArray(el.rows)) {
return el.rows.map(row =>
row.map(cell => typeof cell === 'string' ? cell : (cell?.text || '')).join(' ')
).join(' ');
}
return '';
}
function getElementLabel(el) {
const t = extractTextContent(el);
return t.substring(0, 40) + (t.length > 40 ? '...' : '') || `[${el.type}]`;
}
function getListItemCount(el) {
const styleCount = Number(el.style?._itemCount);
if (Number.isFinite(styleCount) && styleCount > 0) return styleCount;
if (!Array.isArray(el.items) || el.items.length === 0) return 0;
return 1 + el.items.filter(r => r?.options?.breakLine).length;
}
function getListSpacingHeightBonus(el) {
if (el.type !== 'list') return 0;
const itemCount = getListItemCount(el);
const afterPt = Number(el.style?.paraSpaceAfter) || 0;
if (itemCount <= 1 || afterPt <= 0) return 0;
// The source list rect already includes the inter-item gap. PPT may still
// reserve paragraph spacing after the final item, so keep one gap as bottom
// headroom rather than shifting the list down.
return afterPt / 72;
}
function clampHeightToNextTextBound(el, height) {
const nextTop = Number(el.style?._nextTextTopBound);
if (!Number.isFinite(nextTop) || nextTop <= el.position.y) return height;
// Keep a visible gutter before the next normal-flow text block. This catches
// card body -> kicker stacks where PPT text metrics make the body box taller
// than the browser-measured gap, e.g. timeline milestone descriptions.
const maxHeight = nextTop - el.position.y - 0.03;
if (maxHeight < 0.08) return height;
return Math.min(height, maxHeight);
}
function isNarrowBodyTextForPptCompensation(el) {
const txt = extractTextContent(el).replace(/\s+/g, ' ').trim();
if (!txt || hasExplicitTextBreaks(el)) return false;
const fs = Number(el.style?.fontSize) || 0;
return txt.length >= 35 && txt.length <= 160 && fs <= 14 && el.position.w < 1.7;
}
function getNarrowBodyTextClipSlack(el) {
if (!isNarrowBodyTextForPptCompensation(el)) return 0;
return Math.min(0.10, Math.max(0.04, el.position.w * 0.06));
}
function isMediumSingleLineLabelForPptCompensation(el) {
const txt = extractTextContent(el).replace(/\s+/g, ' ').trim();
if (!txt || hasExplicitTextBreaks(el)) return false;
const compactLen = txt.replace(/\s+/g, '').length;
if (compactLen < 23 || compactLen > 42) return false;
const fs = Number(el.style?.fontSize) || 16;
if (fs < 8 || fs > 18) return false;
const lh = el.style?.lineSpacing || fs * 1.2;
const heightSingle = el.position.h <= lh * 1.7 / 72;
if (!heightSingle) return false;
const boxWidthPt = Math.max(1, (el.position?.w || 0) * 72);
return estimateTextWidthPt(txt, fs) <= boxWidthPt * 1.55;
}
function getMediumSingleLineClipSlack(el) {
if (!isMediumSingleLineLabelForPptCompensation(el)) return 0;
return Math.min(0.12, Math.max(0.045, el.position.w * 0.06));
}
// Compute PPT-adjusted position for text/list elements (mirrors addElements logic, pre-clamp)
function getAdjustedTextPosition(el, slideWidthIn) {
const MAX_TEXT_WIDTH_IN = 680 / 72;
const isRotatedText = el.style?.rotate !== undefined;
const widthFactor = isRotatedText ? 0 : calculateWidthCompensation(el, slideWidthIn);
const widthIncrease = el.position.w * widthFactor;
let adjustedX = el.position.x;
let adjustedW = el.position.w;
const align = el.style?.align;
if (align === 'center') { adjustedX -= widthIncrease / 2; adjustedW += widthIncrease; }
else if (align === 'right') { adjustedX -= widthIncrease; adjustedW += widthIncrease; }
else { adjustedW += widthIncrease; }
if (align === 'center' && /^h[1-6]$/.test(el.type)) {
const centerX = adjustedX + adjustedW / 2;
const margin = 0.3;
const maxExpand = Math.min(centerX - margin, slideWidthIn - centerX - margin);
if (maxExpand > adjustedW / 2) { adjustedX = centerX - maxExpand; adjustedW = maxExpand * 2; }
}
const hComp = el.type === 'list' ? COMPENSATION.LIST_HEIGHT : COMPENSATION.TEXT_HEIGHT;
const finalW = isRotatedText ? adjustedW : Math.min(adjustedW, MAX_TEXT_WIDTH_IN);
let finalH = el.position.h * (1 + hComp) + getListSpacingHeightBonus(el);
finalH = clampHeightToNextTextBound(el, finalH);
return { x: adjustedX, y: el.position.y, w: finalW, h: finalH };
}
function getVisualCheckPosition(el, p) {
const rot = ((el.style?.rotate || 0) % 360 + 360) % 360;
if (rot !== 90 && rot !== 270) return p;
const cx = p.x + p.w / 2;
const cy = p.y + p.h / 2;
return { x: cx - p.h / 2, y: cy - p.w / 2, w: p.h, h: p.w };
}
function checkElementBounds(slideData, sw, sh) {
const w = []; const tol = COMPENSATION.BOUNDS_TOLERANCE_IN;
const textTypes = new Set(['p','h1','h2','h3','h4','h5','h6','list']);
// Heavy-watermark text (≥80% transparent OR font-size ≥150pt) is intentionally
// bleeding off-canvas — skip bounds warnings for it to avoid noise on cover slides.
const isDecorative = (el) => {
const t = el.style?.transparency;
if (typeof t === 'number' && t >= 80) return true;
const fs_ = el.style?.fontSize || 0;
if (fs_ >= 150) return true;
return false;
};
for (const el of slideData.elements) {
if (!el.position) continue;
if (textTypes.has(el.type) && isDecorative(el)) continue;
const rawP = textTypes.has(el.type) ? getAdjustedTextPosition(el, sw) : el.position;
const p = textTypes.has(el.type) ? getVisualCheckPosition(el, rawP) : rawP;
if (p.x < -tol) w.push(`⚠ BOUNDS: "${getElementLabel(el)}" extends ${(-p.x*72).toFixed(0)}pt beyond LEFT`);
if (p.y < -tol) w.push(`⚠ BOUNDS: "${getElementLabel(el)}" extends ${(-p.y*72).toFixed(0)}pt beyond TOP`);
// 已禁用:右侧溢出(beyond RIGHT)告警噪声较大,按需关闭
// if (p.x+p.w > sw+tol) w.push(`⚠ BOUNDS: "${getElementLabel(el)}" extends ${((p.x+p.w-sw)*72).toFixed(0)}pt beyond RIGHT`);
if (p.y+p.h > sh+tol) w.push(`⚠ BOUNDS: "${getElementLabel(el)}" extends ${((p.y+p.h-sh)*72).toFixed(0)}pt beyond BOTTOM`);
}
return w;
}
function checkTextOverlaps(slideData) {
const w = [];
const te = slideData.elements.filter(e => ['p','h1','h2','h3','h4','h5','h6','list'].includes(e.type));
// An element is "decorative" if it is essentially invisible — heavily transparent
// and/or very large (like a 600px watermark character). Such elements are intentionally
// placed under foreground content and should not trigger overlap warnings.
const isDecorative = (el) => {
const t = el.style?.transparency;
if (typeof t === 'number' && t >= 80) return true; // ≥80% transparent → watermark-like
const fs_ = el.style?.fontSize || 0;
if (fs_ >= 150) return true; // absurdly large font → watermark / ghost numeral
return false;
};
for (let i = 0; i < te.length; i++) {
for (let j = i + 1; j < te.length; j++) {
// Skip intentional z-stacking (ghost numbers, decorative overlays, etc.)
if (te[i].zIndex !== te[j].zIndex) continue;
// Skip overlaps that involve a decorative (watermark-like) element on either side
if (isDecorative(te[i]) || isDecorative(te[j])) continue;
const a = getVisualCheckPosition(te[i], te[i].position);
const b = getVisualCheckPosition(te[j], te[j].position);
const tol = COMPENSATION.OVERLAP_TOLERANCE_IN;
const ow = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
const oh = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
if (ow > tol && oh > tol) {
// 已禁用:文本重叠(OVERLAP)告警噪声较大,按需关闭
// w.push(`⚠ OVERLAP: "${getElementLabel(te[i])}" overlaps "${getElementLabel(te[j])}"`);
}
}
}
return w;
}
function hasExplicitTextBreaks(el) {
if (typeof el.text === 'string') return el.text.includes('\n');
if (Array.isArray(el.text)) return el.text.some(r => (r.text && r.text.includes('\n')) || r.options?.breakLine);
return false;
}
function estimateTextWidthPt(text, fontSize) {
let units = 0;
for (const ch of Array.from(text || '')) {
if (/\s/.test(ch)) units += 0.28;
else if (/[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(ch)) units += 1.0;
else if (/[A-Z0-9]/.test(ch)) units += 0.62;
else if (/[a-z]/.test(ch)) units += 0.52;
else units += 0.45;
}
return units * (fontSize || 16);
}
function isShortAutoNoWrapText(el) {
const txt = extractTextContent(el).replace(/\s+/g, ' ').trim();
if (!txt || hasExplicitTextBreaks(el)) return false;
const compactTxt = txt.replace(/\s+/g, '');
if (compactTxt.length === 0 || compactTxt.length > COMPENSATION.AUTO_SHORT_TEXT_THRESHOLD) {
return false;
}
const fs = Number(el.style?.fontSize) || 16;
const boxWidthPt = Math.max(1, (el.position?.w || 0) * 72);
return estimateTextWidthPt(txt, fs) <= boxWidthPt * 1.15;
}
function calculateWidthCompensation(el, slideWidthIn) {
if (el.style?._isIcon) return 0;
const isH = /^h[1-6]$/.test(el.type);
const txt = extractTextContent(el);
const lh = el.style?.lineSpacing || (el.style?.fontSize||16)*1.2;
// Detect single-line text: either the box is narrow enough to fit only one
// line of text, OR the source text has no explicit breaks (no '\n' from <br>
// and no breakLine flag on any run). The height-only heuristic misses cases
// where padding inflates the box (e.g. `<div class="px-3 py-1.5">label</div>`),
// causing under-compensation and visible wrapping when PPT substitutes a
// wider font (Inter→Carlito etc.).
const heightSingle = el.position.h <= lh*1.5/72;
const noExplicitBreaks =
(typeof el.text === 'string' && !el.text.includes('\n')) ||
(Array.isArray(el.text) && !el.text.some(r => (r.text && r.text.includes('\n')) || r.options?.breakLine));
// The noExplicitBreaks signal alone is unreliable: a long paragraph with no
// <br> still wraps naturally, but would be misclassified as single-line and
// get the wider compensation, overflowing its parent (e.g. a card column).
// Guard by also estimating whether the text could plausibly fit on one line
// at the measured box width.
const fs_est = el.style?.fontSize || 16;
const boxWidthPt = el.position.w * 72;
const estTextWidthPt = estimateTextWidthPt(txt, fs_est);
const couldFitOneLine = estTextWidthPt <= boxWidthPt * 1.2;
const single = heightSingle || (noExplicitBreaks && couldFitOneLine);
// Width compensation is mostly limited to short labels/titles. Long
// paragraphs usually keep their browser-measured width, except for narrow
// card body copy where PPT font substitution can add one avoidable wrap.
const compactTxt = txt.replace(/\s+/g, '');
const isShortText = compactTxt.length > 0 && compactTxt.length <= COMPENSATION.AUTO_SHORT_TEXT_THRESHOLD;
if (!isShortText || !single) {
let f0 = 0;
if (isNarrowBodyTextForPptCompensation(el)) f0 = Math.max(f0, 0.06);
if (isMediumSingleLineLabelForPptCompensation(el)) f0 = Math.max(f0, 0.08);
// Single-line headings / large titles longer than the short-text threshold
// are still prone to a spurious extra wrap: the browser box is tight to the
// measured glyph run, and PPT's substitute font (Inter→Carlito, etc.) is a
// few percent wider, so the last word drops to a new line. Give these a
// modest widen. Downstream clip/slide clamping (see getAdjustedTextPosition
// and addElements) keeps the wider box from pushing into neighbours.
const fsHere = el.style?.fontSize || 0;
if (single && !el.noWrap && (isH || fsHere >= 18)) {
f0 = Math.max(f0, isH ? COMPENSATION.HEADING_WIDTH : COMPENSATION.HEADING_WIDTH * 0.8);
}
// Single-line labels with pronounced letter-spacing (all-caps footers,
// kickers, "EMPTY-NEST · DEFINITION · DATA" style captions) keep a box that
// is tight to the browser-measured glyph run. pptxgenjs applies charSpacing
// per glyph gap, so PPT's wider substitute font plus the tracking pushes the
// final token onto a 2nd line. The per-char letter-spacing bonus later in
// this function only runs for short text, so long tracked single-liners
// would otherwise fall through and wrap. Widen proportionally to the
// tracking ratio and run length, but only when the text plausibly already
// fits one line (est width — which ignores tracking — is within the measured
// box), so genuinely-wrapping paragraphs are never widened. Take the max
// with the weaker medium-label/narrow-body factors above rather than
// returning early, so a tracked medium label still gets enough room.
if (single && !el.noWrap && fsHere > 0) {
const csHere = el.style?.charSpacing || 0;
const ratio = csHere / fsHere;
if (ratio > 0.08) {
const boxWidthPt = (el.position?.w || 0) * 72;
if (boxWidthPt > 0 && estimateTextWidthPt(txt, fsHere) <= boxWidthPt * 1.3) {
const lenBoost = Math.min(1.6, 0.6 + compactTxt.length / 40);
f0 = Math.max(f0, Math.min(0.30, ratio * 0.9 * lenBoost));
}
}
}
return f0;
}
let f = isH ? COMPENSATION.HEADING_WIDTH : (el.position.w < slideWidthIn/3 && txt.length < 14 ? COMPENSATION.SINGLE_LINE_NARROW : COMPENSATION.SINGLE_LINE_NORMAL);
if (txt.length>0 && txt.length<10) f += COMPENSATION.SHORT_TEXT_EXTRA;
// Extra punch for 1–5 char labels — these are the worst offenders for
// wrapping when PPT swaps to a wider font (e.g. Inter→Carlito on "Apply"
// or YaHei→Noto on "学").
if (txt.length > 0 && txt.length <= 5) f += COMPENSATION.VERY_SHORT_TEXT_EXTRA;
if (/^[\d\s\.\,\-\/\+\%\$\#\@\!\?\:\;\(\)\[\]]+$/.test(txt)) f += COMPENSATION.NUMERIC_TEXT_EXTRA;
if (el.noWrap) f += COMPENSATION.NOWRAP_EXTRA;
// v4: Auto-detect short text that should not wrap even without explicit nowrap.
// Threshold widened to 22 chars so labels like "No. 07 · Spring Term" (19) and
// "Lorem ipsum dolor" (17) still get a wrap-prevention bonus.
// Bonus is intentionally modest (0.4×) — heavier compensation here
// over-inflates plain medium-length labels and makes them push neighbours.
// Styled cases (italic / letter-spaced) get extra bonuses lower in the function
// that stack on top of this baseline.
if (!el.noWrap && single && txt.length >= 10 && txt.length < COMPENSATION.AUTO_SHORT_TEXT_THRESHOLD) {
f += COMPENSATION.SHORT_TEXT_EXTRA * 0.4;
}
// v4: Auto-detect card titles (>=18pt, single line, <20 chars) — treat like heading
const fs_ = el.style?.fontSize || 16;
if (!isH && single && fs_ >= 18 && txt.length < 20) {
f = Math.max(f, COMPENSATION.HEADING_WIDTH * 0.8); // At least 80% of heading compensation
}
// Letter-spacing widens the rendered glyph run; PPT font substitution can
// turn that into wrapping when the browser-measured box was already snug.
// Add a proportional bonus when letter-spacing is a noticeable fraction of
// the font size. Apply across single/multi-line because spacing affects both.
const cs = el.style?.charSpacing || 0;
let hasLetterSpacing = false;
if (cs > 0) {
const ratio = cs / fs_;
if (ratio > 0.02) {
f += Math.min(ratio * 1.0, 0.22);
hasLetterSpacing = true;
}
}
// Italic widens the rendered run vs. the upright browser metric (PPT often
// synthesises italic from the regular face). Tag for the safety guard below.
const isItalic = el.style?.italic === true ||
(Array.isArray(el.text) && el.text.some(r => r.options?.italic));
if (single && isItalic) f += 0.04;
// Already-wide single-line boxes don't usually need full width compensation —
// their parent (card, column, etc.) is tight to the measured content, and
// adding 10% on top can push the text past the container edge. Reduce
// (not zero) the compensation for those cases. Skip the reduction entirely
// when letter-spacing or italic is present — those genuinely need the
// extra width to avoid wrapping (e.g. "No. 07 · Spring Term", italic +
// 0.1em tracking, wraps without help).
if (single && el.position.w > 1.5 && txt.length >= 14 && !el.noWrap &&
!hasLetterSpacing && !isItalic) {
f = Math.min(f, 0.05);
}
return Math.min(f, COMPENSATION.MAX_WIDTH_FACTOR);
}
// ── Emphasis fonts (post-extraction, Node.js scope) ──
const hasCJKCharsNode = (text = "") => /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(text);
const hasDigitCharsNode = (text = "") => /[0-90-9]/.test(text);
const hasEmojiCharsNode = (text = "") => /[\u2600-\u27BF]|[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(text);
const hasIconSymbolCharsNode = (text = "") => /[\u2190-\u21FF\u2300-\u23FF\u2900-\u297F\u2460-\u24FF\u25A0-\u27BF\u2B00-\u2BFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(text);
function isIconOnlyTextNode(text = "") {
const t = String(text).trim();
if (!t) return false;
const compact = t.replace(/[\s\uFE0E\uFE0F\u200D\u20E3]/g, "");
if (!compact || Array.from(compact).length > 4) return false;
if (hasCJKCharsNode(compact) || /[A-Za-z0-90-9]/.test(compact)) return false;
return hasIconSymbolCharsNode(compact);
}
function shouldUseNumericEmphasisText(text = "") {
const t = String(text).trim();
if (!t || hasCJKCharsNode(t) || !hasDigitCharsNode(t)) return false;
if (t.length > 36) return false;
const compact = t.replace(/\s+/g, "");
const useful = compact.replace(/[^0-90-9A-Za-z.,:;_#%+\-\/()\[\]·]/g, "");
const ratio = useful.length / Math.max(1, compact.length);
const labelPrefix = /^(?:lv|no|honor|resource|step|part|section|chapter|page|slide|p|q|a|item|case|fig|table)\b/i.test(compact);
return ratio >= 0.65 || labelPrefix;
}
function applyEmphasisFont(slideData, fontConfig = DEFAULT_FONT_CONFIG) {
const cfg = typeof fontConfig === "string" ? { ...DEFAULT_FONT_CONFIG, emphasis: fontConfig } : { ...DEFAULT_FONT_CONFIG, ...(fontConfig || {}) };
for (const el of slideData.elements) {
if (!["p","h1","h2","h3","h4","h5","h6"].includes(el.type)) continue;
if (typeof el.text === "string") {
const trimmed = el.text.trim();
if (isIconOnlyTextNode(trimmed)) {
el.style.fontFace = cfg.symbol;
el.style._isIcon = true;
el.noWrap = true;
} else if (hasEmojiCharsNode(trimmed) && Array.from(trimmed).length <= 4) {
el.style.fontFace = cfg.symbol;
}
if (shouldUseNumericEmphasisText(trimmed)) el.style.fontFace = cfg.emphasis;
} else if (Array.isArray(el.text)) {
const wholeText = extractTextContent(el).trim();
if (isIconOnlyTextNode(wholeText)) {
el.style.fontFace = cfg.symbol;
el.style._isIcon = true;
el.noWrap = true;
}
for (const run of el.text) {
const trimmed = String(run.text || "").trim();
if (!trimmed) continue;
if (hasEmojiCharsNode(trimmed) && Array.from(trimmed).length <= 4) run.options.fontFace = cfg.symbol;
if (shouldUseNumericEmphasisText(trimmed)) run.options.fontFace = cfg.emphasis;
}
}
}
}
// Helper: Fix image path if file extension doesn't match actual format
function fixImageExtension(imagePath, tmpDir) {
try {
const fd = fs.openSync(imagePath, 'r');
const buf = Buffer.alloc(12);
fs.readSync(fd, buf, 0, 12, 0);
fs.closeSync(fd);
let actualExt = null;
if (buf[0] === 0xFF && buf[1] === 0xD8) actualExt = '.jpg';
else if (buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47) actualExt = '.png';
else if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) actualExt = '.gif';
else if (buf[0] === 0x52 && buf[1] === 0x49 && buf[8] === 0x57 && buf[9] === 0x45) actualExt = '.webp';
if (!actualExt) return imagePath;
const currentExt = path.extname(imagePath).toLowerCase();
if (currentExt === actualExt || (currentExt === '.jpeg' && actualExt === '.jpg') || (currentExt === '.jpg' && actualExt === '.jpeg')) {
return imagePath;
}
// Extension mismatch: copy with correct extension
const fixedPath = path.join(tmpDir, path.basename(imagePath, currentExt) + actualExt);
fs.copyFileSync(imagePath, fixedPath);
return fixedPath;
} catch (e) {
return imagePath;
}
}
// Helper: Convert any SVG data URLs in slideData.elements to PNG files on disk.
// pptxgenjs's addImage() rejects `data:image/svg+xml;...` URLs (PowerPoint's SVG
// support is partial, and pptxgenjs validates the data URL MIME type before
// embedding). We rasterize with sharp at 2× the slide density so the PNG looks
// crisp at typical zoom levels, then swap el.src to point at the temp PNG.
async function rasterizeSvgImages(slideData, tmpDir) {
if (!slideData || !Array.isArray(slideData.elements)) return;
// Collect tasks first so we can run them in parallel
const tasks = [];
for (const el of slideData.elements) {
if (el.type !== 'image' || !el.src) continue;
if (!el.src.startsWith('data:image/svg+xml')) continue;
tasks.push((async () => {
try {
// Strip the data URL prefix — handle both base64 and URL-encoded variants
let svgBuffer;
if (el.src.includes(';base64,')) {
const b64 = el.src.split(';base64,')[1];
svgBuffer = Buffer.from(b64, 'base64');
} else {
// data:image/svg+xml,<urlencoded>
const raw = el.src.replace(/^data:image\/svg\+xml,/, '');
svgBuffer = Buffer.from(decodeURIComponent(raw), 'utf8');
}
// Target raster size: 2× the placed inch size at 96 DPI = 192 DPI
const widthPx = Math.max(1, Math.round((el.position?.w || 1) * 192));
const heightPx = Math.max(1, Math.round((el.position?.h || 1) * 192));
const filename = `h2p-svg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const outPath = path.join(tmpDir, filename);
await sharp(svgBuffer, { density: 192 })
.resize(widthPx, heightPx, { fit: 'fill' })
.png()
.toFile(outPath);
el.src = outPath;
} catch (err) {
// Mark for skip so addElements doesn't try to embed a broken data URL
el._skipRender = true;
el._skipReason = `SVG rasterize failed: ${err.message}`;
}
})());
}
if (tasks.length > 0) await Promise.all(tasks);
}
function clampNumber(value, min, max) {
if (!Number.isFinite(value)) return min;
return Math.max(min, Math.min(max, value));
}
function parseCssObjectPosition(value) {
const pos = { x: 0.5, y: 0.5 };
if (!value || typeof value !== 'string') return pos;
const tokens = value.toLowerCase().trim().split(/\s+/).filter(Boolean);
const toFraction = (token, axis) => {
if (token === 'left') return axis === 'x' ? 0 : null;
if (token === 'right') return axis === 'x' ? 1 : null;
if (token === 'top') return axis === 'y' ? 0 : null;
if (token === 'bottom') return axis === 'y' ? 1 : null;
if (token === 'center') return 0.5;
if (/%$/.test(token)) return clampNumber(parseFloat(token) / 100, 0, 1);
return null;
};
let xSet = false;
let ySet = false;
for (const token of tokens) {
const xVal = toFraction(token, 'x');
const yVal = toFraction(token, 'y');
if (!xSet && xVal !== null && (token !== 'center' || !ySet)) {
pos.x = xVal;
xSet = true;
continue;
}
if (!ySet && yVal !== null) {
pos.y = yVal;
ySet = true;
}
}
if (tokens.length >= 2) {
const firstX = toFraction(tokens[0], 'x');
const firstY = toFraction(tokens[0], 'y');
const secondX = toFraction(tokens[1], 'x');
const secondY = toFraction(tokens[1], 'y');
if (firstX !== null && firstY === null) pos.x = firstX;
if (firstY !== null && firstX === null) pos.y = firstY;
if (secondY !== null && secondX === null) pos.y = secondY;
else if (secondX !== null && secondY === null) pos.x = secondX;
else if (secondX !== null && secondY !== null) pos.y = secondY;
}
return pos;
}
function decodeDataUrlToBuffer(dataUrl) {
const match = String(dataUrl).match(/^data:([^;,]+)?((?:;[^,]+)*),(.*)$/);
if (!match) throw new Error('invalid data URL');
const meta = match[2] || '';
const payload = match[3] || '';
if (meta.includes(';base64')) return Buffer.from(payload, 'base64');
return Buffer.from(decodeURIComponent(payload), 'utf8');
}
function downloadToBuffer(url, redirects = 4) {
return new Promise((resolve, reject) => {
const client = /^https:/i.test(url) ? https : http;
const req = client.get(url, res => {
const status = res.statusCode || 0;
const loc = res.headers.location;
if (status >= 300 && status < 400 && loc && redirects > 0) {
res.resume();
const nextUrl = new URL(loc, url).toString();
downloadToBuffer(nextUrl, redirects - 1).then(resolve, reject);
return;
}
if (status < 200 || status >= 300) {
res.resume();
reject(new Error(`HTTP ${status}`));
return;
}
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => resolve(Buffer.concat(chunks)));
});
req.on('error', reject);
req.setTimeout(20000, () => {
req.destroy(new Error('image download timed out'));
});
});
}
async function loadImageBuffer(src, cache) {
if (cache.has(src)) return cache.get(src);
let buffer;
if (/^data:/i.test(src)) {
buffer = decodeDataUrlToBuffer(src);
} else if (/^https?:\/\//i.test(src)) {
buffer = await downloadToBuffer(src);
} else {
const filePath = src.startsWith('file://') ? src.replace('file://', '') : src;
buffer = await fs.promises.readFile(filePath);
}
cache.set(src, buffer);
return buffer;
}
async function materializeObjectFitImages(slideData, tmpDir) {
if (!slideData || !Array.isArray(slideData.elements)) return;
const cache = new Map();
const tasks = slideData.elements.map(async (el) => {
if (el.type !== 'image' || !el.src) return;
if (el.objectFit !== 'cover' && el.objectFit !== 'contain') return;
if (!el.position || !Number.isFinite(el.position.w) || !Number.isFinite(el.position.h)) return;
const targetW = Math.max(1, Math.round(el.position.w * 192));
const targetH = Math.max(1, Math.round(el.position.h * 192));
try {
const input = await loadImageBuffer(el.src, cache);
const meta = await sharp(input).metadata();
if (!meta.width || !meta.height) return;
const pos = parseCssObjectPosition(el.objectPosition);
const filename = `h2p-fit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
const outPath = path.join(tmpDir, filename);
if (el.objectFit === 'cover') {
const scale = Math.max(targetW / meta.width, targetH / meta.height);
const resizedW = Math.max(targetW, Math.ceil(meta.width * scale));
const resizedH = Math.max(targetH, Math.ceil(meta.height * scale));
const left = clampNumber(Math.round((resizedW - targetW) * pos.x), 0, resizedW - targetW);
const top = clampNumber(Math.round((resizedH - targetH) * pos.y), 0, resizedH - targetH);
await sharp(input)
.rotate()
.resize(resizedW, resizedH, { fit: 'fill' })
.extract({ left, top, width: targetW, height: targetH })
.png()
.toFile(outPath);
} else {
const scale = Math.min(targetW / meta.width, targetH / meta.height);
const resizedW = Math.max(1, Math.round(meta.width * scale));
const resizedH = Math.max(1, Math.round(meta.height * scale));
const left = clampNumber(Math.round((targetW - resizedW) * pos.x), 0, targetW - resizedW);
const top = clampNumber(Math.round((targetH - resizedH) * pos.y), 0, targetH - resizedH);
const resized = await sharp(input)
.rotate()
.resize(resizedW, resizedH, { fit: 'fill' })
.png()
.toBuffer();
await sharp({
create: {
width: targetW,
height: targetH,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 }
}
})
.composite([{ input: resized, left, top }])
.png()
.toFile(outPath);
}
el.src = outPath;
el.objectFit = 'fill';
el._objectFitMaterialized = true;
} catch (err) {
console.warn(`[html2pptx] object-fit image fallback (${el.src}): ${err.message}`);
}
});
await Promise.all(tasks);
}
// Helper: Get body dimensions and check for overflow
async function getBodyDimensions(page) {
const bodyDimensions = await page.evaluate(() => {
// Normalize box-sizing before measuring overflow, so that a child like
// `width:720pt; padding:0 48pt` doesn't falsely appear to overflow the
// 720pt body under default content-box sizing.
document.querySelectorAll('*').forEach(el => { el.style.boxSizing = 'border-box'; });
void document.body.offsetHeight;
const body = document.body;
const slideRoot = document.querySelector('.slide') || body;
const rect = slideRoot.getBoundingClientRect();
const style = window.getComputedStyle(slideRoot);
const bodyStyle = window.getComputedStyle(body);
const padX = (parseFloat(bodyStyle.paddingLeft) || 0) + (parseFloat(bodyStyle.paddingRight) || 0);
const padY = (parseFloat(bodyStyle.paddingTop) || 0) + (parseFloat(bodyStyle.paddingBottom) || 0);
const rootW = Math.max(rect.width || 0, slideRoot.scrollWidth || 0, parseFloat(style.width) || 0);
const rootH = Math.max(rect.height || 0, slideRoot.scrollHeight || 0, parseFloat(style.height) || 0);
return {
width: rootW + padX,
height: rootH + padY,
scrollWidth: slideRoot.scrollWidth || rootW,
scrollHeight: slideRoot.scrollHeight || rootH
};
});
const errors = [];
const widthOverflowPx = Math.max(0, bodyDimensions.scrollWidth - bodyDimensions.width - 1);
const heightOverflowPx = Math.max(0, bodyDimensions.scrollHeight - bodyDimensions.height - 1);
const widthOverflowPt = widthOverflowPx * PT_PER_PX;
const heightOverflowPt = heightOverflowPx * PT_PER_PX;
const OVERFLOW_TOLERANCE_PT = 12;
// Severe overflow ("超长很多") — content spills past the canvas by more than
// ~1 inch. This is almost always real content getting silently clipped, so we
// escalate it to a louder, must-fix CRITICAL message. Output is NOT blocked
// (pptx still writes, exit code stays 0) — the message is the signal that the
// caller MUST fix the source HTML and re-run.
const OVERFLOW_SEVERE_PT = 72;
const maxOverflowPt = Math.max(widthOverflowPt, heightOverflowPt);
if (widthOverflowPt > OVERFLOW_TOLERANCE_PT || heightOverflowPt > OVERFLOW_TOLERANCE_PT) {
const directions = [];
if (widthOverflowPt > OVERFLOW_TOLERANCE_PT) directions.push(`${widthOverflowPt.toFixed(1)}pt horizontally`);
if (heightOverflowPt > OVERFLOW_TOLERANCE_PT) directions.push(`${heightOverflowPt.toFixed(1)}pt vertically`);
const reminder = heightOverflowPt > OVERFLOW_TOLERANCE_PT ? ' (leave 0.5" margin at bottom)' : '';
if (maxOverflowPt > OVERFLOW_SEVERE_PT) {
// Severe: spilled by >1in — content is being clipped, MUST be fixed.
errors.push(`🚨🚨 CRITICAL OVERFLOW (MUST FIX): HTML content overflows body by ${directions.join(' and ')}${reminder} — content is being clipped; reduce content / font size or restructure the source HTML and re-run.`);
} else {
errors.push(`🚨 CRITICAL OVERFLOW: HTML content overflows body by ${directions.join(' and ')}${reminder}`);
}
}
return { ...bodyDimensions, errors };
}
// Helper: Add background to slide
async function addBackground(slideData, targetSlide, pres, tmpDir) {
const slideW = pres.presLayout ? pres.presLayout.width / EMU_PER_IN : 10;
const slideH = pres.presLayout ? pres.presLayout.height / EMU_PER_IN : 5.625;
// Rasterized CSS-gradient canvas background (linear/radial mesh) — emitted as
// a PNG data URL that covers the whole slide.
if (slideData.background.type === 'image' && slideData.background.dataUrl) {
try {
targetSlide.addImage({
data: slideData.background.dataUrl,
x: 0, y: 0, w: slideW, h: slideH,
sizing: { type: 'cover', w: slideW, h: slideH }
});
return;
} catch (err) {
console.warn(`[html2pptx] gradient background failed: ${err.message}`);
if (slideData.background.fallbackColor) {
targetSlide.background = { color: slideData.background.fallbackColor };
}
return;
}
}
if (slideData.background.type === 'image' && slideData.background.path) {
let imagePath = slideData.background.path.startsWith('file://')
? slideData.background.path.replace('file://', '')
: slideData.background.path;
// PptxGenJS slide.background = { path } is unreliable for local files;
// use addImage at (0,0) covering the full slide instead.
try {
targetSlide.addImage({
path: fixImageExtension(imagePath, tmpDir),
x: 0, y: 0, w: slideW, h: slideH,
sizing: { type: 'cover', w: slideW, h: slideH }
});
} catch (err) {
console.warn(`[html2pptx] background image failed (${imagePath}): ${err.message}`);
// Fall back to a solid fill if a representative color was provided
if (slideData.background.fallbackColor) {
targetSlide.background = { color: slideData.background.fallbackColor };
}
}
} else if (slideData.background.type === 'color' && slideData.background.value) {
targetSlide.background = { color: slideData.background.value };
}
}
// Helper: Pick a PPT shape constant based on the element's `shape` block
function pickShapeKind(pres, shapeMeta) {
if (!shapeMeta) return pres.ShapeType.rect;
if (shapeMeta.kind === 'ellipse') return pres.ShapeType.ellipse;
if (shapeMeta.rectRadius && shapeMeta.rectRadius > 0) return pres.ShapeType.roundRect;
return pres.ShapeType.rect;
}
// Helper: Add elements to slide
function addElements(slideData, targetSlide, pres, tmpDir) {
const slideWidthIn = pres.presLayout ? pres.presLayout.width / EMU_PER_IN : 10;
const slideHeightIn = pres.presLayout ? pres.presLayout.height / EMU_PER_IN : 5.625;
const MAX_TEXT_WIDTH_IN = 680 / 72; // ~9.44in — cap after compensation to avoid overflow
// v3: Sort by z-index for correct visual layering. Treat 'auto' (NaN) as 0
// but preserve insertion order via a secondary key so equal z-index keeps DOM order.
const sortedElements = slideData.elements
.map((el, idx) => ({ el, idx, z: Number.isFinite(el.zIndex) ? el.zIndex : 0 }))
.sort((a, b) => (a.z - b.z) || (a.idx - b.idx))
.map(x => x.el);
for (const el of sortedElements) {
if (el.type === 'image') {
// Honor the skip flag set by rasterizeSvgImages on failure
if (el._skipRender) {
if (el._skipReason) console.warn(`[html2pptx] image skipped — ${el._skipReason}`);
continue;
}
let imagePath = el.src && el.src.startsWith('file://') ? el.src.replace('file://', '') : el.src;
if (!imagePath) continue;
// Defensive: if for any reason an SVG data URL slipped past the rasterizer,
// skip it instead of letting pptxgenjs throw "Unable to read media".
if (typeof imagePath === 'string' && imagePath.startsWith('data:image/svg+xml')) {
console.warn('[html2pptx] dropping un-rasterized SVG data URL — sharp may have failed earlier');
continue;
}
const opts = {
x: el.position.x, y: el.position.y, w: el.position.w, h: el.position.h
};
// object-fit support — pptxgenjs `sizing` controls crop/contain/cover behaviour
if (el.objectFit === 'cover') {
opts.sizing = { type: 'cover', w: el.position.w, h: el.position.h };
} else if (el.objectFit === 'contain') {
opts.sizing = { type: 'contain', w: el.position.w, h: el.position.h };
} else if (el.objectFit === 'crop' && el.crop) {
opts.sizing = { type: 'crop', w: el.position.w, h: el.position.h, x: el.crop.x, y: el.crop.y };
}
// border-radius approximation — pptxgenjs has limited support for image clipping;
// we expose the value and let the user opt-in via rounding when supported.
if (el.rounding) opts.rounding = true;
// Forward image opacity / shadow if present
if (el.transparency != null) opts.transparency = el.transparency;
if (el.shadow) opts.shadow = el.shadow;
if (el.hyperlink) opts.hyperlink = el.hyperlink;
try {
// Only run extension-fixup on actual filesystem paths; use data for data URLs.
const isDataUrl = typeof imagePath === 'string' && imagePath.startsWith('data:');
const isLocalFile = typeof imagePath === 'string'
&& !isDataUrl
&& !/^https?:\/\//i.test(imagePath);
if (isDataUrl) opts.data = imagePath;
else opts.path = isLocalFile ? fixImageExtension(imagePath, tmpDir) : imagePath;
targetSlide.addImage(opts);
} catch (err) {
console.warn(`[html2pptx] failed to add image ${imagePath}: ${err.message}`);
}
} else if (el.type === 'line') {
const lineOptions = {
x: el.x1, y: el.y1, w: el.x2 - el.x1, h: el.y2 - el.y1,
line: { color: el.color, width: el.width, dashType: el.dashType }
};
if (el.transparency !== null && el.transparency !== undefined) {
lineOptions.line.transparency = el.transparency;
}
targetSlide.addShape(pres.ShapeType.line, lineOptions);
} else if (el.type === 'hr') {
// Horizontal rule rendered as a thin line shape across its measured width
targetSlide.addShape(pres.ShapeType.line, {
x: el.position.x,
y: el.position.y + el.position.h / 2,
w: el.position.w,
h: 0,
line: { color: el.color || '888888', width: el.thickness || 0.75, dashType: el.dashType || 'solid' }
});
} else if (el.type === 'table') {
// Native PptxGenJS table support
const tableOpts = {
x: el.position.x, y: el.position.y, w: el.position.w,
// height is best left adaptive — pptxgenjs will auto-fit rows
colW: el.colWidths || undefined,
border: el.tableBorder || { type: 'solid', color: 'CCCCCC', pt: 0.5 },
fontFace: el.style?.fontFace,
fontSize: el.style?.fontSize,
color: el.style?.color,
valign: 'top',
autoPage: false
};
try {
targetSlide.addTable(el.rows, tableOpts);
} catch (err) {
console.warn(`[html2pptx] failed to add table: ${err.message}`);
}
} else if (el.type === 'shape') {
const shapeOptions = {
x: el.position.x, y: el.position.y, w: el.position.w, h: el.position.h,
shape: pickShapeKind(pres, el.shape)
};
if (el.shape.fill) {
// String fill = solid color; object fill = gradient (best-effort)
if (typeof el.shape.fill === 'string') {
shapeOptions.fill = { color: el.shape.fill };
if (el.shape.transparency != null) shapeOptions.fill.transparency = el.shape.transparency;
} else {
shapeOptions.fill = el.shape.fill;
}
}
if (el.shape.line) shapeOptions.line = el.shape.line;
if (el.shape.rectRadius > 0) shapeOptions.rectRadius = el.shape.rectRadius;
if (el.shape.shadow) shapeOptions.shadow = el.shape.shadow;
if (el.shape.rotate != null) shapeOptions.rotate = el.shape.rotate;
targetSlide.addText(el.text || '', shapeOptions);
} else if (el.type === 'list') {
// v3: Height compensation for lists
let adjustedH = el.position.h * (1 + COMPENSATION.LIST_HEIGHT) +
getListSpacingHeightBonus(el);
// Clamp list height to slide bottom
if (el.position.y + adjustedH > slideHeightIn) adjustedH = slideHeightIn - el.position.y;
if (adjustedH < 0.1) adjustedH = 0.1;
// Clamp list width to slide right edge
let listX = el.position.x;
let listW = el.position.w;
if (listX + listW > slideWidthIn) listW = slideWidthIn - listX;
if (listW < 0.1) listW = 0.1;
if (listW > MAX_TEXT_WIDTH_IN) listW = MAX_TEXT_WIDTH_IN;
const listOptions = {
x: listX, y: el.position.y, w: listW, h: adjustedH,
fontSize: el.style.fontSize, fontFace: el.style.fontFace, color: el.style.color,
align: el.style.align, valign: 'top', charSpacing: el.style.charSpacing,
lineSpacing: el.style.lineSpacing, paraSpaceBefore: el.style.paraSpaceBefore,
paraSpaceAfter: el.style.paraSpaceAfter, margin: el.style.margin,
lineSpacingMultiple: el.style.lineSpacingMultiple
};
if (el.style.margin) listOptions.margin = el.style.margin;
targetSlide.addText(el.items, listOptions);
} else {
// ── Text elements (p, h1-h6) with v3 adaptive compensation ──
// Hints set in extractSlideData (see baseStyle._* fields).
const hasBrBreaks = el.style?._hasBrBreaks;
const iconOnlyText = el.style?._isIcon === true;
const centerInFrame = el.style?._centerInFrame || (iconOnlyText && !el.style?._inlineLeadGlyph);
const hasContainerPadding = el.style?._hasContainerPadding;
// When the element is a padded container that also rendered a shape behind
// itself (e.g. .bubble), expanding the text frame would push lines past the
// visible shape edge. Skip width compensation in that case — the explicit
// padding already provides safe inset for text wrapping.
// Centered icon/badge containers already use the visual frame as their
// text frame. Expanding them for PPT font compensation moves the glyph
// center away from the background circle/pill.
const isRotatedText = el.style?.rotate !== undefined;
const isHeadingWithBreaks = hasBrBreaks && /^h[1-6]$/.test(el.type);
const widthFactor = (centerInFrame || hasContainerPadding || isRotatedText)
? 0
: Math.max(calculateWidthCompensation(el, slideWidthIn), isHeadingWithBreaks ? COMPENSATION.HEADING_WIDTH : 0);
const widthIncrease = el.position.w * widthFactor;
let adjustedX = el.position.x;
let adjustedW = el.position.w;
const align = el.style.align;
if (align === 'center') {
adjustedX -= widthIncrease / 2;
adjustedW += widthIncrease;
} else if (align === 'right') {
adjustedX -= widthIncrease;
adjustedW += widthIncrease;
} else {
adjustedW += widthIncrease;
}
// Flex rows often use a left title plus right-aligned tags/badges.
// Chromium reports the title's wrapped line box; PPT then preserves that
// narrow box and wraps earlier. If extraction found clear free space up
// to the next flex sibling, use it as the textbox's right edge.
const preferredRightBound = el.style?._preferredRightBound;
if (!isRotatedText && !el.style?._inlineLeadGlyph && Number.isFinite(preferredRightBound) &&
preferredRightBound > adjustedX + adjustedW &&
(align === 'left' || align === undefined)) {
adjustedW = preferredRightBound - adjustedX;
}
// v3: Height compensation for all text. When the source font-size was
// clamped UP to MIN_FONT_SIZE_PT, the browser laid the box out at the
// intended (smaller) size; the PPT box must grow proportionally so the
// larger PPT font has room without spilling into a sibling element.
const clampRatio = el.style?._fontSizeClampRatio || 1;
let adjustedH = centerInFrame ? el.position.h : el.position.h * (1 + COMPENSATION.TEXT_HEIGHT) * clampRatio;
// Clamp height to slide bottom
if (el.position.y + adjustedH > slideHeightIn) adjustedH = slideHeightIn - el.position.y;
if (adjustedH < 0.1) adjustedH = 0.1;
// Centered headings: expand width symmetrically for PPT center alignment
if (el.style.align === 'center' && /^h[1-6]$/.test(el.type)) {
const centerX = adjustedX + adjustedW / 2;
const margin = 0.3;
const maxExpand = Math.min(centerX - margin, slideWidthIn - centerX - margin);
if (maxExpand > adjustedW / 2) {
adjustedX = centerX - maxExpand;
adjustedW = maxExpand * 2;
}
}
// Clamp to nearest visual parent first. Text inside cards/panels should not
// grow past the parent's padded content box just because we compensate for
// PPT font substitution.
const clip = el.style?._clipBounds;
let flowHeightClamped = false;
if (clip) {
const clipRight = clip.x + clip.w + getNarrowBodyTextClipSlack(el) +
getMediumSingleLineClipSlack(el);
if (adjustedX < clip.x) {
adjustedW -= (clip.x - adjustedX);
adjustedX = clip.x;
}
if (adjustedX + adjustedW > clipRight) {
const overflowRight = adjustedX + adjustedW - clipRight;
// Preserve the left edge for left-aligned text inside cards/panels.
// Shifting left to save compensated width makes stacked card labels
// (e.g. RESOURCE 01 / title / formula) visibly misalign. For right
// and centered text, shifting is still the closest match to browser
// layout because the visual anchor is not the left edge.
const canShiftLeft = align === 'right' || align === 'center';
const shiftLeft = canShiftLeft ? Math.min(overflowRight, Math.max(0, adjustedX - clip.x)) : 0;
adjustedX -= shiftLeft;
adjustedW -= (overflowRight - shiftLeft);
}
if (el.position.y + adjustedH > clip.y + clip.h) adjustedH = (clip.y + clip.h) - el.position.y;
}
const beforeFlowClampH = adjustedH;
adjustedH = clampHeightToNextTextBound(el, adjustedH);
flowHeightClamped = adjustedH < beforeFlowClampH - 0.001;
// Clamp to slide bounds (safety net). Rotated text may need an
// off-slide unrotated frame to keep the rotated visual bbox aligned with
// the browser bbox, e.g. a vertical side label at left:24px.
if (!isRotatedText) {
if (adjustedX < 0) { adjustedW += adjustedX; adjustedX = 0; }
if (adjustedX + adjustedW > slideWidthIn) adjustedW = slideWidthIn - adjustedX;
}
if (adjustedW < 0.1) adjustedW = 0.1;
if (adjustedH < 0.1) adjustedH = 0.1;
// Cap at 680pt to prevent over-expansion from width compensation.
// Do not cap rotated frames: their width is often the visual height.
if (!isRotatedText && adjustedW > MAX_TEXT_WIDTH_IN) adjustedW = MAX_TEXT_WIDTH_IN;
// ── Anti-wrap adjustments for single-line text ──────────────────────
// These run after the box width is finalized so we know the real frame.
{
const _txt = extractTextContent(el).replace(/\s+/g, ' ').trim();
const _compact = _txt.replace(/\s+/g, '');
const _fsPt = Number(el.style?.fontSize) || 0;
const _linePt = Number(el.style?.lineSpacing) || _fsPt * 1.35;
// Treat as one visual line when the source had no <br>/breakLine and the
// measured browser box is ≤ ~1.8 line-heights tall.
const _oneLine = _fsPt > 0 && !hasExplicitTextBreaks(el) &&
(Number(el.position?.h) || 0) * 72 <= _linePt * 1.8;
// (B) Shrink-to-fit: when a sub-8pt source font was clamped UP to the
// readability floor (see fontSizeClampRatio / getMinFontSize), the glyph
// run grows by that ratio. Inside a fixed-width container (card, side
// panel) the larger PPT font then overflows and wraps to a 2nd line,
// which collides with the row below (e.g. slide 11 "register() · login()
// · updateProfile()" over-running the object-oriented-design panel).
// The browser fit the text on one line at the intended smaller size, so
// reduce the font just enough to fit again — never below the raw source
// size, floored at 6pt for legibility.
const _clampRatio = Number(el.style?._fontSizeClampRatio) || 1;
if (_oneLine && !centerInFrame && _clampRatio > 1.01) {
const _boxPt = adjustedW * 72;
// The width heuristic runs a few percent under real renderers (PPT/
// LibreOffice substitute fonts are wider), so apply a safety factor
// when deciding whether the run will overflow and wrap.
const _estPt = estimateTextWidthPt(_txt, _fsPt) * 1.12;
if (_boxPt > 0 && _estPt > _boxPt) {
// The browser fit the run on one line at the intended (pre-clamp)
// source size, so undo the readability up-clamp — down to a 6pt
// legibility floor — which guarantees it fits again. Chasing a
// precise "fit" font is unreliable because real renderers run wider
// than the width heuristic.
const _rawPt = _fsPt / _clampRatio;
const _newFs = Math.max(6, _rawPt);
if (_newFs < _fsPt - 0.05) {
const _scale = _newFs / _fsPt;
el.style.fontSize = _newFs;
if (Array.isArray(el.text)) {
el.text.forEach(r => {
if (r.options && Number(r.options.fontSize)) {
r.options.fontSize = Math.max(6, r.options.fontSize * _scale);
}
});
}
}
}
}
// (A) Minimum width for very short single-line labels. Tiny numeric /
// badge captions ("01", "PHP", "→") are measured tight to the browser
// glyph run; the wider PPT substitute font (plus any up-clamp) then wraps
// a 2-char label into two stacked chars (e.g. slide 18 card numbers
// "01" → "0"/"1"). Guarantee the frame is wide enough to hold the run on
// one line. Grow symmetrically around the current center so corner-
// anchored labels barely move. Only act when the current box is tight.
const _fsPt2 = Number(el.style?.fontSize) || 0;
if (!isRotatedText && !el.style?._inlineLeadGlyph && _oneLine && _fsPt2 > 0 &&
_compact.length > 0 && _compact.length <= 12) {
const _estPt2 = estimateTextWidthPt(_txt, _fsPt2);
const _curPt = adjustedW * 72;
if (_estPt2 > _curPt * 0.72) {
const _needIn = (_estPt2 * 1.18 + _fsPt2 * 0.35) / 72;
if (_needIn > adjustedW) {
const _grow = _needIn - adjustedW;
adjustedX -= _grow / 2;
adjustedW += _grow;
// Re-clamp to slide edges (skip for rotated frames handled above).
if (adjustedX < 0) { adjustedW += adjustedX; adjustedX = 0; }
if (adjustedX + adjustedW > slideWidthIn) adjustedX = Math.max(0, slideWidthIn - adjustedW);
}
}
}
}
// Effective alignment / vertical alignment.
// - center-in-frame containers (grid/flex place-items:center) → middle+center
// so single-char circular badges (.teacher, .thumb-num) sit at the visual
// center of the surrounding ellipse instead of the top-left.
const valignOut = centerInFrame ? 'middle' : 'top';
const alignOut2 = centerInFrame ? 'center' : (el.style.align || undefined);
// CSS margins are already reflected in getBoundingClientRect() through
// normal layout. Re-applying them as PPT paragraph spacing shifts text
// downward inside its measured box (especially small labels with
// margin-top). Keep the textbox anchored to the browser bbox.
const paraBefore = 0;
const paraAfter = 0;
const textOptions = {
x: adjustedX, y: el.position.y, w: adjustedW, h: adjustedH,
// Avoid general PowerPoint shrink-to-fit; only use it when a
// normal-flow sibling below would otherwise be overlapped by height
// compensation after PPT font substitution/wrapping.
fit: flowHeightClamped ? 'shrink' : 'none',
fontSize: el.style.fontSize, fontFace: el.style.fontFace, color: el.style.color,
bold: el.style.bold, italic: el.style.italic, underline: el.style.underline,
strike: el.style.strike,
valign: valignOut, charSpacing: el.style.charSpacing, lineSpacing: el.style.lineSpacing,
lineSpacingMultiple: el.style.lineSpacingMultiple,
paraSpaceBefore: paraBefore, paraSpaceAfter: paraAfter,
};
const textPayload = Array.isArray(el.text)
? el.text.map((run) => {
const options = { ...(run.options || {}) };
delete options.paraSpaceBefore;
delete options.paraSpaceAfter;
return { ...run, options };
})
: el.text;
// Only zero the inset when the element has no padding of its own; padded
// containers (e.g. .bubble) need the padding to render as inset so text
// wraps inside the visible shape.
const zeroTextInset = el.style?._zeroTextInset === true;
if (!hasContainerPadding || iconOnlyText || zeroTextInset) textOptions.inset = 0;
if (alignOut2) textOptions.align = alignOut2;
if (iconOnlyText || zeroTextInset) textOptions.margin = [0, 0, 0, 0];
else if (el.style.margin) textOptions.margin = el.style.margin;
if (el.style.rotate !== undefined) textOptions.rotate = el.style.rotate;
if (el.style._pptxVert) textOptions.vert = el.style._pptxVert;
if (el.style.transparency !== null && el.style.transparency !== undefined) textOptions.transparency = el.style.transparency;
if (el.style.shadow) textOptions.shadow = el.style.shadow;
// Headings with explicit <br> breaks normally render with wrap:false so
// PPT preserves the author's exact line breaks. But when a single
// break-delimited segment is itself wider than the frame (e.g. multi_3
// slide-2 "building foundation models." — a long second line that the
// browser re-wraps inside the column), wrap:false lets it bleed off to the
// right over neighbouring content. Detect that case and allow wrapping so
// the overflowing segment breaks like it does in the browser.
let headingBreaksOverflow = false;
if (isHeadingWithBreaks && Array.isArray(el.text)) {
const _hfs = Number(el.style?.fontSize) || 0;
const _boxPt = adjustedW * 72;
if (_hfs > 0 && _boxPt > 0) {
let _seg = '';
const _segPts = [];
const _flush = () => {
const s = _seg.replace(/\s+/g, ' ').trim();
if (s) _segPts.push(estimateTextWidthPt(s, _hfs));
_seg = '';
};
for (const run of el.text) {
const t = typeof run === 'string' ? run : (run && run.text) || '';
_seg += t;
if (run && run.options && run.options.breakLine) _flush();
}
_flush();
const _maxPt = _segPts.length ? Math.max(..._segPts) : 0;
if (_maxPt > _boxPt * 1.02) {
// A single <br>-segment is wider than the frame. With wrap:false it
// would bleed off to the right over adjacent content (multi_3
// slide-2 "building foundation models." running over the diagram
// card). Allow wrapping so the segment breaks like it does in the
// browser; the frame keeps its compensated width so short segments
// don't over-wrap.
headingBreaksOverflow = true;
}
}
}
if (!el.style?._allowWrap && (el.noWrap || isShortAutoNoWrapText(el) || (isHeadingWithBreaks && !headingBreaksOverflow))) textOptions.wrap = false;
targetSlide.addText(textPayload, textOptions);
}
}
}
// Helper: Extract slide data from HTML page
async function extractSlideData(page, slideDims) {
return await page.evaluate((slideDims) => {
const PT_PER_PX = 0.75;
const PX_PER_IN = 96;
// Switch every element to border-box BEFORE measuring body size. Without this,
// a child like `width:720pt; padding:0 48pt` overflows the 720pt body under
// default content-box, making scrollWidth report 1088px instead of 960px —
// which then shrinks SCALE and leaves empty space on the side of the slide.
document.querySelectorAll('*').forEach(el => { el.style.boxSizing = 'border-box'; });
// Force reflow so the new box-sizing affects layout measurements below.
void document.body.offsetHeight;
// HTML slide templates are usually authored at a fixed pixel viewport
// (e.g. 1280×720) that maps to the PPT slide size (e.g. 10×5.625 in).
// Scale every position/dimension by slideWidthIn / (bodyWidth / 96) so the
// 1280-wide design fills the 10-inch slide exactly instead of overflowing.
const slideRoot = document.querySelector('.slide') || document.body;
const slideRootRect = slideRoot.getBoundingClientRect();
const CANVAS_LEFT = slideRootRect.left || 0;
const CANVAS_TOP = slideRootRect.top || 0;
const bodyWPx = slideRootRect.width || slideRoot.scrollWidth || document.body.clientWidth || 1280;
const bodyHPx = slideRootRect.height || slideRoot.scrollHeight || document.body.clientHeight || 720;
const SCALE_X = slideDims.widthIn / (bodyWPx / PX_PER_IN);
const SCALE_Y = slideDims.heightIn / (bodyHPx / PX_PER_IN);
// Use a single uniform scale (derived from width) for aspect-preserving
// layout. Height overflow is still reported, but scaling width keeps the
// design identical to HTML proportions.
const SCALE = SCALE_X;
// Fonts that are single-weight and should not have bold applied
// (applying bold causes PowerPoint to use faux bold which makes text wider)
const SINGLE_WEIGHT_FONTS = ['impact'];
// Helper: Check if a font should skip bold formatting
const shouldSkipBold = (fontFamily) => {
if (!fontFamily) return false;
const normalizedFont = fontFamily.toLowerCase().replace(/['"]/g, '').split(',')[0].trim();
return SINGLE_WEIGHT_FONTS.includes(normalizedFont);
};
// Known CJK font name fragments (lowercase) — presence means the element targets CJK text
const CJK_FONT_FRAGMENTS = [
'yahei', '雅黑', 'noto sans sc', '黑体', 'noto serif sc', '宋体', 'kaiti', '楷体',
'fangsong', '仿宋', 'pingfang', 'hiragino', 'noto sans cjk', 'noto sans sc',
'noto sans tc', 'noto sans hk', 'source han sans', '思源黑体', '思源宋体',
'wenquanyi', 'lxgw wenkai', '霞鹜文楷', 'arial unicode', 'yugothic', 'meiryo', 'ms gothic', 'ms mincho',
'malgun gothic', 'apple sd gothic', 'heiti', 'songti', 'wawati', 'weibei',
'libian', 'xingkai', 'baoli', 'yuanti', 'dengxian', '等线', 'stxihei',
'stheiti', 'stkaiti', 'stsong', 'stfangsong'
];
// Detect if text content contains CJK characters
const hasCJKChars = (text) => /[\u4e00-\u9fff\u3040-\u30ff\uac00-\ud7af]/.test(text);
// v3: Smart font mapping — PPT-safe fonts pass through, others get mapped
// fontConfig from caller is still respected as ultimate fallback for CJK/Latin defaults
const _fc = window.__FONT_CONFIG__ || {};
// macOS-only / web fonts → cross-platform PPT-safe equivalents
const FONT_FALLBACK_MAP = {
'pingfang sc': 'Microsoft YaHei', 'pingfang tc': 'Microsoft YaHei', 'pingfang hk': 'Microsoft YaHei',
'hiragino sans': 'Microsoft YaHei', 'hiragino sans gb': 'Microsoft YaHei',
'hiragino mincho pron': 'SimSun', 'hiragino maru gothic pro': 'Microsoft YaHei',
'heiti sc': 'Microsoft YaHei', 'heiti tc': 'Microsoft YaHei', 'songti sc': 'SimSun',
'stxihei': 'Microsoft YaHei', 'stheiti': 'Microsoft YaHei', 'stkaiti': 'KaiTi',
'stsong': 'SimSun', 'stfangsong': 'KaiTi', 'apple sd gothic neo': 'Microsoft YaHei',
'noto sans sc': 'Microsoft YaHei', 'noto sans tc': 'Microsoft YaHei',
'noto sans cjk sc': 'Microsoft YaHei', 'noto serif sc': 'SimSun',
'source han sans sc': 'Microsoft YaHei', 'source han serif sc': 'SimSun',
'source han sans': 'Microsoft YaHei', 'source han serif': 'SimSun',
'思源黑体': 'Microsoft YaHei', '思源宋体': 'SimSun',
'阿里巴巴普惠体': 'Microsoft YaHei',
'lxgw wenkai': 'KaiTi', 'lxgw wenkai tc': 'KaiTi',
'lxgw wenkai gb': 'KaiTi', '霞鹜文楷': 'KaiTi', '霞鹜文楷 tc': 'KaiTi',
'fredoka': 'Arial Rounded MT Bold', 'fredoka one': 'Arial Rounded MT Bold',
'system-ui': 'Carlito', '-apple-system': 'Carlito', 'blinkmacsystemfont': 'Carlito',
'segoe ui': 'Carlito', 'helvetica neue': 'Liberation Sans', 'helvetica': 'Liberation Sans',
'sans-serif': 'Carlito', 'serif': 'Tinos', 'monospace': 'Courier New',
'inter': 'Carlito', 'roboto': 'Liberation Sans', 'roboto slab': 'Tinos',
'open sans': 'Carlito', 'lato': 'Carlito', 'montserrat': 'Carlito',
'poppins': 'Carlito', 'raleway': 'Carlito', 'nunito': 'Carlito',
'nunito sans': 'Carlito', 'source sans pro': 'Carlito', 'source sans 3': 'Carlito',
'source serif pro': 'Tinos', 'work sans': 'Carlito', 'dm sans': 'Carlito',
'space grotesk': 'Carlito', 'plus jakarta sans': 'Carlito',
'manrope': 'Carlito', 'fira sans': 'Carlito', 'playfair display': 'Tinos',
'merriweather': 'Tinos', 'libre baskerville': 'Tinos',
'pt sans': 'Carlito', 'pt serif': 'Tinos', 'ubuntu': 'Carlito',
'arial': 'Liberation Sans', 'arial black': 'Liberation Sans',
'arial narrow': 'Liberation Sans', 'georgia': 'Tinos',
'gill sans mt': 'Carlito', 'century gothic': 'Carlito',
'palatino linotype': 'Tinos', 'palatino': 'Tinos',
'trebuchet ms': 'Carlito', 'garamond': 'Tinos',
'rockwell': 'Tinos', 'candara': 'Carlito', 'corbel': 'Carlito',
'constantia': 'Tinos', 'cambria': 'Tinos',
'verdana': 'Carlito', 'tahoma': 'Carlito', 'impact': 'Liberation Sans Narrow',
'comic sans ms': 'Arial Rounded MT Bold', 'lucida sans': 'Carlito',
'franklin gothic medium': 'Liberation Sans', 'bodoni mt': 'Tinos',
'copperplate gothic': 'Carlito', 'tw cen mt': 'Carlito',
'century schoolbook': 'Tinos', 'book antiqua': 'Tinos',
// Condensed / display sans → Liberation Sans Narrow (closer geometry)
'bebas neue': 'Liberation Sans Narrow', 'bebas neue pro': 'Liberation Sans Narrow',
'oswald': 'Liberation Sans Narrow', 'anton': 'Liberation Sans Narrow',
'fjalla one': 'Liberation Sans Narrow', 'teko': 'Liberation Sans Narrow',
'saira condensed': 'Liberation Sans Narrow', 'saira': 'Carlito',
'roboto condensed': 'Liberation Sans Narrow', 'archivo narrow': 'Liberation Sans Narrow',
'barlow condensed': 'Liberation Sans Narrow', 'barlow semi condensed': 'Liberation Sans Narrow',
'encode sans condensed': 'Liberation Sans Narrow',
'pt sans narrow': 'Liberation Sans Narrow',
'league spartan': 'Liberation Sans Narrow', 'league gothic': 'Liberation Sans Narrow',
'six caps': 'Liberation Sans Narrow', 'big shoulders display': 'Liberation Sans Narrow',
'big shoulders text': 'Liberation Sans Narrow',
'alumni sans': 'Liberation Sans Narrow', 'dharma gothic e': 'Liberation Sans Narrow',
'titillium web': 'Liberation Sans Narrow', 'staatliches': 'Liberation Sans Narrow',
'bungee': 'Liberation Sans Narrow', 'bungee shade': 'Liberation Sans Narrow', 'bungee inline': 'Liberation Sans Narrow',
'orbitron': 'Liberation Sans Narrow', 'rajdhani': 'Liberation Sans Narrow',
// Pixel / retro fonts — none ship with PPT; map to a monospaced
// pass-through so glyphs at least keep a uniform Latin width.
'vt323': 'Liberation Mono', 'press start 2p': 'Liberation Mono',
'major mono display': 'Liberation Mono', 'silkscreen': 'Liberation Mono',
'pixelify sans': 'Liberation Mono', 'jersey 10': 'Liberation Mono',
// Modern web sans-serif families that LLMs love to reach for
'inter tight': 'Carlito', 'recursive': 'Carlito', 'figtree': 'Carlito',
'sora': 'Carlito', 'urbanist': 'Carlito', 'be vietnam pro': 'Carlito',
'rubik': 'Carlito', 'archivo': 'Carlito', 'instrument sans': 'Carlito',
'instrument serif': 'Tinos', 'cormorant garamond': 'Tinos',
'eb garamond': 'Tinos', 'lora': 'Tinos', 'crimson text': 'Tinos',
'crimson pro': 'Tinos', 'fraunces': 'Tinos', 'spectral': 'Tinos',
'literata': 'Tinos', 'newsreader': 'Tinos',
// Newer web fonts (2023-2026 design trend choices)
'geist': 'Carlito', 'geist sans': 'Carlito',
'onest': 'Carlito', 'satoshi': 'Carlito', 'general sans': 'Carlito',
'switzer': 'Carlito', 'syne': 'Carlito', 'red hat display': 'Carlito',
'red hat text': 'Carlito', 'public sans': 'Carlito',
'reddit sans': 'Carlito', 'mulish': 'Carlito', 'hanken grotesk': 'Carlito',
'karla': 'Carlito', 'outfit': 'Carlito', 'bricolage grotesque': 'Carlito',
'host grotesk': 'Carlito', 'unbounded': 'Carlito',
'ibm plex sans': 'Carlito', 'ibm plex serif': 'Tinos',
'noto sans': 'Carlito', 'noto serif': 'Tinos',
'libre franklin': 'Carlito', 'oswald': 'Liberation Sans Narrow',
'barlow': 'Carlito', 'barlow condensed': 'Liberation Sans Narrow',
'epilogue': 'Carlito', 'overpass': 'Carlito', 'sofia sans': 'Carlito',
'gabarito': 'Carlito', 'wix madefor display': 'Carlito',
'wix madefor text': 'Carlito',
// More serif options
'bitter': 'Tinos', 'vollkorn': 'Tinos', 'pt serif': 'Tinos',
'cardo': 'Tinos', 'old standard tt': 'Tinos',
'noto serif display': 'Tinos', 'tinos': 'Tinos',
'big shoulders display': 'Liberation Sans Narrow', 'roboto serif': 'Tinos',
'dm serif display': 'Tinos', 'dm serif text': 'Tinos',
// Programming / monospaced web fonts → Courier New (PPT-safe)
'jetbrains mono': 'Courier New', 'fira code': 'Courier New',
'source code pro': 'Courier New', 'ibm plex mono': 'Courier New',
'space mono': 'Courier New', 'roboto mono': 'Courier New',
'cascadia code': 'Courier New', 'cascadia mono': 'Courier New',
'ubuntu mono': 'Courier New', 'geist mono': 'Courier New',
'departure mono': 'Courier New', 'inconsolata': 'Courier New',
'commit mono': 'Courier New', 'martian mono': 'Courier New',
// CJK display fonts that aren't in PPT — map to Microsoft YaHei
'smiley sans': 'Microsoft YaHei', 'smiley sans oblique': 'Microsoft YaHei',
'lxgw wenkai screen': 'KaiTi', 'zcool kuaile': 'KaiTi',
'zcool xiaowei': 'SimSun', 'zcool qingke huangyou': 'KaiTi',
'long cang': 'KaiTi', 'ma shan zheng': 'KaiTi',
'liu jian mao cao': 'KaiTi',
'kaiti': 'KaiTi', '楷体': 'KaiTi',
'fangsong': 'KaiTi', '仿宋': 'KaiTi',
'dengxian': 'Microsoft YaHei', '等线': 'Microsoft YaHei',
'黑体': 'Microsoft YaHei', '宋体': 'SimSun',
'simhei': 'Microsoft YaHei', 'simsun': 'SimSun',
'microsoft yahei': 'Microsoft YaHei',
// Modern Chinese-friendly variants
'harmonyos sans': 'Microsoft YaHei', 'harmonyos sans sc': 'Microsoft YaHei',
'opposans': 'Microsoft YaHei', 'oppo sans': 'Microsoft YaHei',
'misans': 'Microsoft YaHei', 'mi sans': 'Microsoft YaHei',
'小米兰亭': 'Microsoft YaHei', 'xiaomi lanting': 'Microsoft YaHei',
'霞鹜文楷': 'KaiTi', 'lxgw wenkai gb': 'KaiTi',
'阿里妈妈数黑体': 'Microsoft YaHei', 'alimama shuheiti': 'Microsoft YaHei',
'noto serif hk': 'SimSun', 'noto sans hk': 'Microsoft YaHei',
'noto sans tc': 'Microsoft YaHei', 'noto serif tc': 'SimSun',
'noto sans jp': 'Microsoft YaHei', 'noto serif jp': 'SimSun',
'noto sans kr': 'Microsoft YaHei', 'noto serif kr': 'SimSun',
// Calligraphic/handwriting Chinese → KaiTi (warmest substitute)
'楷书': 'KaiTi', '行楷': 'KaiTi', '隶书': 'KaiTi',
'cangerjinkai': 'KaiTi', 'cangermingbo': 'SimSun',
// Additional commonly-referenced Chinese families
'wenquanyi micro hei': 'Microsoft YaHei', 'wenquanyi zen hei': 'Microsoft YaHei',
'文泉驿微米黑': 'Microsoft YaHei', '文泉驿正黑': 'Microsoft YaHei',
'adobe heiti std': 'Microsoft YaHei', 'adobe fangsong std': 'KaiTi',
'adobe kaiti std': 'KaiTi', 'adobe song std': 'SimSun',
'ar pl ukai cn': 'KaiTi', 'ar pl uming cn': 'SimSun',
'fzhei': 'Microsoft YaHei', 'fzkai': 'KaiTi', 'fzsong': 'SimSun',
'方正黑体': 'Microsoft YaHei', '方正楷体': 'KaiTi', '方正宋体': 'SimSun',
'方正书宋': 'SimSun', '方正仿宋': 'KaiTi',
'汉仪旗黑': 'Microsoft YaHei', '汉仪文黑': 'Microsoft YaHei',
'hyqihei': 'Microsoft YaHei', 'hywenhei': 'Microsoft YaHei',
'ruiziyun': 'Microsoft YaHei', '锐字': 'Microsoft YaHei',
'dingtalk jinbuti': 'Microsoft YaHei', '钉钉进步体': 'Microsoft YaHei',
'youshebiaotihei': 'Microsoft YaHei', '优设标题黑': 'Microsoft YaHei',
'pangmen': 'Microsoft YaHei', '庞门正道标题体': 'Microsoft YaHei',
'aocomic': 'KaiTi', 'maoken': 'KaiTi',
'tw-kai': 'KaiTi', 'mingliu': 'SimSun', 'pmingliu': 'SimSun',
// Korean web fonts
'noto sans korean': 'Microsoft YaHei', 'noto serif korean': 'SimSun',
'spoqa han sans': 'Microsoft YaHei', 'spoqa han sans neo': 'Microsoft YaHei',
'nanum gothic': 'Microsoft YaHei', 'nanum myeongjo': 'SimSun',
'nanum barun gothic': 'Microsoft YaHei', 'nanum pen script': 'KaiTi',
'gowun batang': 'SimSun', 'gowun dodum': 'Microsoft YaHei',
'do hyeon': 'Microsoft YaHei', 'jua': 'Microsoft YaHei',
'black han sans': 'Microsoft YaHei', 'black and white picture': 'Microsoft YaHei',
'sunflower': 'Microsoft YaHei', 'gaegu': 'KaiTi',
// Japanese display fonts
'kosugi maru': 'Microsoft YaHei', 'kosugi': 'Microsoft YaHei',
'sawarabi gothic': 'Microsoft YaHei', 'sawarabi mincho': 'SimSun',
'm plus 1p': 'Microsoft YaHei', 'm plus rounded 1c': 'Microsoft YaHei',
'm plus 1': 'Microsoft YaHei', 'm plus 2': 'Microsoft YaHei',
'klee one': 'KaiTi', 'shippori mincho': 'SimSun',
'shippori mincho b1': 'SimSun', 'rampart one': 'Microsoft YaHei',
'rocknroll one': 'Microsoft YaHei', 'reggae one': 'Microsoft YaHei',
'dot gothic 16': 'Liberation Mono', 'dotgothic16': 'Liberation Mono',
'yusei magic': 'Microsoft YaHei', 'mochiy pop one': 'Microsoft YaHei',
'kaisei opti': 'SimSun', 'kaisei tokumin': 'SimSun',
// Additional modern Google Fonts not yet covered
'albert sans': 'Carlito', 'cabinet grotesk': 'Carlito',
'clash display': 'Liberation Sans Narrow', 'clash grotesk': 'Carlito',
'general sans': 'Carlito', 'supreme': 'Carlito', 'chillax': 'Carlito',
'erode': 'Tinos', 'gambarino': 'Tinos', 'melodrama': 'Tinos',
'tanker': 'Liberation Sans Narrow', 'panchang': 'Liberation Sans Narrow',
'array': 'Liberation Sans Narrow', 'sentient': 'Tinos',
'familjen grotesk': 'Carlito', 'instrument display': 'Tinos',
'fragment mono': 'Courier New', 'redaction': 'Tinos',
'authentic sans': 'Carlito', 'space grotesk variable': 'Carlito',
'national park': 'Liberation Sans Narrow',
'reddit mono': 'Courier New', 'noto sans mono': 'Courier New',
'azeret mono': 'Courier New', 'sometype mono': 'Courier New',
// Display script families occasionally requested
'lobster': 'Tinos', 'pacifico': 'Tinos', 'great vibes': 'Tinos',
'sacramento': 'Tinos', 'allura': 'Tinos', 'tangerine': 'Tinos',
'dancing script': 'Tinos', 'satisfy': 'Tinos', 'parisienne': 'Tinos',
'amatic sc': 'Carlito', 'shadows into light': 'Tinos',
'caveat': 'Tinos', 'kalam': 'Tinos', 'patrick hand': 'Tinos',
'gloria hallelujah': 'Tinos', 'architects daughter': 'Tinos',
'permanent marker': 'Tinos', 'indie flower': 'Tinos',
'homemade apple': 'Tinos', 'rock salt': 'Tinos',
// Decorative serifs and slab serifs
'abril fatface': 'Tinos', 'alfa slab one': 'Tinos',
'josefin slab': 'Tinos', 'zilla slab': 'Tinos',
'roboto slab': 'Tinos', 'arvo': 'Tinos',
'patua one': 'Tinos', 'sansita': 'Carlito',
};
// PPT-safe fonts — pass through directly without mapping.
// Liberation Sans Narrow is shipped widely and is the closest stand-in for
// condensed display fonts (Bebas Neue, Oswald, Barlow Condensed, …).
const PPT_SAFE_FONTS = new Set([
"microsoft yahei", "simsun", "kaiti", "microsoft jhenghei", "dengxian",
"carlito", "liberation sans", "liberation sans narrow",
"liberation serif", "liberation mono", "arial rounded mt bold",
"aptos", "aptos display", "aptos narrow",
"tinos", "courier new", "segoe ui symbol", "noto color emoji",
]);
// Heuristic classifier — for unknown font names, infer the closest PPT-safe
// substitute from keywords in the family name. This lets new Google/web
// fonts the explicit map doesn't cover still get a sensible mapping
// (e.g. "Bebas Neue Pro" → Liberation Sans Narrow because of "neue"/condensed
// pattern, "Source Han Sans HW" → Microsoft YaHei because of "han"/"sans").
// Returns null if no confident match — caller decides the final fallback.
const heuristicMapFont = (font, cjkText) => {
const lower = font.toLowerCase();
// Strip common weight / style modifiers so "Foo Bold Condensed" → "foo condensed"
const cleaned = lower
.replace(/\b(thin|extralight|ultralight|light|regular|book|medium|semibold|demibold|bold|extrabold|ultrabold|black|heavy)\b/g, ' ')
.replace(/\b(italic|oblique|roman|upright)\b/g, ' ')
.replace(/\bw\d{1,3}\b/g, ' ') // Weight numbers like "W400"
.replace(/\s+/g, ' ')
.trim();
// ── CJK keyword routing ────────────────────────────────────────────
// Heiti / sans-style Chinese
if (/(\bhei\b|黑|gothic|gothik|sans.*(cn|sc|tc|hk|jp|kr|cjk|chinese|chn|han|hans|hant)|(cn|sc|tc|hk|jp|kr|cjk|chinese|chn|han|hans|hant).*sans)/.test(cleaned)) {
return 'Microsoft YaHei';
}
// Songti / Mincho / serif Chinese
if (/(\bsong\b|宋|\bming\b|明朝|明体|mincho|serif.*(cn|sc|tc|hk|jp|kr|cjk|chinese|han|hans|hant)|(cn|sc|tc|hk|jp|kr|cjk|chinese|han|hans|hant).*serif)/.test(cleaned)) {
return 'SimSun';
}
// Kaiti / Fangsong / handwriting / brush
if (/(\bkai\b|楷|fangsong|仿宋|xing(kai|shu)?|行楷|行书|li ?bian|隶书|maru|手写|brush|caoshu|草书|手书|hand|script)/.test(cleaned)) {
return 'KaiTi';
}
// Any other recognisable CJK-script tag → Microsoft YaHei default
if (CJK_FONT_FRAGMENTS.some(f => lower.includes(f))) {
return 'Microsoft YaHei';
}
// Text content is CJK but the font is something else → caller's CJK default
if (cjkText) return _fc.cjk || 'Microsoft YaHei';
// ── Latin keyword routing ──────────────────────────────────────────
// Monospace / code fonts
if (/(\bmono\b|\bcode\b|console|courier|terminal|typewriter|fixedsys|menlo|monaco|consolas|hack|iosevka|berkeley mono|comic mono)/.test(cleaned)) {
return 'Courier New';
}
// Condensed / narrow / compressed display fonts → Liberation Sans Narrow
if (/(condensed|narrow|compressed|compact|extended|cond\b|cn\b)/.test(cleaned)) {
return 'Liberation Sans Narrow';
}
// Well-known condensed display families even without the keyword
if (/(bebas|oswald|anton|teko|saira|fjalla|tungsten|alfa slab|six caps|big shoulders|archivo narrow|barlow condensed|roboto condensed|encode sans condensed|league spartan|league gothic|alumni sans|dharma gothic)/.test(cleaned)) {
return 'Liberation Sans Narrow';
}
// Slab serifs → Tinos (closer in colour than Carlito)
if (/(slab|rockwell|chunk|courier prime|josefin slab|zilla slab|alfa)/.test(cleaned)) {
return 'Tinos';
}
// Generic serif markers
if (/(\bserif\b|antiqua|times|roman|garamond|georgia|caslon|baskerville|didot|bodoni|jenson|sabon|trajan|minion|palatino|fraktur|book|elephant|venetian)/.test(cleaned)) {
return 'Tinos';
}
// Script / handwriting / brush Latin → Tinos (closest stylised humanist)
if (/(script|hand|brush|cursive|calligraphic|signature|pen|ink|chalk|marker|kalam|caveat|pacifico|dancing|sacramento|satisfy|allura|great vibes|tangerine|parisienne|amatic|shadows|gloria|architects|patrick|reenie|indie|covered by your grace|nothing you could do|rock salt|permanent marker|homemade|just another hand)/.test(cleaned)) {
return 'Tinos';
}
// Pixel / retro display → Liberation Mono (uniform width preserves block style)
if (/(pixel|pixelify|press start|vt323|silkscreen|jersey|retro|8.?bit|terminal)/.test(cleaned)) {
return 'Liberation Mono';
}
// Anything explicitly "sans" / "grotesk" / "gothic" / "neue" / common humanist → Carlito
if (/(\bsans\b|\bgrotesk\b|grotesque|gothic|neue|deck|display|text|ui|interface|system|humanist|geometric|neo|standard|modern)/.test(cleaned)) {
return 'Carlito';
}
return null;
};
const mapFontFace = (fontFamily, textContent = '') => {
if (!fontFamily) {
return hasCJKChars(textContent) ? (_fc.cjk || 'Microsoft YaHei') : (_fc.latin || 'Carlito');
}
const fonts = fontFamily.split(',').map(f => f.trim().replace(/['"]/g, ''));
const cjkText = hasCJKChars(textContent);
// First pass: any safe / mapped font wins. Track the first unknown
// (non-generic, non-CJK-fragment) font for the heuristic second pass —
// the previous behaviour returned the raw unknown name, which meant the
// chain "VT323, Microsoft YaHei, monospace" never reached the YaHei →
// Microsoft YaHei fallback or the monospace generic for English text.
let firstUnknown = null;
for (const font of fonts) {
const lower = font.toLowerCase();
const isCJKFont = CJK_FONT_FRAGMENTS.some(f => lower.includes(f));
if (PPT_SAFE_FONTS.has(lower)) {
// CJK font specified but text has no CJK characters → use Latin font to avoid
// mixing Carlito and Microsoft YaHei for English-only content
if (isCJKFont && !cjkText) return _fc.latin || 'Carlito';
return font;
}
if (FONT_FALLBACK_MAP[lower]) {
const mapped = FONT_FALLBACK_MAP[lower];
const mappedIsCJK = CJK_FONT_FRAGMENTS.some(f => mapped.toLowerCase().includes(f));
// Keep script families coherent: CJK text should not fall back to a
// Latin web-font substitute, and Latin-only text should not inherit a
// Chinese face just because the surrounding template uses one.
if (!mappedIsCJK && cjkText) continue;
if (mappedIsCJK && !cjkText) continue;
return mapped;
}
if (['sans-serif', 'serif', 'monospace', 'cursive', 'fantasy'].includes(lower)) continue;
if (isCJKFont && !cjkText) continue;
if (isCJKFont || cjkText) return _fc.cjk || 'Microsoft YaHei';
// Unknown non-CJK font with non-CJK text — remember and keep looking
// so the next font in the chain (often a system fallback) gets a turn.
if (!firstUnknown) firstUnknown = font;
}
// Second pass: nothing in the chain matched the explicit map. Try the
// keyword heuristic on each font in order before giving up.
for (const font of fonts) {
const lower = font.toLowerCase();
if (PPT_SAFE_FONTS.has(lower)) continue;
if (FONT_FALLBACK_MAP[lower]) continue;
if (['sans-serif', 'serif', 'monospace', 'cursive', 'fantasy'].includes(lower)) continue;
const guessed = heuristicMapFont(font, cjkText);
if (guessed) {
const guessedIsCJK = CJK_FONT_FRAGMENTS.some(f => guessed.toLowerCase().includes(f));
if (!guessedIsCJK && cjkText) continue;
return guessed;
}
}
// Last-resort generic fallback by text script — do NOT return the raw
// unknown font name, because PPT will substitute it with whatever the
// viewer has installed (often unreadable for CJK). Carlito / Microsoft YaHei
// are always present in the deck workflow.
if (cjkText) return _fc.cjk || 'Microsoft YaHei';
return _fc.latin || 'Carlito';
};
const hasLatinChars = (text = "") => /[A-Za-z]/.test(text);
const hasDigitChars = (text = "") => /[0-90-9]/.test(text);
const hasEmojiChars = (text = "") => /[\u2600-\u27BF]|[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(text);
const hasIconSymbolChars = (text = "") => /[\u2190-\u21FF\u2300-\u23FF\u2900-\u297F\u2460-\u24FF\u25A0-\u27BF\u2B00-\u2BFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]/.test(text);
const isIconOnlyText = (text = "") => {
const t = String(text).trim();
if (!t) return false;
const compact = t.replace(/[\s\uFE0E\uFE0F\u200D\u20E3]/g, "");
if (!compact || Array.from(compact).length > 4) return false;
if (hasCJKChars(compact) || /[A-Za-z0-90-9]/.test(compact)) return false;
return hasIconSymbolChars(compact);
};
const shouldApplyPptBold = (fontWeight) => fontWeight === "bold" || (parseInt(fontWeight, 10) || 0) >= 700;
const isMediumOrBold = (fontWeight) => fontWeight === "bold" || (parseInt(fontWeight, 10) || 0) >= 600;
const shouldUseEmphasisFont = (text = "") => {
const t = String(text).trim();
if (!t || hasCJKChars(t) || !hasDigitChars(t)) return false;
if (t.length > 36) return false;
const compact = t.replace(/\s+/g, "");
const useful = compact.replace(/[^0-90-9A-Za-z.,:;_#%+\-\/()\[\]·]/g, "");
const ratio = useful.length / Math.max(1, compact.length);
const labelPrefix = /^(?:lv|no|honor|resource|step|part|section|chapter|page|slide|p|q|a|item|case|fig|table)\b/i.test(compact);
return ratio >= 0.65 || labelPrefix;
};
const sameRunOptions = (a = {}, b = {}) => {
const keys = ["fontFace", "fontSize", "color", "bold", "italic", "underline", "strike", "charSpacing", "transparency", "highlight", "subscript", "superscript"];
return keys.every(k => a[k] === b[k]);
};
const pushTextRun = (runs, text, options) => {
if (!text) return;
const cleanOptions = { ...options };
const prev = runs[runs.length - 1];
if (prev && sameRunOptions(prev.options, cleanOptions)) prev.text += text;
else runs.push({ text, options: cleanOptions });
};
const appendTextRuns = (runs, text, baseOptions = {}) => {
if (!text) return;
const fullText = String(text);
const whole = fullText.trim();
if (whole && !hasCJKChars(whole) && shouldUseEmphasisFont(whole)) {
pushTextRun(runs, fullText, { ...baseOptions, fontFace: _fc.emphasis || "Liberation Sans Narrow" });
return;
}
if (whole && hasEmojiChars(whole) && Array.from(whole).length <= 4) {
pushTextRun(runs, fullText, { ...baseOptions, fontFace: _fc.symbol || "Segoe UI Symbol", bold: false });
return;
}
const shouldSplit = (hasCJKChars(fullText) && (hasLatinChars(fullText) || hasDigitChars(fullText) || hasEmojiChars(fullText))) || hasEmojiChars(fullText);
if (!shouldSplit) {
pushTextRun(runs, fullText, baseOptions);
return;
}
const tokenRe = /([\uD800-\uDBFF][\uDC00-\uDFFF]|[A-Za-z]+\.?\d[A-Za-z0-9._:/+%\-]*|[+\-]?\d[\d.,:/+%\-]*(?:[A-Za-z]+)?|[A-Za-z]+(?:[-_][A-Za-z]+)*)/g;
let last = 0;
let match;
while ((match = tokenRe.exec(fullText)) !== null) {
if (match.index > last) pushTextRun(runs, fullText.slice(last, match.index), baseOptions);
const token = match[0];
const tokenOptions = { ...baseOptions };
if (hasEmojiChars(token)) {
tokenOptions.fontFace = _fc.symbol || "Segoe UI Symbol";
tokenOptions.bold = false;
} else if (shouldUseEmphasisFont(token)) {
tokenOptions.fontFace = _fc.emphasis || "Liberation Sans Narrow";
} else if (hasLatinChars(token) && hasCJKChars(fullText)) {
tokenOptions.fontFace = _fc.latin || "Carlito";
}
pushTextRun(runs, token, tokenOptions);
last = match.index + token.length;
}
if (last < fullText.length) pushTextRun(runs, fullText.slice(last), baseOptions);
};
const splitTextIntoFontRuns = (text, baseOptions = {}) => {
const runs = [];
appendTextRuns(runs, text, baseOptions);
return runs.length > 1 || (runs[0] && !sameRunOptions(runs[0].options, baseOptions)) ? runs : null;
};
// Unit conversion helpers — all use SCALE so 1280×720 HTML maps to 10×5.625" slide.
const pxToInch = (px) => (px / PX_PER_IN) * SCALE;
const relX = (x) => x - CANVAS_LEFT;
const relY = (y) => y - CANVAS_TOP;
const rectPosition = (rect) => ({
x: pxToInch(relX(rect.left)),
y: pxToInch(relY(rect.top)),
w: pxToInch(rect.width),
h: pxToInch(rect.height)
});
const pxToPoints = (pxStr) => parseFloat(pxStr) * PT_PER_PX * SCALE;
// Font sizes specifically: keep the floor readable, but allow dense CJK body
// text to stay closer to the browser layout. A blanket 8pt clamp makes
// 12.5px Chinese text about 14% wider on 1280px templates, which causes
// premature wrapping inside cards.
const MIN_FONT_SIZE_PT = 8;
const MIN_CJK_BODY_FONT_SIZE_PT = 7;
const getMinFontSize = (textContent = '') => hasCJKChars(textContent) ? MIN_CJK_BODY_FONT_SIZE_PT : MIN_FONT_SIZE_PT;
const pxToFontSize = (pxStr, textContent = '') => Math.max(getMinFontSize(textContent), pxToPoints(pxStr));
// Returns >1 when pxToFontSize had to clamp the value upward (i.e. the
// PPT font is bigger than the source HTML font would be at slide scale).
// Callers use this to bump the text-frame height accordingly so the
// larger PPT font doesn't overflow the box that was sized for the
// smaller browser font.
const fontSizeClampRatio = (pxStr, textContent = '') => {
const raw = pxToPoints(pxStr);
const minFontSize = getMinFontSize(textContent);
if (!Number.isFinite(raw) || raw >= minFontSize) return 1;
return minFontSize / Math.max(raw, 0.1);
};
const rgbToHex = (rgbStr) => {
// Handle transparent backgrounds by defaulting to white
if (!rgbStr) return 'FFFFFF';
if (rgbStr === 'rgba(0, 0, 0, 0)' || rgbStr === 'transparent') return 'FFFFFF';
// Already a hex value? (#xxx / #xxxxxx / #xxxxxxxx)
if (rgbStr.startsWith('#')) {
const hex = rgbStr.replace('#', '');
if (hex.length === 3) {
return hex.split('').map(c => c + c).join('').toUpperCase();
}
if (hex.length === 4) {
// #RGBA — drop alpha
return hex.slice(0, 3).split('').map(c => c + c).join('').toUpperCase();
}
if (hex.length === 6) return hex.toUpperCase();
if (hex.length === 8) return hex.slice(0, 6).toUpperCase();
return 'FFFFFF';
}
// RGB / RGBA (browsers normalize most named colors into rgb() form already)
const rgbMatch = rgbStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbMatch) {
return rgbMatch.slice(1).map(n => parseInt(n).toString(16).padStart(2, '0')).join('').toUpperCase();
}
// HSL / HSLA — convert to RGB then hex
const hslMatch = rgbStr.match(/hsla?\(\s*([\d.]+)(?:deg)?\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%/);
if (hslMatch) {
const h = parseFloat(hslMatch[1]) / 360;
const s = parseFloat(hslMatch[2]) / 100;
const l = parseFloat(hslMatch[3]) / 100;
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [r, g, b]
.map(v => Math.round(v * 255).toString(16).padStart(2, '0'))
.join('').toUpperCase();
}
// Fallback — unknown color spec
return 'FFFFFF';
};
const extractAlpha = (rgbStr) => {
if (!rgbStr) return null;
// RGBA
const rgbaMatch = rgbStr.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/);
if (rgbaMatch && rgbaMatch[4]) {
const alpha = parseFloat(rgbaMatch[4]);
if (!Number.isFinite(alpha) || alpha >= 1) return null;
return Math.round((1 - alpha) * 100);
}
// HSLA
const hslaMatch = rgbStr.match(/hsla\(\s*[\d.]+(?:deg)?\s*,\s*[\d.]+%\s*,\s*[\d.]+%\s*,\s*([\d.]+)\)/);
if (hslaMatch && hslaMatch[1]) {
const alpha = parseFloat(hslaMatch[1]);
if (!Number.isFinite(alpha) || alpha >= 1) return null;
return Math.round((1 - alpha) * 100);
}
// 8-digit hex (#RRGGBBAA)
if (rgbStr.startsWith('#') && rgbStr.length === 9) {
const a = parseInt(rgbStr.slice(7, 9), 16) / 255;
if (!Number.isFinite(a) || a >= 1) return null;
return Math.round((1 - a) * 100);
}
return null;
};
// Combine an rgba/hsla color's alpha channel with the element's CSS `opacity`
// into a single transparency value (0–100, where higher = more transparent —
// the convention pptxgenjs expects). Both reduce visibility, so we use:
// finalOpacity = colorOpacity × elementOpacity
// transparency = (1 − finalOpacity) × 100
// Returns null when neither dimension reduces visibility (i.e. fully opaque).
//
// This is what the user's `<span class="watermark">液</span>` case needs:
// its color is opaque rgb(...) but `opacity: 0.05` makes it nearly invisible.
// Without this helper, the watermark renders at 100% opacity in PPT.
const computeTransparency = (colorStr, opacityStr) => {
const colorT = extractAlpha(colorStr); // 0..100 or null
const cssOpacity = parseFloat(opacityStr);
const opacityT = (Number.isFinite(cssOpacity) && cssOpacity < 1)
? Math.round((1 - cssOpacity) * 100)
: null;
if (colorT === null && opacityT === null) return null;
const colorOpacity = (100 - (colorT || 0)) / 100;
const elementOpacity = (100 - (opacityT || 0)) / 100;
const finalT = Math.round((1 - colorOpacity * elementOpacity) * 100);
return finalT > 0 ? finalT : null;
};
const splitCssTopLevel = (value) => {
const out = [];
let cur = '';
let depth = 0;
for (const ch of String(value || '')) {
if (ch === '(') depth++;
else if (ch === ')') depth = Math.max(0, depth - 1);
if (ch === ',' && depth === 0) {
out.push(cur.trim());
cur = '';
} else {
cur += ch;
}
}
if (cur.trim()) out.push(cur.trim());
return out;
};
const cssGradientAngle = (dir) => {
const d = String(dir || '').trim().toLowerCase();
const deg = d.match(/(-?[\d.]+)deg/);
if (deg) return ((parseFloat(deg[1]) % 360) + 360) % 360;
const turn = d.match(/(-?[\d.]+)turn/);
if (turn) return ((parseFloat(turn[1]) * 360 % 360) + 360) % 360;
if (/to\s+bottom\s+right/.test(d)) return 135;
if (/to\s+top\s+right/.test(d)) return 45;
if (/to\s+bottom\s+left/.test(d)) return 225;
if (/to\s+top\s+left/.test(d)) return 315;
if (/to\s+right/.test(d)) return 90;
if (/to\s+left/.test(d)) return 270;
if (/to\s+bottom/.test(d)) return 180;
if (/to\s+top/.test(d)) return 0;
return 180;
};
const parseLinearGradientLayer = (layer) => {
const m = String(layer || '').trim().match(/^linear-gradient\((.*)\)$/i);
if (!m) return null;
const parts = splitCssTopLevel(m[1]);
if (parts.length < 2) return null;
let angle = 180;
let stopParts = parts;
if (/^(to\b|[-\d.]+(?:deg|turn|rad)\b)/i.test(parts[0])) {
angle = cssGradientAngle(parts[0]);
stopParts = parts.slice(1);
}
const stops = stopParts.map((part, idx) => {
const p = part.trim();
const cm = p.match(/^(rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-fA-F]{3,8}|transparent|[a-zA-Z]+)\s*(.*)$/i);
if (!cm) return null;
const posM = cm[2].match(/(-?[\d.]+)%/);
return {
color: cm[1],
pos: posM ? Math.max(0, Math.min(1, parseFloat(posM[1]) / 100)) : (stopParts.length === 1 ? 0 : idx / (stopParts.length - 1))
};
}).filter(Boolean);
return stops.length >= 2 ? { angle, stops } : null;
};
// Parse a single `radial-gradient(...)` layer. Handles the common authored
// forms: an optional leading config (shape / explicit "<rx>% <ry>%" size /
// "at <x>% <y>%" position) followed by colour stops. Position defaults to
// center; size defaults to cover (max dimension). Used both for mesh cover
// backgrounds and standalone glow "orb" divs.
const parseRadialGradientLayer = (layer) => {
const m = String(layer || '').trim().match(/^radial-gradient\((.*)\)$/i);
if (!m) return null;
const parts = splitCssTopLevel(m[1]);
if (parts.length < 2) return null;
let cxPct = 50, cyPct = 50, rxPct = null, ryPct = null, shape = 'ellipse';
let stopParts = parts;
const first = parts[0].trim();
const isColorFirst = /^(rgba?\(|hsla?\(|#|transparent\b)/i.test(first);
const isConfig = !isColorFirst &&
/(^|\s)(at|circle|ellipse|closest-side|closest-corner|farthest-side|farthest-corner)\b/i.test(first) ||
(!isColorFirst && /^[\d.]+%\s+[\d.]+%/.test(first));
if (isConfig) {
stopParts = parts.slice(1);
const atM = first.match(/at\s+([\d.]+)%\s+([\d.]+)%/i);
if (atM) { cxPct = parseFloat(atM[1]); cyPct = parseFloat(atM[2]); }
if (/\bcircle\b/i.test(first)) shape = 'circle';
const sizeM = first.replace(/\bat\b.*$/i, '').match(/([\d.]+)%\s+([\d.]+)%/);
if (sizeM) { rxPct = parseFloat(sizeM[1]); ryPct = parseFloat(sizeM[2]); }
}
const stops = stopParts.map((part, idx) => {
const p = part.trim();
const cm = p.match(/^(rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-fA-F]{3,8}|transparent|[a-zA-Z]+)\s*(.*)$/i);
if (!cm) return null;
const posM = cm[2].match(/(-?[\d.]+)%/);
return {
color: cm[1],
pos: posM ? Math.max(0, Math.min(1, parseFloat(posM[1]) / 100)) : (stopParts.length === 1 ? 0 : idx / (stopParts.length - 1))
};
}).filter(Boolean);
return stops.length >= 2 ? { type: 'radial', cxPct, cyPct, rxPct, ryPct, shape, stops } : null;
};
const isFullyTransparentCssColor = (colorStr) => {
const c = String(colorStr || '').trim().toLowerCase();
if (!c) return false;
if (c === 'transparent') return true;
const t = extractAlpha(c);
return typeof t === 'number' && t >= 100;
};
const hasTextBackgroundClip = (computed) => {
const clip = [
computed.backgroundClip,
computed.webkitBackgroundClip,
computed.WebkitBackgroundClip
].filter(Boolean).join(' ').toLowerCase();
return /\btext\b/.test(clip);
};
const pickGradientTextColor = (computed) => {
const bgImage = computed.backgroundImage;
if (!bgImage || bgImage === 'none') return null;
const layers = splitCssTopLevel(bgImage)
.map(parseLinearGradientLayer)
.filter(Boolean);
for (const layer of layers) {
const visibleStops = layer.stops.filter(stop => {
const t = extractAlpha(stop.color);
return t === null || t < 100;
});
if (visibleStops.length === 0) continue;
const picked = visibleStops.reduce((best, stop) =>
Math.abs(stop.pos - 0.5) < Math.abs(best.pos - 0.5) ? stop : best
);
return rgbToHex(picked.color);
}
return null;
};
const findGradientTextColor = (computed, element = null) => {
if (hasTextBackgroundClip(computed)) {
const ownColor = pickGradientTextColor(computed);
if (ownColor) return ownColor;
}
let cur = element ? element.parentElement : null;
while (cur && cur !== document.body) {
const curStyle = window.getComputedStyle(cur);
if (hasTextBackgroundClip(curStyle)) {
const inheritedColor = pickGradientTextColor(curStyle);
if (inheritedColor) return inheritedColor;
}
cur = cur.parentElement;
}
return null;
};
const resolveTextPaintStyle = (computed, element = null) => {
const fillColor = computed.webkitTextFillColor && computed.webkitTextFillColor !== 'currentcolor'
? computed.webkitTextFillColor
: computed.color;
const transparentTextPaint =
isFullyTransparentCssColor(fillColor) ||
isFullyTransparentCssColor(computed.color);
if (transparentTextPaint) {
const gradientColor = findGradientTextColor(computed, element);
if (gradientColor) {
return {
color: gradientColor,
transparency: computeTransparency(null, computed.opacity),
_gradientTextFallback: true
};
}
}
return {
color: rgbToHex(fillColor),
transparency: computeTransparency(fillColor, computed.opacity),
_gradientTextFallback: false
};
};
const renderLinearGradientBackground = (bgImage, widthPx, heightPx) => {
const layers = splitCssTopLevel(bgImage)
.map(l => parseLinearGradientLayer(l) || parseRadialGradientLayer(l))
.filter(Boolean);
if (layers.length === 0 || widthPx <= 0 || heightPx <= 0) return null;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(widthPx));
canvas.height = Math.max(1, Math.round(heightPx));
const ctx = canvas.getContext('2d');
const cx = canvas.width / 2;
const cy = canvas.height / 2;
// CSS paints the first-listed layer on top, so draw in reverse (last first).
for (const layer of layers.reverse()) {
if (layer.type === 'radial') {
const rcx = (layer.cxPct / 100) * canvas.width;
const rcy = (layer.cyPct / 100) * canvas.height;
// Default size (no explicit "<rx>% <ry>%") is farthest-corner: the
// distance from the center to the furthest box corner. Using the box's
// max dimension instead pushes the fade past the edges, so the glow
// gets clipped into a boxy square (visible on the cover "orb" glows).
const dx = Math.max(rcx, canvas.width - rcx);
const dy = Math.max(rcy, canvas.height - rcy);
const farCorner = Math.sqrt(dx * dx + dy * dy);
let rx = layer.rxPct != null ? (layer.rxPct / 100) * canvas.width : farCorner;
let ry = layer.ryPct != null ? (layer.ryPct / 100) * canvas.height : farCorner;
rx = Math.max(1, rx); ry = Math.max(1, ry);
ctx.save();
ctx.translate(rcx, rcy);
ctx.scale(1, ry / rx); // squash the circular gradient into an ellipse
const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, rx);
for (const stop of layer.stops) grad.addColorStop(Math.max(0, Math.min(1, stop.pos)), stop.color);
ctx.fillStyle = grad;
// Fill generously in the transformed space so the ellipse covers the box.
ctx.fillRect(-canvas.width * 2, -canvas.height * 2, canvas.width * 4, canvas.height * 4);
ctx.restore();
} else {
const rad = layer.angle * Math.PI / 180;
const vx = Math.sin(rad);
const vy = -Math.cos(rad);
const len = Math.abs(canvas.width * vx) + Math.abs(canvas.height * vy) || Math.max(canvas.width, canvas.height);
const grad = ctx.createLinearGradient(cx - vx * len / 2, cy - vy * len / 2, cx + vx * len / 2, cy + vy * len / 2);
for (const stop of layer.stops) grad.addColorStop(stop.pos, stop.color);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}
return canvas.toDataURL('image/png');
};
const applyTextTransform = (text, textTransform) => {
if (textTransform === 'uppercase') return text.toUpperCase();
if (textTransform === 'lowercase') return text.toLowerCase();
if (textTransform === 'capitalize') {
return text.replace(/\b\w/g, c => c.toUpperCase());
}
return text;
};
const normalizeWhitespaceForPpt = (text, whiteSpace = 'normal') => {
let out = String(text || '').replace(/\r\n?/g, '\n');
if (whiteSpace === 'pre' || whiteSpace === 'pre-wrap' || whiteSpace === 'break-spaces') {
return out;
}
if (whiteSpace === 'pre-line') {
return out.replace(/[ \t\f\v]+/g, ' ');
}
return out.replace(/\s+/g, ' ');
};
// Extract rotation angle from CSS transform and writing-mode
const getRotation = (transform, writingMode) => {
let angle = 0;
// Handle writing-mode first
// PowerPoint: 90° = text rotated 90° clockwise (reads top to bottom, letters upright)
// PowerPoint: 270° = text rotated 270° clockwise (reads bottom to top, letters upright)
if (writingMode === 'vertical-rl') {
// vertical-rl alone = text reads top to bottom = 90° in PowerPoint
angle = 90;
} else if (writingMode === 'vertical-lr') {
// vertical-lr alone = text reads bottom to top = 270° in PowerPoint
angle = 270;
}
// Then add any transform rotation
if (transform && transform !== 'none') {
// Try to match rotate() function
const rotateMatch = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/);
if (rotateMatch) {
angle += parseFloat(rotateMatch[1]);
} else {
// Browser may compute as matrix - extract rotation from matrix
const matrixMatch = transform.match(/matrix\(([^)]+)\)/);
if (matrixMatch) {
const values = matrixMatch[1].split(',').map(parseFloat);
// matrix(a, b, c, d, e, f) where rotation = atan2(b, a)
const matrixAngle = Math.atan2(values[1], values[0]) * (180 / Math.PI);
angle += Math.round(matrixAngle);
}
}
}
// Normalize to 0-359 range
angle = angle % 360;
if (angle < 0) angle += 360;
return angle === 0 ? null : angle;
};
// Get position/dimensions accounting for rotation
const getPositionAndSize = (el, rect, rotation) => {
if (rotation === null) {
return { x: rect.left, y: rect.top, w: rect.width, h: rect.height };
}
// For 90° or 270° rotations, swap width and height
// because PowerPoint applies rotation to the original (unrotated) box
const isVertical = rotation === 90 || rotation === 270;
if (isVertical) {
// The browser shows us the rotated dimensions (tall box for vertical text)
// But PowerPoint needs the pre-rotation dimensions (wide box that will be rotated)
// So we swap: browser's height becomes PPT's width, browser's width becomes PPT's height
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
return {
x: centerX - rect.height / 2,
y: centerY - rect.width / 2,
w: rect.height,
h: rect.width
};
}
// For other rotations, use element's offset dimensions
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
return {
x: centerX - el.offsetWidth / 2,
y: centerY - el.offsetHeight / 2,
w: el.offsetWidth,
h: el.offsetHeight
};
};
// Parse CSS box-shadow into PptxGenJS shadow properties
const parseBoxShadow = (boxShadow) => {
if (!boxShadow || boxShadow === 'none') return null;
// Browser computed style format: "rgba(0, 0, 0, 0.3) 2px 2px 8px 0px [inset]"
// CSS format: "[inset] 2px 2px 8px 0px rgba(0, 0, 0, 0.3)"
const insetMatch = boxShadow.match(/inset/);
// IMPORTANT: PptxGenJS/PowerPoint doesn't properly support inset shadows
// Only process outer shadows to avoid file corruption
if (insetMatch) return null;
// Extract color first (rgba or rgb at start)
const colorMatch = boxShadow.match(/rgba?\([^)]+\)/);
// Extract numeric values (handles both px and pt units)
const parts = boxShadow.match(/([-\d.]+)(px|pt)/g);
if (!parts || parts.length < 2) return null;
const offsetX = parseFloat(parts[0]);
const offsetY = parseFloat(parts[1]);
const blur = parts.length > 2 ? parseFloat(parts[2]) : 0;
// Calculate angle from offsets (in degrees, 0 = right, 90 = down)
let angle = 0;
if (offsetX !== 0 || offsetY !== 0) {
angle = Math.atan2(offsetY, offsetX) * (180 / Math.PI);
if (angle < 0) angle += 360;
}
// Calculate offset distance (hypotenuse)
const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY) * PT_PER_PX;
// Extract opacity from rgba/hsla. PptxGenJS expects 0..1 opacity, not 0..100.
let opacity = 0.5;
if (colorMatch) {
const colorTransparency = extractAlpha(colorMatch[0]);
if (colorTransparency !== null) {
opacity = (100 - colorTransparency) / 100;
}
}
return {
type: 'outer',
angle: Math.round(angle),
blur: blur * 0.75, // Convert to points
color: colorMatch ? rgbToHex(colorMatch[0]) : '000000',
offset: offset,
opacity
};
};
// Parse inline formatting tags (<b>, <i>, <u>, <strong>, <em>, <span>) into text runs
const parseInlineFormatting = (element, baseOptions = {}, runs = [], baseTextTransform = (x) => x) => {
let prevNodeIsText = false;
const elementWhiteSpace = window.getComputedStyle(element).whiteSpace;
element.childNodes.forEach((node) => {
let textTransform = baseTextTransform;
const isText = node.nodeType === Node.TEXT_NODE || node.tagName === 'BR';
if (isText) {
const text = node.tagName === 'BR' ? '\n' : textTransform(normalizeWhitespaceForPpt(node.textContent, elementWhiteSpace));
appendTextRuns(runs, text, baseOptions);
} else if (node.nodeType === Node.ELEMENT_NODE && node.textContent.trim()) {
// Icon element appearing inline inside a text container — emit mapped
// glyph with a symbol-capable font instead of the raw ligature name.
if (node._isIcon) {
const iconComputed = window.getComputedStyle(node);
runs.push({
text: node._iconGlyph,
options: {
...baseOptions,
fontFace: 'Segoe UI Symbol',
color: rgbToHex(iconComputed.color),
_isIcon: true
}
});
prevNodeIsText = false;
return;
}
const options = { ...baseOptions };
const computed = window.getComputedStyle(node);
// Handle inline elements with computed styles
const tag = node.tagName;
if (tag === 'SPAN' || tag === 'B' || tag === 'STRONG' || tag === 'I' || tag === 'EM' || tag === 'U' ||
tag === 'A' || tag === 'SUB' || tag === 'SUP' || tag === 'S' || tag === 'DEL' || tag === 'STRIKE' ||
tag === 'MARK' || tag === 'CODE' || tag === 'SMALL' || tag === 'BIG' || tag === 'CITE' || tag === 'ABBR' ||
tag === 'TIME' || tag === 'LABEL') {
// If this inline-tag has been promoted to block-display via CSS
// (e.g. <h1>看见<span class="accent" style="display:block">无家可归者</span></h1>
// where `.accent { display:block }` puts the span on its own
// visual line), insert a newline so the run starts on a new
// paragraph line in PowerPoint instead of continuing inline.
const dispMode = computed.display;
const parentComputed = node.parentElement ? window.getComputedStyle(node.parentElement) : null;
const parentIsRowFlex = parentComputed &&
(parentComputed.display === 'flex' || parentComputed.display === 'inline-flex') &&
!String(parentComputed.flexDirection || 'row').startsWith('column');
const isBlockSpan = !parentIsRowFlex &&
(dispMode === 'block' || dispMode === 'flex' || dispMode === 'grid' || dispMode === 'list-item');
if (isBlockSpan) {
const prevRun = runs[runs.length - 1];
if (prevRun && !/\n$/.test(prevRun.text)) {
prevRun.text += '\n';
} else if (!prevRun) {
// Span is the first child — no leading newline needed.
}
}
const isBold = shouldApplyPptBold(computed.fontWeight);
if (isBold && !shouldSkipBold(computed.fontFamily)) options.bold = true;
if (computed.fontStyle === 'italic') options.italic = true;
if (computed.textDecoration && computed.textDecoration.includes('underline')) options.underline = true;
// line-through (DEL/S/STRIKE or any element with text-decoration:line-through)
if (tag === 'S' || tag === 'DEL' || tag === 'STRIKE' ||
(computed.textDecoration && computed.textDecoration.includes('line-through'))) {
options.strike = true;
}
// Hyperlink — preserve URL & tooltip on the run
if (tag === 'A' && node.href) {
options.hyperlink = { url: node.href };
if (node.title) options.hyperlink.tooltip = node.title;
}
// Subscript / superscript — pptxgenjs supports {subscript: true} / {superscript: true}
if (tag === 'SUB') options.subscript = true;
if (tag === 'SUP') options.superscript = true;
// Keep inline runs on the same resolved font as the browser-computed span.
// Without this, PowerPoint may substitute a wider default font at run boundaries.
if (computed.fontFamily) options.fontFace = mapFontFace(computed.fontFamily, node.textContent);
// <code> — use a monospace stand-in font.
if (tag === 'CODE') options.fontFace = 'Courier New';
// Inline background highlight: <mark> and styled spans like .mark.
const inlineBg = computed.backgroundColor;
const hasInlineBg = inlineBg && inlineBg !== 'rgba(0, 0, 0, 0)' && inlineBg !== 'transparent';
if (tag === 'MARK' || hasInlineBg) {
options.highlight = hasInlineBg ? rgbToHex(inlineBg) : 'FFFF00';
}
const inlineTextPaint = resolveTextPaintStyle(computed, node);
if ((computed.color && computed.color !== 'rgb(0, 0, 0)') || inlineTextPaint._gradientTextFallback) {
options.color = inlineTextPaint.color;
}
// Combine color alpha + element opacity into a single per-run transparency.
// For CSS gradient text, transparent glyph paint is replaced with a
// representative solid color, so only element opacity applies.
if (inlineTextPaint.transparency !== null) options.transparency = inlineTextPaint.transparency;
if (computed.fontSize) options.fontSize = pxToFontSize(computed.fontSize, node.textContent);
if (computed.letterSpacing && computed.letterSpacing !== "normal") options.charSpacing = pxToPoints(computed.letterSpacing);
if (isMediumOrBold(computed.fontWeight) && shouldUseEmphasisFont(node.textContent)) options.fontFace = _fc.emphasis || "Liberation Sans Narrow";
// Apply text-transform on the span element itself
if (computed.textTransform && computed.textTransform !== 'none') {
const transformStr = computed.textTransform;
textTransform = (text) => applyTextTransform(text, transformStr);
}
// <sub>/<sup>/<small>/<big>: leaf-style — emit text directly with run options
if (tag === 'SUB' || tag === 'SUP' || tag === 'SMALL' || tag === 'BIG' || tag === 'A') {
// Flatten any nested formatting inside an <a>: process children with current options
if (node.childNodes.length > 0 && (tag === 'A' || tag === 'SMALL' || tag === 'BIG')) {
parseInlineFormatting(node, options, runs, textTransform);
} else {
const txt = textTransform(normalizeWhitespaceForPpt(node.textContent, computed.whiteSpace));
if (txt.trim()) appendTextRuns(runs, txt, options);
}
} else {
// Recursively process the child node — flatten nested spans into multiple runs.
parseInlineFormatting(node, options, runs, textTransform);
}
// If this span was promoted to block-display, append a trailing
// newline so any following sibling text/spans start on a fresh line.
if (isBlockSpan) {
const lastRun = runs[runs.length - 1];
if (lastRun && !/\n$/.test(lastRun.text)) lastRun.text += '\n';
prevNodeIsText = false;
} else if (parentIsRowFlex && node.nextSibling && node.nextSibling.textContent.trim()) {
const lastRun = runs[runs.length - 1];
if (lastRun && !/\s$/.test(lastRun.text)) appendTextRuns(runs, ' ', baseOptions);
}
} else {
// Unknown inline element (e.g. <a>, <code>, <sub>, <sup>, <mark>) —
// extract its text content as plain text to avoid silent data loss.
const text = textTransform(normalizeWhitespaceForPpt(node.textContent, computed.whiteSpace));
if (text.trim()) runs.push({ text, options: { ...baseOptions } });
}
}
prevNodeIsText = isText;
});
// Trim leading space from first run and trailing space from last run
if (runs.length > 0) {
runs[0].text = runs[0].text.replace(/^\s+/, '');
runs[runs.length - 1].text = runs[runs.length - 1].text.replace(/\s+$/, '');
}
// Collapse whitespace around explicit line breaks. Source HTML like
// <h1>foo<br>\n <span>bar</span></h1>
// produces runs ["foo\n ", "bar"] — pptxgenjs splits on '\n' and the
// standalone " " becomes a visible blank paragraph between "foo" and
// "bar". Strip whitespace immediately before/after each '\n' so we get
// a clean two-line layout. Also trim leading whitespace from a run when
// the previous run ends with '\n'.
for (const run of runs) {
run.text = run.text.replace(/[ \t]+\n/g, '\n').replace(/\n[ \t]+/g, '\n');
}
for (let i = 1; i < runs.length; i++) {
if (runs[i - 1].text.endsWith('\n')) {
runs[i].text = runs[i].text.replace(/^[ \t]+/, '');
}
}
// PptxGenJS splits rich-text runs containing \n by marking every
// resulting segment as breakLine, so a run like "line1\nline2" followed by
// a styled span incorrectly puts the span into a new paragraph. Convert
// explicit line breaks to breakLine on the preceding run ourselves.
const expandedRuns = [];
for (const run of runs) {
const parts = String(run.text || '').split('\n');
parts.forEach((part, idx) => {
if (idx > 0 && expandedRuns.length > 0) {
expandedRuns[expandedRuns.length - 1].options = {
...expandedRuns[expandedRuns.length - 1].options,
breakLine: true
};
}
if (part.length > 0) {
expandedRuns.push({ text: part, options: { ...run.options } });
}
});
}
return expandedRuns.filter(r => r.text.length > 0);
};
// Extract background from the actual slide canvas, not the presentation body/chrome.
const body = document.body;
const canvasStyle = window.getComputedStyle(slideRoot || body);
const bgImage = canvasStyle.backgroundImage;
const bgColor = canvasStyle.backgroundColor;
// Collect validation errors
const errors = [];
// Collect non-blocking warnings (browser scope)
const warnings = [];
const extractNotesText = (noteEl) => {
const items = Array.from(noteEl.querySelectorAll('li'))
.map(li => normalizeWhitespaceForPpt(li.textContent, 'normal').trim())
.filter(Boolean);
if (items.length > 0) return items.map(t => '- ' + t).join('\n');
return normalizeWhitespaceForPpt(noteEl.textContent, 'pre-line').trim();
};
const noteElements = Array.from(document.querySelectorAll('[data-notes]'));
const notes = noteElements
.map(extractNotesText)
.filter(Boolean)
.join('\n');
// Notes are speaker metadata, not slide artwork. Remove them after extraction
// so later element collection never renders them into the visible slide.
noteElements.forEach(noteEl => noteEl.remove());
let background;
if (bgImage && bgImage !== 'none') {
// Extract URL from url("...") or url(...)
const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/);
if (urlMatch) {
background = {
type: 'image',
path: urlMatch[1]
};
} else {
// Not a url() — this is a CSS gradient (linear/radial mesh) used as the
// slide canvas background (e.g. multi_3 cover `.cover-bg` grad-mesh).
// Rasterize it to a PNG so the full colour transition survives; the flat
// bgColor is kept only as a fallback if the raster fails.
const canvasGrad = renderLinearGradientBackground(bgImage, bodyWPx, bodyHPx);
if (canvasGrad) {
background = {
type: 'image',
dataUrl: canvasGrad,
fallbackColor: rgbToHex(bgColor)
};
} else {
background = {
type: 'color',
value: rgbToHex(bgColor)
};
}
}
} else {
background = {
type: 'color',
value: rgbToHex(bgColor)
};
}
// Process all elements
const elements = [];
const placeholders = [];
const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI'];
// Block container elements treated identically to DIV (background → shape, unwrapped text → error)
const blockContainerTags = new Set(['DIV', 'SECTION', 'HEADER', 'FOOTER', 'ARTICLE', 'ASIDE', 'MAIN', 'NAV', 'FIGURE']);
// Inline elements that LLMs sometimes use as standalone block text containers
const inlineAsBlockTags = new Set(['SPAN', 'STRONG', 'B', 'EM', 'I', 'A', 'LABEL', 'CODE', 'MARK', 'TIME', 'CITE', 'ABBR', 'S', 'U']);
// Block elements that are text-leaf-ish (rarely contain P/H* children)
const leafBlockTags = new Set(['FIGCAPTION', 'DT', 'DD', 'CAPTION', 'BLOCKQUOTE', 'TD', 'TH', 'SUMMARY']);
const processed = new Set();
// (box-sizing: border-box is already applied at the top of extractSlideData.)
// ── Icon-font handling ──
// <i class="material-icons">spa</i> etc. use a webfont to render icon glyphs.
// In PPT we cannot embed those fonts, so the raw ligature name ("spa") would
// render as literal text. Map known names to Unicode equivalents and treat as
// a short symbol run with a PPT-safe font.
// Material Icons → Unicode glyph map.
// Strongly prefer single-codepoint BMP symbols (Geometric Shapes, Dingbats,
// Misc Symbols, Arrows blocks) — they render in nearly every PPT-safe font
// and produce the clean monochrome look of Material Icons. Multi-codepoint
// emoji are a last resort for concepts (people, megaphone, plant) with no
// BMP equivalent. The icon font is set to a symbol font (Segoe UI Symbol)
// so even emoji codepoints render as monochrome glyphs in PowerPoint.
const MATERIAL_ICON_MAP = {
// Search / view / visibility
search: '⌕', visibility: '◉', visibility_off: '⊘', preview: '◉',
pageview: '⌕', find_in_page: '⌕', zoom_in: '⊕', zoom_out: '⊖',
// People / community
person: '☻', person_outline: '☺', people: '♟', people_outline: '♟',
group: '♟', groups: '♟', group_add: '♟', person_add: '☻',
family_restroom: '♟', diversity_1: '♟', diversity_3: '♟',
account_circle: '◉', supervised_user_circle: '◉', face: '☻',
handshake: '✻', volunteer_activism: '♥', favorite_border: '♡',
favorite: '♥', loyalty: '♥',
// Communication
campaign: '◖', record_voice_over: '◖', voice_chat: '◖',
mail: '✉', email: '✉', drafts: '✉', mark_email_read: '✉',
mark_email_unread: '✉', forward_to_inbox: '✉', send: '➤',
phone: '☎', call: '☎', call_end: '☎', dialpad: '⌨',
chat: '◌', chat_bubble: '◌', chat_bubble_outline: '◌',
forum: '◌', sms: '◌', comment: '◌', message: '◌',
question_answer: '◌', notifications: '◔', notifications_active: '◔',
// Actions / status
check: '✓', check_circle: '✓', check_circle_outline: '✓',
done: '✓', done_all: '✓', done_outline: '✓', verified: '✓',
task_alt: '✓', fact_check: '✓', playlist_add_check: '✓',
close: '✕', cancel: '✕', highlight_off: '✕', block: '⊘', do_not_disturb: '⊘',
add: '+', add_circle: '⊕', add_circle_outline: '⊕', remove: '−',
remove_circle: '⊖', remove_circle_outline: '⊖',
edit: '✎', mode_edit: '✎', create: '✎', draw: '✎', border_color: '✎',
delete: '✕', delete_outline: '✕', delete_forever: '✕',
refresh: '↻', sync: '↻', autorenew: '↻', cached: '↻', update: '↻',
restore: '↺', undo: '↶', redo: '↷',
info: 'ⓘ', info_outline: 'ⓘ', warning: '⚠', warning_amber: '⚠',
error: '⊗', error_outline: '⊗', report: '⚠', report_problem: '⚠',
help: '?', help_outline: '?', help_center: '?', live_help: '?',
question_mark: '?', priority_high: '!',
// Arrows / navigation
arrow_forward: '→', arrow_back: '←', arrow_upward: '↑', arrow_downward: '↓',
arrow_forward_ios: '›', arrow_back_ios: '‹',
chevron_right: '›', chevron_left: '‹',
navigate_next: '›', navigate_before: '‹',
keyboard_arrow_right: '›', keyboard_arrow_left: '‹',
keyboard_arrow_up: '▲', keyboard_arrow_down: '▼',
expand_more: '▼', expand_less: '▲',
arrow_drop_down: '▼', arrow_drop_up: '▲',
arrow_right: '▶', arrow_left: '◀',
play_arrow: '▶', pause: '⏸', stop: '■', skip_next: '⏭', skip_previous: '⏮',
fast_forward: '⏩', fast_rewind: '⏪',
first_page: '⇤', last_page: '⇥',
north: '↑', south: '↓', east: '→', west: '←',
north_east: '↗', north_west: '↖', south_east: '↘', south_west: '↙',
swap_horiz: '⇄', swap_vert: '⇅', sort: '⇅', sort_by_alpha: '⇅',
compare_arrows: '⇄', sync_alt: '⇄', import_export: '⇅',
// Menu / list
menu: '☰', menu_open: '☰', filter_list: '☰', tune: '⚙', sort: '⇅',
view_list: '☰', view_module: '▦', view_quilt: '▦', view_comfy: '▦',
grid_view: '▦', dashboard: '▦', apps: '▦', widgets: '▦',
more_horiz: '⋯', more_vert: '⋮', drag_handle: '☰', drag_indicator: '⋮',
// Files / docs
folder: '▣', folder_open: '▣', folder_shared: '▣', create_new_folder: '▣',
file_copy: '⎘', content_copy: '⎘', content_paste: '⎘', content_cut: '✂',
attach_file: '⫶', attachment: '⫶', upload_file: '↑', cloud_upload: '↑',
cloud_download: '↓', download: '↓', download_for_offline: '↓',
file_download: '↓', file_upload: '↑', save: '⤓', save_alt: '⤓',
description: '▤', article: '▤', text_snippet: '▤', notes: '▤',
assignment: '▤', list_alt: '▤', receipt: '▤', receipt_long: '▤',
book: '▤', menu_book: '▤', auto_stories: '▤', import_contacts: '▤',
bookmark: '❏', bookmark_border: '❏', bookmarks: '❏', collections_bookmark: '❏',
flag: '⚑', outlined_flag: '⚐', tour: '⚑',
tag: '#', label: '⌧', label_outline: '⌧', new_releases: '✦',
print: '⎙', share: '↗', launch: '↗', open_in_new: '↗', link: '⌘',
// Settings / tools
settings: '⚙', settings_applications: '⚙', settings_suggest: '⚙',
build: '⚒', build_circle: '⚒', construction: '⚒', engineering: '⚒',
handyman: '⚒', home_repair_service: '⚒',
// Security
lock: '⚿', lock_outline: '⚿', lock_open: '◯', no_encryption: '◯',
enhanced_encryption: '⚿', vpn_key: '⚿', security: '⛨', shield: '⛨',
verified_user: '⛨', gpp_good: '⛨', admin_panel_settings: '⛨',
privacy_tip: 'ⓘ', policy: '▤', gavel: '⚖', balance: '⚖',
// Stars / awards / highlights
star: '★', star_border: '☆', star_half: '⯨', star_outline: '☆',
grade: '★', stars: '★', auto_awesome: '✦', auto_awesome_motion: '✦',
tips_and_updates: '◆', insights: '◆', lightbulb: '◆', lightbulb_outline: '◇',
whatshot: '✦', local_fire_department: '✦', emoji_events: '♛', trophy: '♛',
military_tech: '✦', workspace_premium: '♛', diamond: '◆',
celebration: '✦', sparkles: '✦', new_releases: '✦',
thumb_up: '👍', thumb_down: '👎', recommend: '👍',
// Charts / data
trending_up: '↗', trending_down: '↘', trending_flat: '→',
show_chart: '⌇', timeline: '⌇', stacked_line_chart: '⌇',
bar_chart: '▦', equalizer: '▤', leaderboard: '▦',
pie_chart: '◐', donut_large: '◐', donut_small: '◑',
analytics: '▦', monitoring: '⌇', query_stats: '⌇',
data_usage: '◐', insights: '◆', poll: '▦',
// Time / calendar
access_time: '◔', schedule: '◔', timer: '◔', timelapse: '◔',
hourglass_top: '⧗', hourglass_bottom: '⧗', hourglass_empty: '⧖',
alarm: '◔', alarm_on: '◔', snooze: '◔', av_timer: '◔', watch_later: '◔',
calendar_today: '▦', calendar_month: '▦', date_range: '▦',
event: '▦', event_available: '▦', event_note: '▦', today: '▦',
// Money / commerce
attach_money: '$', monetization_on: '$', payments: '$', paid: '$',
account_balance: '🏛', account_balance_wallet: '◫', savings: '◫', wallet: '◫',
credit_card: '▭', payment: '▭', local_atm: '$',
shopping_cart: '🛒', shopping_bag: '🛍', shopping_basket: '🛒',
add_shopping_cart: '🛒', store: '🏪', storefront: '🏪',
local_offer: '⌧', sell: '⌧', loyalty: '♥',
// Tech / device
computer: '▢', desktop_windows: '▢', laptop: '▢', laptop_mac: '▢',
phone_iphone: '▯', smartphone: '▯', tablet: '▭', tablet_mac: '▭',
tv: '▭', cast: '▱', headphones: '◯',
keyboard: '⌨', keyboard_alt: '⌨', mouse: '◯',
memory: '▦', sd_storage: '▦', storage: '▦', dns: '▦',
cloud: '☁', cloud_done: '☁', cloud_off: '☁', wb_cloudy: '☁',
wifi: '◌', wifi_off: '◌', signal_cellular_alt: '⌇', router: '◌',
bluetooth: 'ᛒ', bluetooth_connected: 'ᛒ',
battery_full: '▮', battery_charging_full: '▮',
power: '⚡', flash_on: '⚡', bolt: '⚡', power_settings_new: '⏻',
// Media
photo: '▣', image: '▣', collections: '▣', camera_alt: '◉', photo_camera: '◉',
videocam: '▶', movie: '▶', play_circle: '▶', play_circle_outline: '▶',
mic: '◖', mic_off: '◖', music_note: '♪', library_music: '♪', queue_music: '♪',
headphones: '◯', volume_up: '◀', volume_off: '◀', volume_mute: '◀',
audiotrack: '♪', album: '◯', radio: '◯',
// Location / map / travel
my_location: '◎', location_on: '◎', location_pin: '◎', place: '◎',
pin_drop: '◎', room: '◎', not_listed_location: '◎',
map: '▣', explore: '✦', navigation: '➤', near_me: '➤',
directions: '➤', directions_car: '⊟', directions_walk: '⫯',
directions_run: '⫯', hiking: '△', directions_bike: '⊕', directions_bus: '⊟',
directions_subway: '⊟', directions_train: '⊟', directions_boat: '⊟',
directions_transit: '⊟', flight: '✈', flight_takeoff: '✈',
flight_land: '✈', train: '⊟', tram: '⊟', subway: '⊟',
local_taxi: '⊟', local_shipping: '⊟', local_parking: 'P',
local_gas_station: '⛽', ev_station: '⚡',
// Home / building
home: '⌂', house: '⌂', home_filled: '⌂', cottage: '⌂',
apartment: '▥', business: '▥', business_center: '▥', domain: '▥',
hotel: '▥', meeting_room: '▥', other_houses: '⌂',
maps_home_work: '▥', corporate_fare: '▥',
// Education / work
school: '▿', menu_book: '▤', auto_stories: '▤',
cast_for_education: '▿', psychology: '◯', science: '⚗', biotech: '⚗',
work: '▭', work_outline: '▭', business_center: '▭',
// Health / nature
medical_services: '✚', local_hospital: '✚', healing: '✚',
medication: '◯', vaccines: '✚', monitor_heart: '♥', favorite: '♥',
eco: '✿', local_florist: '✿', spa: '✿', park: '✿',
nature: '✿', forest: '✿', grass: '✿', water_drop: '◐',
pets: '◯',
restaurant: '⊕', restaurant_menu: '⊕', local_dining: '⊕',
local_cafe: '◯', coffee: '◯', local_drink: '◯', wine_bar: '◯',
// Weather
wb_sunny: '☀', sunny: '☀', wb_twighlight: '☀',
brightness_high: '☀', brightness_low: '☾', dark_mode: '☾',
light_mode: '☀', nightlight: '☾', bedtime: '☾',
cloud: '☁', wb_cloudy: '☁', cloudy_snowing: '☁',
thunderstorm: '⚡', umbrella: '☂', beach_access: '☂', ac_unit: '❄',
water: '◐', waves: '〜', terrain: '△',
// Misc / objects
inventory: '▣', inventory_2: '▣', category: '▣',
backpack: '▣', luggage: '▣', shopping_bag: '▣',
key: '⚿', vpn_key: '⚿', emoji_objects: '◆',
gavel: '⚖', balance: '⚖', policy: '▤', rule: '▤',
kitchen: '▤', dining: '⊕', fitness_center: '▣', sports_kabaddi: '♟',
pet_supplies: '◯', auto_awesome: '✦',
gif: '▣', emoji_emotions: '☻', sentiment_satisfied: '☻',
sentiment_dissatisfied: '☹', sentiment_very_satisfied: '☻',
sentiment_very_dissatisfied: '☹', mood: '☻', mood_bad: '☹',
// Globe / language
language: '◯', public: '◯', translate: '⇄', g_translate: '⇄',
// Misc symbols
rocket_launch: '➤', rocket: '➤',
};
// Font Awesome icon class → Unicode glyph map.
// Key is the part AFTER `fa-` (e.g. for `<i class="fas fa-user">`, key is 'user').
// Reuses the Material map's well-tested glyphs.
const FA_ICON_MAP = {
// people
'user': '☻', 'users': '♟', 'user-circle': '◉', 'user-friends': '♟',
'user-plus': '☻', 'user-tie': '☻', 'address-book': '▤', 'id-card': '▭',
'people-group': '♟', 'people-arrows': '♟', 'people-line': '♟',
'face-smile': '☻', 'face-frown': '☹',
// actions
'check': '✓', 'check-circle': '✓', 'check-square': '✓', 'check-double': '✓',
'circle-check': '✓', 'square-check': '✓',
'xmark': '✕', 'times': '✕', 'times-circle': '✕', 'circle-xmark': '✕',
'ban': '⊘', 'minus': '−', 'minus-circle': '⊖', 'circle-minus': '⊖',
'plus': '+', 'plus-circle': '⊕', 'circle-plus': '⊕',
'pen': '✎', 'pencil': '✎', 'pen-to-square': '✎', 'edit': '✎',
'trash': '✕', 'trash-can': '✕', 'trash-alt': '✕',
'rotate': '↻', 'rotate-right': '↻', 'rotate-left': '↺',
'arrows-rotate': '↻', 'sync': '↻', 'redo': '↷', 'undo': '↶',
// alerts
'info': 'ⓘ', 'info-circle': 'ⓘ', 'circle-info': 'ⓘ',
'triangle-exclamation': '⚠', 'exclamation-triangle': '⚠',
'circle-exclamation': '⊗', 'exclamation-circle': '⊗',
'question': '?', 'question-circle': '?', 'circle-question': '?',
// arrows / nav
'arrow-right': '→', 'arrow-left': '←', 'arrow-up': '↑', 'arrow-down': '↓',
'arrow-up-right': '↗', 'arrow-up-long': '↑',
'angle-right': '›', 'angle-left': '‹', 'angle-up': '▲', 'angle-down': '▼',
'angles-right': '»', 'angles-left': '«',
'chevron-right': '›', 'chevron-left': '‹',
'chevron-up': '▲', 'chevron-down': '▼',
'caret-right': '▶', 'caret-left': '◀', 'caret-up': '▲', 'caret-down': '▼',
'arrow-trend-up': '↗', 'arrow-trend-down': '↘',
'arrow-right-arrow-left': '⇄', 'arrows-left-right': '⇄',
'arrows-up-down': '⇅',
'play': '▶', 'pause': '⏸', 'stop': '■', 'forward': '⏩', 'backward': '⏪',
'forward-step': '⏭', 'backward-step': '⏮',
// menu / list
'bars': '☰', 'list': '☰', 'list-ul': '☰', 'list-ol': '☰',
'th': '▦', 'th-large': '▦', 'border-all': '▦', 'table-cells': '▦',
'ellipsis': '⋯', 'ellipsis-vertical': '⋮',
'filter': '☰', 'sliders': '☰',
// files
'folder': '▣', 'folder-open': '▣', 'folder-plus': '▣',
'file': '▤', 'file-lines': '▤', 'file-alt': '▤', 'file-pdf': '▤',
'file-word': '▤', 'file-excel': '▤', 'file-powerpoint': '▤',
'file-image': '▣', 'file-video': '▶', 'file-audio': '♪',
'copy': '⎘', 'paste': '⎘', 'cut': '✂', 'scissors': '✂',
'paperclip': '⫶', 'link': '⌘', 'unlink': '⌘',
'upload': '↑', 'download': '↓', 'cloud-upload': '↑', 'cloud-download': '↓',
'cloud-arrow-up': '↑', 'cloud-arrow-down': '↓',
'save': '⤓', 'floppy-disk': '⤓',
'book': '▤', 'book-open': '▤', 'bookmark': '❏',
'flag': '⚑', 'tag': '#', 'tags': '#',
'print': '⎙', 'share': '↗', 'share-nodes': '↗', 'up-right-from-square': '↗',
// settings
'gear': '⚙', 'cog': '⚙', 'gears': '⚙', 'cogs': '⚙', 'wrench': '⚒',
'screwdriver-wrench': '⚒', 'tools': '⚒', 'hammer': '⚒',
// security
'lock': '⚿', 'lock-open': '◯', 'unlock': '◯', 'key': '⚿',
'shield': '⛨', 'shield-halved': '⛨', 'shield-alt': '⛨',
'user-shield': '⛨', 'user-lock': '⚿',
// stars / awards
'star': '★', 'star-half': '⯨', 'star-of-life': '✦',
'award': '♛', 'trophy': '♛', 'crown': '♛', 'medal': '✦',
'gem': '◆', 'diamond': '◆',
'fire': '✦', 'lightbulb': '◆', 'wand-magic-sparkles': '✦',
'heart': '♥', 'heart-circle-check': '♥',
'thumbs-up': '👍', 'thumbs-down': '👎',
// charts / data
'chart-line': '⌇', 'chart-bar': '▦', 'chart-column': '▦',
'chart-pie': '◐', 'chart-area': '⌇', 'chart-simple': '▦',
'arrow-up-right-dots': '↗', 'magnifying-glass-chart': '▦',
// time
'clock': '◔', 'clock-rotate-left': '↺', 'stopwatch': '◔',
'hourglass': '⧗', 'hourglass-half': '⧗', 'hourglass-end': '⧗',
'calendar': '▦', 'calendar-days': '▦', 'calendar-check': '▦',
// money
'dollar-sign': '$', 'euro-sign': '€', 'yen-sign': '¥', 'pound-sign': '£',
'money-bill': '$', 'money-bill-wave': '$', 'sack-dollar': '$',
'wallet': '◫', 'piggy-bank': '◫',
'credit-card': '▭', 'cart-shopping': '🛒', 'shopping-cart': '🛒',
'bag-shopping': '🛍', 'shopping-bag': '🛍', 'store': '🏪',
// tech
'desktop': '▢', 'laptop': '▢', 'computer': '▢', 'display': '▢',
'mobile': '▯', 'mobile-screen': '▯', 'tablet': '▭',
'keyboard': '⌨', 'mouse': '◯', 'headphones': '◯',
'database': '▦', 'server': '▦', 'hard-drive': '▦', 'microchip': '▦',
'cloud': '☁', 'wifi': '◌', 'signal': '⌇', 'tower-broadcast': '⌇',
'bluetooth': 'ᛒ', 'battery-full': '▮', 'plug': '⚡', 'bolt': '⚡',
// media
'image': '▣', 'images': '▣', 'photo-film': '▣',
'camera': '◉', 'camera-retro': '◉', 'video': '▶', 'film': '▶',
'circle-play': '▶', 'play-circle': '▶',
'microphone': '◖', 'mic': '◖', 'music': '♪', 'headphones-simple': '◯',
'volume-high': '◀', 'volume-low': '◀', 'volume-xmark': '◀', 'volume-off': '◀',
// location
'location-dot': '◎', 'map-marker': '◎', 'map-marker-alt': '◎',
'map-pin': '◎', 'map': '▣', 'compass': '✦',
'paper-plane': '➤', 'plane': '✈', 'plane-departure': '✈', 'plane-arrival': '✈',
'car': '⊟', 'bus': '⊟', 'train': '⊟', 'truck': '⊟', 'taxi': '⊟',
'bicycle': '⊕', 'person-walking': '⫯', 'person-running': '⫯',
'gas-pump': '⛽', 'charging-station': '⚡',
// home / building
'house': '⌂', 'home': '⌂', 'house-chimney': '⌂',
'building': '▥', 'city': '▥', 'industry': '▥', 'hotel': '▥', 'school': '▿',
// education / science
'graduation-cap': '▿', 'book-open-reader': '▤', 'brain': '◯',
'flask': '⚗', 'vial': '⚗', 'atom': '⚛', 'dna': '⚗', 'microscope': '⚗',
'briefcase': '▭',
// health / nature
'plus-square': '✚', 'square-plus': '✚', 'hospital': '✚',
'stethoscope': '✚', 'heart-pulse': '♥', 'pills': '◯', 'syringe': '✚',
'leaf': '✿', 'seedling': '✿', 'tree': '✿', 'spa': '✿',
'water': '◐', 'droplet': '◐', 'snowflake': '❄',
// weather
'sun': '☀', 'moon': '☾', 'cloud-sun': '☁', 'cloud-moon': '☁',
'cloud-rain': '☁', 'cloud-bolt': '⚡', 'umbrella': '☂',
'temperature-high': '☀', 'temperature-low': '☾',
// communication
'envelope': '✉', 'envelope-open': '✉', 'at': '@',
'phone': '☎', 'phone-volume': '☎', 'mobile-button': '▯',
'message': '◌', 'comment': '◌', 'comments': '◌',
'comment-dots': '◌', 'bullhorn': '◖', 'microphone-lines': '◖',
'bell': '◔', 'bell-slash': '◔',
'paper-plane-top': '➤',
// misc
'globe': '◯', 'earth-americas': '◯', 'earth-europe': '◯', 'earth-asia': '◯',
'rocket': '➤', 'magnifying-glass': '⌕', 'search': '⌕',
'eye': '◉', 'eye-slash': '⊘',
'gift': '◆', 'cake-candles': '◆', 'wand-sparkles': '✦',
'arrow-pointer': '➤', 'hand': '✋', 'hand-pointer': '☞',
'circle': '●', 'circle-dot': '◉', 'square': '▢', 'square-full': '▢',
'asterisk': '✦', 'hashtag': '#',
'quote-left': '“', 'quote-right': '”',
};
// Bootstrap Icons class → Unicode glyph map.
// Key is the part AFTER `bi-` (e.g. for `<i class="bi bi-check">`, key is 'check').
const BI_ICON_MAP = {
'check': '✓', 'check2': '✓', 'check-circle': '✓', 'check2-circle': '✓',
'check-square': '✓', 'check-all': '✓',
'x': '✕', 'x-circle': '✕', 'x-square': '✕', 'x-lg': '✕',
'plus': '+', 'plus-circle': '⊕', 'plus-lg': '+', 'plus-square': '⊕',
'dash': '−', 'dash-circle': '⊖', 'dash-lg': '−',
'pencil': '✎', 'pencil-square': '✎', 'pencil-fill': '✎',
'trash': '✕', 'trash-fill': '✕', 'trash3': '✕',
'arrow-clockwise': '↻', 'arrow-counterclockwise': '↺', 'arrow-repeat': '↻',
'info-circle': 'ⓘ', 'info-square': 'ⓘ',
'exclamation-triangle': '⚠', 'exclamation-circle': '⊗',
'question-circle': '?', 'question-square': '?',
'arrow-right': '→', 'arrow-left': '←', 'arrow-up': '↑', 'arrow-down': '↓',
'arrow-up-right': '↗', 'arrow-down-right': '↘',
'chevron-right': '›', 'chevron-left': '‹', 'chevron-up': '▲', 'chevron-down': '▼',
'caret-right': '▶', 'caret-left': '◀', 'caret-up': '▲', 'caret-down': '▼',
'graph-up': '↗', 'graph-down': '↘', 'graph-up-arrow': '↗',
'play': '▶', 'play-fill': '▶', 'pause': '⏸', 'pause-fill': '⏸',
'stop': '■', 'skip-forward': '⏭', 'skip-backward': '⏮',
'list': '☰', 'list-ul': '☰', 'list-ol': '☰', 'list-check': '☰',
'grid': '▦', 'grid-3x3': '▦', 'grid-fill': '▦',
'three-dots': '⋯', 'three-dots-vertical': '⋮',
'funnel': '☰', 'sliders': '☰',
'folder': '▣', 'folder-fill': '▣', 'folder-plus': '▣', 'folder2': '▣',
'file': '▤', 'file-earmark': '▤', 'file-text': '▤', 'file-pdf': '▤',
'file-word': '▤', 'file-excel': '▤', 'file-image': '▣',
'paperclip': '⫶', 'link': '⌘', 'link-45deg': '⌘',
'upload': '↑', 'download': '↓', 'cloud-upload': '↑', 'cloud-download': '↓',
'save': '⤓', 'save2': '⤓',
'book': '▤', 'bookmark': '❏', 'bookmark-fill': '❏',
'flag': '⚑', 'flag-fill': '⚑', 'tag': '#', 'tags': '#',
'printer': '⎙', 'share': '↗', 'share-fill': '↗', 'box-arrow-up-right': '↗',
'gear': '⚙', 'gear-fill': '⚙', 'wrench': '⚒', 'tools': '⚒', 'hammer': '⚒',
'lock': '⚿', 'lock-fill': '⚿', 'unlock': '◯', 'key': '⚿', 'key-fill': '⚿',
'shield': '⛨', 'shield-check': '⛨', 'shield-fill': '⛨', 'shield-lock': '⚿',
'person': '☻', 'person-fill': '☻', 'person-circle': '◉',
'people': '♟', 'people-fill': '♟', 'person-plus': '☻',
'star': '★', 'star-fill': '★', 'star-half': '⯨',
'trophy': '♛', 'trophy-fill': '♛', 'award': '♛', 'gem': '◆',
'heart': '♥', 'heart-fill': '♥', 'hand-thumbs-up': '👍', 'hand-thumbs-down': '👎',
'lightbulb': '◆', 'lightbulb-fill': '◆', 'fire': '✦', 'magic': '✦',
'bar-chart': '▦', 'bar-chart-fill': '▦', 'pie-chart': '◐', 'pie-chart-fill': '◐',
'graph-up-arrow': '↗',
'clock': '◔', 'clock-fill': '◔', 'stopwatch': '◔',
'hourglass': '⧗', 'hourglass-split': '⧗',
'calendar': '▦', 'calendar-event': '▦', 'calendar-check': '▦',
'currency-dollar': '$', 'currency-euro': '€', 'currency-yen': '¥', 'currency-pound': '£',
'cash': '$', 'cash-stack': '$', 'wallet': '◫', 'wallet2': '◫',
'credit-card': '▭', 'cart': '🛒', 'cart-fill': '🛒', 'cart3': '🛒',
'bag': '🛍', 'bag-fill': '🛍', 'shop': '🏪', 'shop-window': '🏪',
'laptop': '▢', 'pc-display': '▢', 'tv': '▭',
'phone': '▯', 'phone-fill': '▯', 'tablet': '▭',
'keyboard': '⌨', 'mouse': '◯', 'headphones': '◯', 'headset': '◯',
'database': '▦', 'server': '▦', 'hdd': '▦', 'cpu': '▦',
'cloud': '☁', 'cloud-fill': '☁', 'wifi': '◌', 'reception-4': '⌇',
'bluetooth': 'ᛒ', 'battery-full': '▮', 'battery-charging': '▮',
'lightning': '⚡', 'lightning-fill': '⚡', 'plug': '⚡',
'image': '▣', 'images': '▣', 'camera': '◉', 'camera-fill': '◉',
'camera-video': '▶', 'film': '▶',
'mic': '◖', 'mic-fill': '◖', 'music-note': '♪', 'music-note-beamed': '♪',
'volume-up': '◀', 'volume-down': '◀', 'volume-mute': '◀',
'geo-alt': '◎', 'geo': '◎', 'pin-map': '◎', 'map': '▣', 'compass': '✦',
'send': '➤', 'send-fill': '➤', 'airplane': '✈', 'airplane-fill': '✈',
'car-front': '⊟', 'truck': '⊟', 'bicycle': '⊕',
'house': '⌂', 'house-fill': '⌂', 'house-door': '⌂',
'building': '▥', 'buildings': '▥', 'hospital': '✚',
'mortarboard': '▿', 'book-half': '▤', 'brain': '◯',
'flask': '⚗', 'eyedropper': '⚗',
'briefcase': '▭', 'briefcase-fill': '▭',
'plus-square-fill': '✚', 'bandaid': '✚', 'heart-pulse': '♥',
'tree': '✿', 'flower1': '✿', 'flower2': '✿', 'flower3': '✿',
'droplet': '◐', 'snow': '❄', 'snow2': '❄', 'snow3': '❄',
'sun': '☀', 'sun-fill': '☀', 'moon': '☾', 'moon-fill': '☾',
'cloud-sun': '☁', 'cloud-rain': '☁', 'cloud-lightning': '⚡', 'umbrella': '☂',
'envelope': '✉', 'envelope-fill': '✉', 'envelope-open': '✉', 'at': '@',
'telephone': '☎', 'telephone-fill': '☎',
'chat': '◌', 'chat-fill': '◌', 'chat-dots': '◌', 'chat-square': '◌',
'megaphone': '◖', 'broadcast': '◖',
'bell': '◔', 'bell-fill': '◔', 'bell-slash': '◔',
'globe': '◯', 'globe2': '◯', 'translate': '⇄',
'rocket': '➤', 'rocket-takeoff': '➤',
'search': '⌕', 'eye': '◉', 'eye-fill': '◉', 'eye-slash': '⊘',
'gift': '◆', 'gift-fill': '◆',
'hand-index': '☞', 'hand-index-thumb': '☞',
'circle': '●', 'circle-fill': '●', 'square': '▢', 'square-fill': '▢',
'asterisk': '✦', 'hash': '#',
};
// Generic icon-name → glyph heuristic (used when no explicit map hits).
// Inspects keywords commonly found in icon class names / Material ligatures.
// Returns null when nothing recognisable matches — caller falls back to a
// neutral default.
const ICON_KEYWORD_RULES = [
[/check|done|tick|verified|complete|ok\b/, '✓'],
[/close|cancel|xmark|times|wrong|reject/, '✕'],
[/ban|block|disable|forbidden|prohibit/, '⊘'],
[/plus|add|create|new\b/, '+'],
[/minus|subtract|less|reduce/, '−'],
[/edit|pencil|pen|write|compose|modify/, '✎'],
[/trash|delete|bin|remove/, '✕'],
[/refresh|reload|sync|rotate|cycle/, '↻'],
[/undo|revert/, '↶'],
[/redo/, '↷'],
[/info|about/, 'ⓘ'],
[/warn|alert|caution/, '⚠'],
[/error|fail|critical/, '⊗'],
[/help|question|faq|inquir/, '?'],
[/forward|next|right/, '→'],
[/back|previous|prev|left/, '←'],
[/upload|cloud[\s_-]*up/, '↑'],
[/download|cloud[\s_-]*down/, '↓'],
[/up\b|top|north/, '↑'],
[/down\b|bottom|south/, '↓'],
[/expand|more|chevron[\s_-]*down|caret[\s_-]*down/, '▼'],
[/collapse|less|chevron[\s_-]*up|caret[\s_-]*up/, '▲'],
[/swap|exchange|transfer|convert/, '⇄'],
[/menu|bars|hamburger|drawer/, '☰'],
[/grid|dashboard|apps|tiles|widgets/, '▦'],
[/list/, '☰'],
[/more[\s_-]*horiz|dots[\s_-]*horizontal/, '⋯'],
[/more[\s_-]*vert|dots[\s_-]*vertical|kebab/, '⋮'],
[/filter|funnel|sort|slider|tune/, '☰'],
[/folder|directory/, '▣'],
[/file|document|doc\b|paper|article|page/, '▤'],
[/copy|duplicate|clone/, '⎘'],
[/cut|scissors/, '✂'],
[/attach|paperclip|clip/, '⫶'],
[/link|url|chain/, '⌘'],
[/save|disk|floppy/, '⤓'],
[/print/, '⎙'],
[/share|export|external|launch/, '↗'],
[/book|read|library|stories/, '▤'],
[/bookmark/, '❏'],
[/flag|pennant|tour/, '⚑'],
[/tag|label|hash/, '#'],
[/settings?|preferences?|config|gear|cog|wrench/, '⚙'],
[/build|construct|hammer|tool|repair|fix/, '⚒'],
[/lock|secure|password|encrypt|key/, '⚿'],
[/shield|security|guard|protect|defend|admin/, '⛨'],
[/star|favori?te|grade|stellar/, '★'],
[/trophy|award|medal|champion|crown|premium/, '♛'],
[/diamond|gem|jewel/, '◆'],
[/heart|love|romance|liked?\b/, '♥'],
[/thumb[\s_-]*up|like\b|approve/, '👍'],
[/thumb[\s_-]*down|dislike|disapprove/, '👎'],
[/spark|magic|wand|shine|glow|awesome|celebrat|fire|hot|flame|sun\b|light|bulb|idea/, '✦'],
[/insight|tip|update|highlight/, '◆'],
[/chart|graph|analytic|stats|metric|bar/, '▦'],
[/pie|donut|doughnut/, '◐'],
[/trend|growth|increase|rise/, '↗'],
[/decrease|decline|drop|fall/, '↘'],
[/timeline|line[\s_-]*chart|wave|pulse|monitor/, '⌇'],
[/clock|time|schedule|watch|stopwatch|alarm|snooze|deadline/, '◔'],
[/hourglass|sandglass/, '⧗'],
[/calendar|date|event|day|today|month|year|week/, '▦'],
[/dollar|money|cash|payment|cost|price|pay|income|revenue/, '$'],
[/euro/, '€'],
[/yen|rmb/, '¥'],
[/pound/, '£'],
[/wallet|savings|bank/, '◫'],
[/credit[\s_-]*card|card\b/, '▭'],
[/cart|basket/, '🛒'],
[/bag|shopping/, '🛍'],
[/store|shop|market/, '🏪'],
[/laptop|desktop|computer|monitor|display|pc\b/, '▢'],
[/mobile|phone|smartphone/, '▯'],
[/tablet|ipad/, '▭'],
[/keyboard|typing/, '⌨'],
[/mouse(?!.*hover)/, '◯'],
[/headphone|headset|earbud/, '◯'],
[/database|server|storage|disk|drive|hdd|ssd|cpu|memory|chip/, '▦'],
[/cloud/, '☁'],
[/wifi|signal|network|router|broadcast/, '◌'],
[/bluetooth/, 'ᛒ'],
[/battery|charge/, '▮'],
[/bolt|lightning|flash|power|electric|energy/, '⚡'],
[/image|photo|picture|gallery|album/, '▣'],
[/camera/, '◉'],
[/video|movie|film|clip|play/, '▶'],
[/pause/, '⏸'],
[/stop\b/, '■'],
[/mic|microphone|broadcast|voice|record/, '◖'],
[/music|song|audio|track|note/, '♪'],
[/volume|speaker|sound/, '◀'],
[/location|pin|marker|place|map[\s_-]*marker|geo/, '◎'],
[/map\b/, '▣'],
[/compass|navigate|near|direction|explor/, '✦'],
[/send|paperplane|paper-plane|airplane|flight|plane/, '➤'],
[/car|truck|bus|taxi|train|tram|subway|metro|vehicle|transport/, '⊟'],
[/bicycle|bike/, '⊕'],
[/walk|run|person[\s_-]*walk|person[\s_-]*run/, '⫯'],
[/gas|fuel|pump/, '⛽'],
[/home|house|cottage/, '⌂'],
[/building|office|apartment|hotel|business|company|domain|corporate|city|industry/, '▥'],
[/school|education|graduat|academic|study/, '▿'],
[/brain|psychology|mind/, '◯'],
[/science|flask|atom|chemistry|biology|biotech|lab|experiment|test[\s_-]*tube|vial/, '⚗'],
[/work|job|brief|portfolio|case/, '▭'],
[/hospital|medical|medicine|doctor|nurse|care|clinic|heal|first[\s_-]*aid|pill|drug|vaccin|syring/, '✚'],
[/pulse|heartbeat/, '♥'],
[/eco|plant|leaf|tree|flower|garden|park|forest|nature|grass|sprout|seed/, '✿'],
[/water|drop|liquid/, '◐'],
[/snow|frost|cold|ice|winter/, '❄'],
[/wave|ocean|sea|beach/, '〜'],
[/sun|sunny|bright|day|light[\s_-]*mode/, '☀'],
[/moon|night|dark[\s_-]*mode|bedtime/, '☾'],
[/rain|storm|thunder|umbrella/, '☂'],
[/mail|envelope|email|inbox|message[\s_-]*read/, '✉'],
[/at[\s_-]*sign|@/, '@'],
[/telephone|call|phone/, '☎'],
[/chat|comment|message|sms|forum|conversation/, '◌'],
[/megaphone|campaign|announce|broadcast|bullhorn/, '◖'],
[/bell|notification|notify|ring/, '◔'],
[/globe|earth|world|planet|public|language|international/, '◯'],
[/translate|i18n|locale/, '⇄'],
[/rocket|launch/, '➤'],
[/search|find|magnif|loupe/, '⌕'],
[/eye|visibility|view|preview|watch/, '◉'],
[/hidden|invisible|eye[\s_-]*slash|eye[\s_-]*off/, '⊘'],
[/gift|present|reward|bonus|celebrate|cake|birthday/, '◆'],
[/hand|pointer|cursor|tap|click|touch/, '☞'],
[/circle/, '●'],
[/square|box|rectangle/, '▢'],
[/triangle/, '△'],
[/asterisk|sparkle|new\b/, '✦'],
[/quote/, '“'],
[/face|smile|happy|emoji|sentiment/, '☻'],
[/sad|frown/, '☹'],
[/user|person|profile|account|avatar/, '☻'],
[/people|team|group|community|members?|crowd/, '♟'],
[/handshake|partner|deal|agreement/, '✻'],
];
const inferIconByKeyword = (name) => {
if (!name) return null;
const lower = name.toLowerCase().replace(/[_-]+/g, ' ');
for (const [re, glyph] of ICON_KEYWORD_RULES) {
if (re.test(lower)) return glyph;
}
return null;
};
const ICON_CLASS_PATTERN = /(?:^|\s)(material-icons|material-symbols-(?:outlined|rounded|sharp)|fa-[a-z0-9-]+|fas|far|fab|fal|fad|bi-[a-z0-9-]+|icon-[a-z0-9-]+)(?:\s|$)/;
const MATERIAL_ICON_CLASS_PATTERN = /(?:^|\s)(material-icons|material-symbols-(?:outlined|rounded|sharp))(?:\s|$)/;
const isIconElement = (el) => {
if (!el) return false;
if (el.tagName !== 'I' && el.tagName !== 'SPAN') return false;
const cls = typeof el.className === 'string' ? el.className : (el.className.baseVal || '');
if (ICON_CLASS_PATTERN.test(cls)) return true;
const ff = (window.getComputedStyle(el).fontFamily || '').toLowerCase();
return ff.includes('material icons') || ff.includes('material symbols') ||
ff.includes('font awesome') || ff.includes('fontawesome') ||
ff.includes('bootstrap-icons') || ff.includes('feather');
};
const isMaterialLigatureIcon = (el) => {
if (!el) return false;
const raw = (el.textContent || '').trim();
if (raw.length <= 1) return false;
const cls = typeof el.className === 'string' ? el.className : (el.className?.baseVal || '');
if (MATERIAL_ICON_CLASS_PATTERN.test(cls)) return true;
const ff = (window.getComputedStyle(el).fontFamily || '').toLowerCase();
return ff.includes('material icons') || ff.includes('material symbols');
};
const normalizeIconBoxForMeasurement = (el) => {
if (!isMaterialLigatureIcon(el)) return;
const computed = window.getComputedStyle(el);
const fsPx = parseFloat(computed.fontSize) || 16;
// Material Icons ligatures are visually a 1em square. If the webfont is
// unavailable during conversion, Chromium measures raw names such as
// business_center as long words, which corrupts flex layout and
// produces inconsistent PPT icon widths. Force the measured box to match
// the symbol we will emit into PowerPoint.
const boxPx = Math.max(1, fsPx);
el._h2pIconBoxPx = boxPx;
if (el._iconGlyph) el.textContent = el._iconGlyph;
el.style.setProperty('display', 'inline-flex', 'important');
el.style.setProperty('align-items', 'center', 'important');
el.style.setProperty('justify-content', 'center', 'important');
el.style.setProperty('width', boxPx + 'px', 'important');
el.style.setProperty('height', boxPx + 'px', 'important');
el.style.setProperty('min-width', boxPx + 'px', 'important');
el.style.setProperty('max-width', boxPx + 'px', 'important');
el.style.setProperty('line-height', boxPx + 'px', 'important');
el.style.setProperty('overflow', 'hidden', 'important');
el.style.setProperty('white-space', 'nowrap', 'important');
el.style.setProperty('flex', '0 0 ' + boxPx + 'px', 'important');
el.style.setProperty('font-family', 'Segoe UI Symbol', 'important');
};
// Extract the bare icon name from a class string (`fa-user`, `bi-check-circle`).
const extractIconNameFromClass = (cls) => {
if (!cls) return null;
// Try FA first (`fa-` prefix)
const fa = cls.match(/(?:^|\s)fa-([a-z0-9-]+)(?:\s|$)/);
if (fa && !/^(solid|regular|brands|light|duotone|sharp|thin|stack|spin|pulse|fw|li|lg|sm|xs|2x|3x|4x|5x|6x|7x|8x|9x|10x|inverse|rotate|flip|pull|border)$/.test(fa[1])) {
return fa[1];
}
// Bootstrap Icons (`bi-` prefix)
const bi = cls.match(/(?:^|\s)bi-([a-z0-9-]+)(?:\s|$)/);
if (bi) return bi[1];
// Generic icon-XYZ pattern
const ic = cls.match(/(?:^|\s)icon-([a-z0-9-]+)(?:\s|$)/);
if (ic) return ic[1];
return null;
};
const resolveIconGlyph = (el) => {
const raw = (el.textContent || '').trim().toLowerCase().replace(/\s+/g, '_');
// 1. Material Icons (ligature is the text content)
if (raw && MATERIAL_ICON_MAP[raw]) return MATERIAL_ICON_MAP[raw];
// 2. Font Awesome / Bootstrap Icons / generic — class-based lookup
const cls = typeof el.className === 'string' ? el.className : (el.className?.baseVal || '');
const iconName = extractIconNameFromClass(cls);
if (iconName) {
if (FA_ICON_MAP[iconName]) return FA_ICON_MAP[iconName];
if (BI_ICON_MAP[iconName]) return BI_ICON_MAP[iconName];
if (MATERIAL_ICON_MAP[iconName.replace(/-/g, '_')]) {
return MATERIAL_ICON_MAP[iconName.replace(/-/g, '_')];
}
}
// 3. Read ::before pseudo content (FA/Bootstrap render glyphs via CSS)
try {
const beforeContent = window.getComputedStyle(el, '::before').content;
const m = beforeContent && beforeContent.match(/^"(.+)"$/);
if (m && m[1] && m[1] !== 'none') return m[1];
} catch (e) { /* ignore */ }
// 4. Keyword heuristic on the icon name OR class — picks a glyph that
// matches the icon's *meaning* instead of a literal "▢" placeholder.
const guessed = inferIconByKeyword(iconName || raw) || inferIconByKeyword(cls);
if (guessed) return guessed;
// 5. Last-resort fallback — a star is a friendlier neutral icon than
// an empty square. It reads as "marker / highlight" rather than
// "broken glyph", so a missed mapping still looks intentional.
return '✦';
};
// Mark icon elements so they are extracted with mapped glyph + PPT-safe font
document.querySelectorAll('i, span').forEach(el => {
if (isIconElement(el)) {
el._isIcon = true;
el._iconGlyph = resolveIconGlyph(el);
normalizeIconBoxForMeasurement(el);
}
});
// The inline style changes above intentionally affect flex/grid layout.
// Flush once before any bounding boxes are collected.
void document.body.offsetHeight;
// Pre-pass: heading/paragraph elements whose only text-bearing children are
// block-display inline tags (e.g. `<h1><span class="block">A</span><span class="block">B</span></h1>`)
// would otherwise be flattened into a single paragraph of inline runs by
// parseInlineFormatting, causing the children to render side-by-side in
// PowerPoint. Detect this case and split into per-child text frames so each
// gets its own bounding box, font size, and line spacing.
const _isBlockDisplay = (n) => {
const d = window.getComputedStyle(n).display;
return d === 'block' || d === 'flex' || d === 'grid' || d === 'list-item';
};
document.querySelectorAll('h1,h2,h3,h4,h5,h6,p').forEach(parent => {
const children = Array.from(parent.children);
if (children.length < 2) return;
const blockTextChildren = children.filter(c =>
['SPAN','B','STRONG','I','EM','U','A','LABEL','CODE','MARK','TIME','CITE','ABBR','S'].includes(c.tagName)
&& c.textContent.trim()
&& _isBlockDisplay(c)
);
// Require ALL text-bearing children to be block-display — mixed content
// (some inline, some block) is risky to split.
const textBearingChildren = children.filter(c => c.textContent.trim());
if (blockTextChildren.length < 2 || blockTextChildren.length !== textBearingChildren.length) return;
// No direct text nodes at the parent level.
const hasDirectText = Array.from(parent.childNodes).some(n =>
n.nodeType === Node.TEXT_NODE && n.textContent.trim()
);
if (hasDirectText) return;
parent._skipTextExtraction = true;
blockTextChildren.forEach(c => { c._treatAsP = true; });
});
// Mixed direct text + block-display inline child, e.g.
// <div class="chap-num">01<span class="slash">— Tsinghua</span></div>.
// Keeping this as one rich-text frame makes the small block child inherit
// the large parent's paragraph line height in PPT. Split the parent direct
// text and each block child into their own measured text frames.
const INLINE_TEXT_CHILD_TAGS = ['SPAN','B','STRONG','I','EM','U','A','LABEL','CODE','MARK','TIME','CITE','ABBR','S'];
document.querySelectorAll('*').forEach(parent => {
const directTextNodes = Array.from(parent.childNodes).filter(n =>
n.nodeType === Node.TEXT_NODE && n.textContent.trim()
);
if (directTextNodes.length === 0) return;
const blockTextChildren = Array.from(parent.children).filter(c =>
INLINE_TEXT_CHILD_TAGS.includes(c.tagName) && c.textContent.trim() && _isBlockDisplay(c)
);
if (blockTextChildren.length === 0) return;
parent._directTextOnly = true;
blockTextChildren.forEach(c => { c._treatAsP = true; });
});
// Inline sibling spans of very different font sizes on the SAME line, e.g.
// <div><span class="ch-num">01</span><span class="ch-num-tail">/ 04</span></div>
// where 01 is 140px and "/ 04" is 38px. Emitting each span as its own text
// frame anchors both at their individual bounding-box tops; because the small
// span's box sits at the big span's baseline (CSS vertical-align:baseline), it
// renders far below the big number in PPT instead of hugging its bottom-right.
// Merge such a row into ONE rich-text frame so PowerPoint baseline-aligns the
// runs exactly like the browser. Restricted to normal (non-flex/grid) flow and
// a clear size gap so flex "label … value" rows (justify-between) are untouched.
document.querySelectorAll('*').forEach(parent => {
if (parent._treatAsP || parent._directTextOnly || parent._skipTextExtraction) return;
if (parent.closest('p,h1,h2,h3,h4,h5,h6,li')) return;
const pdisp = window.getComputedStyle(parent).display;
if (pdisp === 'flex' || pdisp === 'inline-flex' || pdisp === 'grid' || pdisp === 'inline-grid') return;
const kids = Array.from(parent.children);
if (kids.length < 2) return;
const textKids = kids.filter(c => c.textContent.trim());
if (textKids.length < 2) return;
// Every text-bearing child must be an inline (non-block) span-like element.
const allInline = textKids.every(c =>
INLINE_TEXT_CHILD_TAGS.includes(c.tagName) && !_isBlockDisplay(c)
);
if (!allInline) return;
// No direct text at the parent level (handled by the _directTextOnly pass).
const hasDirectText = Array.from(parent.childNodes).some(n =>
n.nodeType === Node.TEXT_NODE && n.textContent.trim()
);
if (hasDirectText) return;
// All children must share one visual row (vertical overlap).
const rects = textKids.map(c => c.getBoundingClientRect());
if (rects.some(r => r.width === 0 || r.height === 0)) return;
const rowTop = Math.max(...rects.map(r => r.top));
const rowBottom = Math.min(...rects.map(r => r.bottom));
if (rowBottom <= rowTop) return; // no common vertical band → not one line
// Require a clear font-size gap — this is the case where separate frames
// misalign. Similarly-sized siblings already line up when split.
const sizes = textKids.map(c => parseFloat(window.getComputedStyle(c).fontSize) || 0);
const maxS = Math.max(...sizes), minS = Math.min(...sizes);
if (!(minS > 0 && maxS / minS >= 1.4)) return;
parent._treatAsP = true;
parent._mergeInlineRow = true;
textKids.forEach(c => { c._mergedIntoInlineRow = true; });
});
// Effective z-index — a descendant of a z-indexed ancestor paints inside
// that ancestor's stacking context (per CSS painting rules). In our flat
// z-index model we approximate that by promoting each element's z-index
// to the maximum of its own and any ancestor's. Without this, a panel
// like `.text-overlay { background:#fff; z-index:20 }` would end up
// rendering ON TOP of the text it contains because the children default
// to z=0 and the panel shape is drawn last.
const getEffectiveZIndex = (el) => {
const ownRaw = parseInt(window.getComputedStyle(el).zIndex, 10);
let effective = Number.isFinite(ownRaw) ? ownRaw : 0;
let p = el.parentElement;
while (p && p !== document.body && p !== document.documentElement) {
const pZ = parseInt(window.getComputedStyle(p).zIndex, 10);
if (Number.isFinite(pZ) && pZ > effective) effective = pZ;
p = p.parentElement;
}
return effective;
};
const getNearestTextClipBounds = (el) => {
let p = el.parentElement;
while (p && p !== document.body && p !== document.documentElement) {
if (p.classList && p.classList.contains('slide')) return null;
const cs = window.getComputedStyle(p);
const r = p.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) {
p = p.parentElement;
continue;
}
const bg = cs.backgroundColor;
const hasBg = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
const hasBgImage = cs.backgroundImage && cs.backgroundImage !== 'none';
const borders = [cs.borderTopWidth, cs.borderRightWidth, cs.borderBottomWidth, cs.borderLeftWidth]
.map(v => parseFloat(v) || 0);
const hasBorder = borders.some(v => v > 0);
const hasShadow = cs.boxShadow && cs.boxShadow !== 'none';
const hasRadius = (parseFloat(cs.borderRadius) || 0) > 0;
if (hasBg || hasBgImage || hasBorder || hasShadow || hasRadius) {
const padL = parseFloat(cs.paddingLeft) || 0;
const padR = parseFloat(cs.paddingRight) || 0;
const padT = parseFloat(cs.paddingTop) || 0;
const padB = parseFloat(cs.paddingBottom) || 0;
const x = r.left + borders[3] + padL;
const y = r.top + borders[0] + padT;
const w = Math.max(1, r.width - borders[1] - borders[3] - padL - padR);
const h = Math.max(1, r.height - borders[0] - borders[2] - padT - padB);
return { x: pxToInch(relX(x)), y: pxToInch(relY(y)), w: pxToInch(w), h: pxToInch(h) };
}
p = p.parentElement;
}
return null;
};
const getFlexTextRightBound = (el, rect) => {
const p = el.parentElement;
if (!p) return null;
const pcs = window.getComputedStyle(p);
if (pcs.display !== 'flex' && pcs.display !== 'inline-flex') return null;
if (String(pcs.flexDirection || 'row').startsWith('column')) return null;
const pRect = p.getBoundingClientRect();
if (pRect.width <= 0 || pRect.height <= 0) return null;
const gap = parseFloat(pcs.columnGap || pcs.gap) || 0;
const padR = parseFloat(pcs.paddingRight) || 0;
const siblingRects = Array.from(p.children)
.filter(c => c !== el)
.map(c => c.getBoundingClientRect())
.filter(r => r.width > 0 && r.height > 0);
const nextRightSibling = siblingRects
.filter(r => r.left >= rect.right - 1 || r.left > rect.left + rect.width * 0.5)
.sort((a, b) => a.left - b.left)[0];
const rightPx = nextRightSibling
? nextRightSibling.left - Math.max(2, gap * 0.5)
: pRect.right - padR;
if (rightPx <= rect.left + rect.width + 1) return null;
return pxToInch(relX(rightPx));
};
const getNextFlowTextTopBound = (el, rect) => {
const p = el.parentElement;
if (!p) return null;
const cs = window.getComputedStyle(el);
if (cs.position === 'absolute' || cs.position === 'fixed') return null;
if (cs.float && cs.float !== 'none') return null;
const pcs = window.getComputedStyle(p);
const parentDisplay = pcs.display || 'block';
if (parentDisplay.includes('grid')) return null;
if (parentDisplay.includes('flex') && !String(pcs.flexDirection || 'row').startsWith('column')) {
return null;
}
for (let sib = el.nextElementSibling; sib; sib = sib.nextElementSibling) {
const sr = sib.getBoundingClientRect();
if (sr.width <= 0 || sr.height <= 0) continue;
if (sr.top <= rect.top + 1) return null;
const scs = window.getComputedStyle(sib);
if (scs.display === 'none' || scs.visibility === 'hidden') continue;
const txt = normalizeWhitespaceForPpt(sib.textContent || '', scs.whiteSpace).trim();
if (!txt) continue;
return pxToInch(relY(sr.top));
}
return null;
};
document.querySelectorAll('*').forEach((el) => {
if (processed.has(el)) return;
// Inline spans that were merged into a single parent rich-text frame by the
// same-line size-gap pre-pass. The parent emits their text as runs; emitting
// them again here would duplicate the glyphs.
if (el._mergedIntoInlineRow) {
processed.add(el);
el.querySelectorAll('*').forEach(child => processed.add(child));
return;
}
// ── Icon elements (Material Icons / Font Awesome / Bootstrap Icons / etc.) ──
if (el._isIcon) {
// If this icon is inside a text container, parseInlineFormatting emits it
// inline — do not double-extract here.
if (el.closest('p,h1,h2,h3,h4,h5,h6,li,ul,ol')) {
processed.add(el);
return;
}
let rect = el.getBoundingClientRect();
const iconParent = el.parentElement;
const parentComputed = iconParent ? window.getComputedStyle(iconParent) : null;
const parentRect = iconParent ? iconParent.getBoundingClientRect() : null;
const textBearingSiblings = iconParent
? Array.from(iconParent.children).filter(c => c !== el && (c.textContent || '').trim())
: [];
const parentCentersIcon = parentComputed && parentRect &&
(parentComputed.display === 'flex' || parentComputed.display === 'inline-flex' || parentComputed.display === 'grid' || parentComputed.display === 'inline-grid') &&
parentComputed.alignItems === 'center' &&
(parentComputed.justifyContent === 'center' || parentComputed.justifyItems === 'center') &&
textBearingSiblings.length === 0;
if (parentCentersIcon && parentRect.width > 0 && parentRect.height > 0) {
const computed = window.getComputedStyle(el);
const fsPx = parseFloat(computed.fontSize) || Math.min(parentRect.width, parentRect.height);
// HTML icon fonts draw the glyph inside a centered 1em box. In PPT the
// fallback Unicode symbol has different font metrics, so using the
// whole visual container as the text box can look off-center. Rebuild
// the 1em-ish glyph box at the center of the parent frame instead.
const minFrame = Math.min(parentRect.width, parentRect.height);
const glyphBox = Math.max(1, Math.min(minFrame, fsPx * 1.45));
rect = {
left: parentRect.left + (parentRect.width - glyphBox) / 2,
top: parentRect.top + (parentRect.height - glyphBox) / 2,
width: glyphBox,
height: glyphBox
};
}
if (rect.width > 0 && rect.height > 0) {
const computed = window.getComputedStyle(el);
const fsPx = parseFloat(computed.fontSize) || rect.height;
// Size the glyph to match the icon's visible box. Material Icons
// render at ~1em with the glyph filling the full box, but most
// Unicode symbols leave much more empty padding around their
// glyph. Bumping the font size up to ~1.25× the source font-size
// (capped at 48pt) gets the rendered symbol closer to the
// original icon's optical size.
const glyphFontSize = Math.max(8, Math.min(fsPx * PT_PER_PX * SCALE * 1.25, 48));
elements.push({
type: 'p',
text: el._iconGlyph,
noWrap: true,
zIndex: getEffectiveZIndex(el),
position: rectPosition(rect),
style: {
fontSize: glyphFontSize,
// Segoe UI Symbol is shipped with Windows by default and has
// broad coverage of Unicode BMP symbols, geometric shapes,
// dingbats, and basic emoji — far more reliable in
// PowerPoint than Noto Color Emoji. On Mac/Linux the OS
// falls back to a font with the needed glyph automatically.
fontFace: 'Segoe UI Symbol',
color: rgbToHex(computed.color),
align: 'center',
charSpacing: undefined,
lineSpacing: null,
paraSpaceBefore: 0,
paraSpaceAfter: 0,
margin: [0, 0, 0, 0],
bold: false,
italic: false,
underline: false,
_isIcon: true,
_centerInFrame: parentCentersIcon
}
});
}
processed.add(el);
el.querySelectorAll('*').forEach(child => processed.add(child));
return;
}
// Extract placeholder elements (for charts, etc.). Do not treat a bare
// decorative class token like `.placeholder` as a chart placeholder:
// authors also use it for styled content cards.
const classTokens = el.classList ? Array.from(el.classList) : [];
const isPlaceholderElement =
el.hasAttribute('data-placeholder') ||
el.hasAttribute('data-chart') ||
/^placeholder(?:-|$)/i.test(el.id || '') ||
classTokens.some(cls => /^(chart|plot|viz|graph)-?placeholder$/i.test(cls));
if (isPlaceholderElement) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const computed = window.getComputedStyle(el);
placeholders.push({
id: el.id || `placeholder-${placeholders.length}`,
...rectPosition(rect),
zIndex: getEffectiveZIndex(el)
});
}
processed.add(el);
el.querySelectorAll('*').forEach(child => processed.add(child));
return;
}
// Rasterize <canvas> (ECharts / Chart.js data visualizations) to a PNG so
// charts survive into the deck. The canvas is already painted by the time
// extraction runs (see the chart-render wait after page load). A blank or
// not-yet-painted canvas produces a tiny transparent PNG — skip those.
if (el.tagName === 'CANVAS') {
const rect = el.getBoundingClientRect();
if (rect.width > 1 && rect.height > 1) {
let dataUrl = null;
try { dataUrl = el.toDataURL('image/png'); } catch (e) { dataUrl = null; }
if (dataUrl && dataUrl.length > 2000) {
elements.push({
type: 'image',
src: dataUrl,
objectFit: 'fill',
zIndex: getEffectiveZIndex(el),
position: rectPosition(rect)
});
}
}
processed.add(el);
el.querySelectorAll('*').forEach(child => processed.add(child));
return;
}
// Silently skip media/embed elements that we cannot render in PPT
// (these would otherwise emit empty shapes from the container fallback)
if (el.tagName === 'IFRAME' || el.tagName === 'VIDEO' || el.tagName === 'AUDIO' ||
el.tagName === 'EMBED' || el.tagName === 'OBJECT' ||
el.tagName === 'SOURCE' || el.tagName === 'TRACK' || el.tagName === 'PARAM') {
processed.add(el);
el.querySelectorAll('*').forEach(child => processed.add(child));
return;
}
// Horizontal rules → thin line shape
if (el.tagName === 'HR') {
const rect = el.getBoundingClientRect();
if (rect.width > 0) {
const computed = window.getComputedStyle(el);
// Prefer border-top color & width (most browsers render <hr> via border)
let widthPx = parseFloat(computed.borderTopWidth) || 1;
let color = computed.borderTopColor && computed.borderTopColor !== 'rgba(0, 0, 0, 0)'
? rgbToHex(computed.borderTopColor)
: rgbToHex(computed.color);
const styleVal = computed.borderTopStyle;
const dashType = styleVal === 'dashed' ? 'dash' : styleVal === 'dotted' ? 'dot' : 'solid';
elements.push({
type: 'hr',
color,
thickness: widthPx * PT_PER_PX * SCALE,
dashType,
position: rectPosition(rect)
});
}
processed.add(el);
return;
}
// Inline SVG → serialize, embed as data URL image
if (el.tagName === 'svg' || el.tagName === 'SVG' ||
(el.namespaceURI === 'http://www.w3.org/2000/svg' && el.tagName.toLowerCase() === 'svg')) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
try {
const computed = window.getComputedStyle(el);
// Ensure xmlns is set for standalone parsing
let svgEl = el.cloneNode(true);
if (!svgEl.getAttribute('xmlns')) svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
// Standalone SVG rasterization cannot see page CSS. Inline the
// computed paint on descendants so rules like `.starburst path` keep
// their intended color after sharp renders the SVG data URL.
const srcNodes = [el, ...Array.from(el.querySelectorAll('*'))];
const cloneNodes = [svgEl, ...Array.from(svgEl.querySelectorAll('*'))];
srcNodes.forEach((srcNode, idx) => {
const cloneNode = cloneNodes[idx];
if (!cloneNode) return;
const cs = window.getComputedStyle(srcNode);
if (cs.fill && cs.fill !== 'none' && cs.fill !== 'rgba(0, 0, 0, 0)') cloneNode.setAttribute('fill', cs.fill);
if (cs.stroke && cs.stroke !== 'none' && cs.stroke !== 'rgba(0, 0, 0, 0)') cloneNode.setAttribute('stroke', cs.stroke);
});
// Lock viewBox and explicit pixel size so the rasterizer respects layout.
if (!svgEl.getAttribute('width')) svgEl.setAttribute('width', String(rect.width));
if (!svgEl.getAttribute('height')) svgEl.setAttribute('height', String(rect.height));
const svgStr = new XMLSerializer().serializeToString(svgEl);
// base64 in browser via btoa with utf-8 safe encoding
const utf8 = unescape(encodeURIComponent(svgStr));
const dataUrl = 'data:image/svg+xml;base64,' + btoa(utf8);
const transparency = computeTransparency(null, computed.opacity);
const imageEl = {
type: 'image',
src: dataUrl,
position: rectPosition(rect)
};
if (transparency !== null) imageEl.transparency = transparency;
elements.push(imageEl);
} catch (e) {
// Best effort — if serialization fails, just drop it
}
}
processed.add(el);
el.querySelectorAll('*').forEach(child => processed.add(child));
return;
}
// Extract images
if (el.tagName === 'IMG') {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const computed = window.getComputedStyle(el);
// object-fit: fill (default), cover, contain — all map to PptxGenJS sizing types
let objectFit = null;
const fit = computed.objectFit;
if (fit === 'cover') objectFit = 'cover';
else if (fit === 'contain') objectFit = 'contain';
// opacity → transparency (image has no color so we pass null for color)
const transparency = computeTransparency(null, computed.opacity);
// box-shadow on image
const shadow = parseBoxShadow(computed.boxShadow);
// border-radius rounding hint
let rounding = false;
const br = parseFloat(computed.borderRadius);
if (br > 0 && computed.borderRadius.includes('%') && br >= 50 &&
Math.abs(rect.width - rect.height) < 2) {
rounding = true;
}
// hyperlink — if wrapped in <a>
let hyperlink = null;
const a = el.closest('a');
if (a && a.href) {
hyperlink = { url: a.href };
if (a.title) hyperlink.tooltip = a.title;
}
elements.push({
type: 'image',
src: el.src,
objectFit,
objectPosition: computed.objectPosition,
rounding,
transparency,
shadow,
hyperlink,
zIndex: getEffectiveZIndex(el),
position: rectPosition(rect)
});
processed.add(el);
return;
}
}
// Extract <table> as native PptxGenJS table (handle here BEFORE the container/text fallback)
if (el.tagName === 'TABLE') {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const tableComputed = window.getComputedStyle(el);
const rows = [];
// Collect rows in document order across thead/tbody/tfoot
const trList = Array.from(el.querySelectorAll('tr'));
const getSpan = (node, attr) => {
const n = parseInt(node.getAttribute(attr), 10);
return Number.isFinite(n) && n > 0 ? n : 1;
};
const colGroupWidths = [];
for (const col of Array.from(el.querySelectorAll('colgroup col'))) {
const span = getSpan(col, 'span');
const colComputed = window.getComputedStyle(col);
const styleWidth = parseFloat(col.style.width || colComputed.width) || 0;
const rectWidth = col.getBoundingClientRect ? col.getBoundingClientRect().width : 0;
const widthPx = rectWidth || styleWidth;
if (widthPx > 0) {
for (let i = 0; i < span; i++) colGroupWidths.push(pxToInch(widthPx / span));
}
}
let colWidths = colGroupWidths.length > 0 ? colGroupWidths : undefined;
if (!colWidths) {
let bestCells = [];
let bestExpandedCount = 0;
for (const tr of trList) {
const cells = Array.from(tr.children).filter(c => c.tagName === 'TD' || c.tagName === 'TH');
const expandedCount = cells.reduce((sum, c) => sum + getSpan(c, 'colspan'), 0);
if (expandedCount > bestExpandedCount || (expandedCount === bestExpandedCount && cells.length > bestCells.length)) {
bestCells = cells;
bestExpandedCount = expandedCount;
}
}
if (bestCells.length > 0) {
colWidths = [];
for (const cell of bestCells) {
const span = getSpan(cell, 'colspan');
const width = pxToInch(cell.getBoundingClientRect().width) / span;
for (let i = 0; i < span; i++) colWidths.push(width);
}
}
}
for (const tr of trList) {
const cells = Array.from(tr.children).filter(c => c.tagName === 'TD' || c.tagName === 'TH');
if (cells.length === 0) continue;
const rowOut = cells.map(cell => {
const cellComputed = window.getComputedStyle(cell);
const isHeader = cell.tagName === 'TH';
const cellAlign = cellComputed.textAlign === 'start' ? 'left'
: cellComputed.textAlign === 'end' ? 'right' : cellComputed.textAlign;
const valignMap = { top: 'top', middle: 'middle', bottom: 'bottom' };
const cellOpts = {
bold: isHeader || cellComputed.fontWeight === 'bold' || parseInt(cellComputed.fontWeight) >= 700,
fontFace: mapFontFace(cellComputed.fontFamily, cell.textContent),
fontSize: pxToFontSize(cellComputed.fontSize, cell.textContent),
color: rgbToHex(cellComputed.color),
align: cellAlign === 'left' || cellAlign === 'right' || cellAlign === 'center' || cellAlign === 'justify' ? cellAlign : 'left',
valign: valignMap[cellComputed.verticalAlign] || 'middle',
margin: [
pxToPoints(cellComputed.paddingLeft),
pxToPoints(cellComputed.paddingRight),
pxToPoints(cellComputed.paddingBottom),
pxToPoints(cellComputed.paddingTop)
]
};
const bgc = cellComputed.backgroundColor;
if (bgc && bgc !== 'rgba(0, 0, 0, 0)' && bgc !== 'transparent') {
const cellT = extractAlpha(bgc); // null when opaque, else 0..100
// Skip near-invisible tints (alpha < 0.08 → transparency > 92).
// Otherwise a `background: rgba(44,62,93,0.05)` row tint would
// render as a SOLID indigo cell because rgbToHex drops alpha.
if (cellT === null || cellT < 92) {
cellOpts.fill = { color: rgbToHex(bgc) };
if (cellT !== null) cellOpts.fill.transparency = cellT;
}
}
// Colspan / rowspan
const colspan = parseInt(cell.getAttribute('colspan')) || 1;
const rowspan = parseInt(cell.getAttribute('rowspan')) || 1;
if (colspan > 1) cellOpts.colspan = colspan;
if (rowspan > 1) cellOpts.rowspan = rowspan;
return { text: cell.textContent.replace(/\s+/g, ' ').trim(), options: cellOpts };
});
rows.push(rowOut);
}
if (rows.length > 0) {
// Border defaults from computed styles
const borderColor = tableComputed.borderTopColor && tableComputed.borderTopColor !== 'rgba(0, 0, 0, 0)'
? rgbToHex(tableComputed.borderTopColor)
: 'CCCCCC';
const borderPt = Math.max(0.25, pxToPoints(tableComputed.borderTopWidth) || 0.5);
elements.push({
type: 'table',
rows,
colWidths,
tableBorder: { type: 'solid', color: borderColor, pt: borderPt },
zIndex: getEffectiveZIndex(el),
position: rectPosition(rect),
style: {
fontSize: pxToFontSize(tableComputed.fontSize, el.textContent),
fontFace: mapFontFace(tableComputed.fontFamily, el.textContent),
color: rgbToHex(tableComputed.color)
}
});
processed.add(el);
el.querySelectorAll('*').forEach(child => processed.add(child));
return;
}
}
}
// Extract container elements with backgrounds/borders as shapes.
// Some badge/pill labels are authored as standalone spans (e.g.
// <span class="num-tag">PRINCIPLE 01</span>). They are not block
// containers, but their background is a real visual shape and must be
// emitted before the text.
const isStandaloneInlineShape = inlineAsBlockTags.has(el.tagName) &&
!el.closest('p,h1,h2,h3,h4,h5,h6,li,ul,ol');
const isContainer = blockContainerTags.has(el.tagName) || isStandaloneInlineShape;
if (isContainer) {
const computed = window.getComputedStyle(el);
const isSlideCanvasElement = el === slideRoot;
// background-clip:text uses the element background as glyph paint.
// Exporting it as a normal shape/image creates a visible gradient box
// around KPI numbers and headings, so only the text extractor may use it.
const isTextClippedBackground = hasTextBackgroundClip(computed);
let hasBg = !isTextClippedBackground && computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)';
let bgFillValue = hasBg ? computed.backgroundColor : null; // rgb/rgba/#hex string
// Check for background images on shapes.
// - Linear gradients → emit a multi-stop gradient fill object that pptxgenjs
// accepts (best effort; pptxgenjs gradient support is partial).
// - Radial / conic / repeating gradients → fall back to a representative
// color stop (no first-class radial support in PPTX shape fill).
// - url() background images → emit a SEPARATE image element layered under
// this shape (so the texture is preserved instead of dropped silently).
const bgImage = computed.backgroundImage;
// Capture extracted gradient data for richer fill output below
let gradientFill = null;
// When a gradient has only translucent stops (e.g. a "scrim" overlay
// like rgba(0,0,0,0.45)→rgba(0,0,0,0)), carry an average transparency
// so the solid fallback still reads as a tint rather than an opaque mask.
let gradientTransparency = null;
if (!isTextClippedBackground && bgImage && bgImage !== 'none') {
const isLinear = /^linear-gradient/.test(bgImage);
const isOtherGradient = /^(repeating-)?(linear|radial|conic)-gradient/.test(bgImage) && !isLinear;
// Collect all url() entries from possibly multi-layer backgroundImage
const urlMatches = [...bgImage.matchAll(/url\(["']?([^"')]+)["']?\)/g)].map(m => m[1]);
const hostBgRect = el.getBoundingClientRect();
const gradientDataUrl = renderLinearGradientBackground(bgImage, hostBgRect.width, hostBgRect.height);
if (gradientDataUrl && hostBgRect.width > 0 && hostBgRect.height > 0) {
const gradientImg = {
type: 'image',
src: gradientDataUrl,
objectFit: 'fill',
zIndex: getEffectiveZIndex(el),
position: rectPosition(hostBgRect)
};
const gradientOpacityT = computeTransparency(null, computed.opacity);
if (gradientOpacityT !== null) gradientImg.transparency = gradientOpacityT;
elements.push(gradientImg);
}
if (isLinear && !gradientDataUrl) {
// Parse the angle (e.g. "linear-gradient(135deg, ...)" or "to right, ...")
const linearMatch = bgImage.match(/linear-gradient\(\s*([^,]+),(.*)\)/);
let angle = 90; // default top-to-bottom
if (linearMatch) {
const head = linearMatch[1].trim();
const angleM = head.match(/(-?[\d.]+)deg/);
if (angleM) angle = ((parseFloat(angleM[1]) % 360) + 360) % 360;
else if (/to\s+right/.test(head)) angle = 90;
else if (/to\s+left/.test(head)) angle = 270;
else if (/to\s+bottom/.test(head)) angle = 180;
else if (/to\s+top/.test(head)) angle = 0;
else if (/to\s+bottom\s+right/.test(head)) angle = 135;
else if (/to\s+top\s+right/.test(head)) angle = 45;
else if (/to\s+bottom\s+left/.test(head)) angle = 225;
else if (/to\s+top\s+left/.test(head)) angle = 315;
}
const colorTokens = bgImage.match(/(rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-fA-F]{3,8})/g) || [];
if (colorTokens.length >= 2) {
// pptxgenjs (v4) does NOT support shape gradient fills via the
// {type:'gradient',angle,stops} structure — passing it as `fill`
// silently emits a shape with no fill at all. That hid the
// semi-transparent overlay panel on cover slides, leaving only
// the slide-canvas-level gradient (resolved to a single solid
// hex below) to fill the entire slide. Until pptxgenjs grows
// proper gradient support, fall back to a representative solid:
// - opaque stop → use that color directly
// - all stops have alpha → carry the average alpha as
// transparency so a "fade" still reads as a translucent panel
const stops = colorTokens.map(tok => ({
hex: rgbToHex(tok),
t: extractAlpha(tok) || 0 // 0..100 transparency
}));
const opaqueStop = stops.find(s => s.t === 0);
const translucentStop = stops.find(s => s.t > 0);
// Mixed-alpha gradient (directional fade between opaque and
// translucent stops, e.g. a "scrim" that fades from solid beige
// on the left to translucent on the right so a backdrop image
// bleeds through): a single opaque solid would hide whatever
// sits under the faded side. Use the average alpha across stops
// so the resulting flat panel still lets the underlying layer
// partly show through.
const mixedAlpha = opaqueStop && translucentStop;
const repStop = (opaqueStop && !mixedAlpha) ? opaqueStop : stops[Math.floor(stops.length / 2)];
gradientFill = null; // intentionally not emitted
hasBg = true;
bgFillValue = '#' + repStop.hex;
if (!opaqueStop || mixedAlpha) {
// All-translucent OR mixed gradient: carry the average alpha
// as transparency so the panel reads as a tint instead of an
// opaque mask that hides everything underneath.
gradientTransparency = Math.round(
stops.reduce((acc, s) => acc + s.t, 0) / stops.length
);
}
}
} else if (isOtherGradient) {
const rgbStops = bgImage.match(/rgba?\([^)]+\)/g);
const hexStops = bgImage.match(/#[0-9a-fA-F]{3,8}\b/g);
let picked = null;
if (rgbStops && rgbStops.length > 0) {
picked = rgbStops[Math.floor(rgbStops.length / 2)];
} else if (hexStops && hexStops.length > 0) {
picked = hexStops[Math.floor(hexStops.length / 2)];
}
if (picked) {
hasBg = true;
bgFillValue = picked;
}
}
// Also emit url() bg images as their own image layer. Use the same
// z-index as the host element (not z-1): in CSS a div's url()
// background paints ON TOP of the parent's background-color but
// below child content. Subtracting 1 makes the image render BELOW
// sibling shapes that sit at z=0 (e.g. a parent panel with a solid
// background-color), hiding the image entirely. Insertion order
// ensures it still lands above the parent's shape (which was pushed
// earlier when we visited the parent element).
if (urlMatches.length > 0 && !isSlideCanvasElement) {
const r = hostBgRect;
// Forward host element's opacity to the bg image. Decorative bg
// images often rely on `opacity: 0.2` (or a radial mask-image) to
// fade visually into the page; without forwarding opacity, the
// image dominates the slide as a sharp full-strength rectangle.
// We can't replicate `mask-image: radial-gradient(...)` exactly,
// but a uniformly translucent image is a much closer match than
// a fully opaque one.
const bgImgT = computeTransparency(null, computed.opacity);
for (const u of urlMatches) {
if (r.width > 0 && r.height > 0) {
const imgEl = {
type: 'image',
src: u,
objectFit: computed.backgroundSize === 'contain' ? 'contain' : 'cover',
objectPosition: computed.backgroundPosition,
zIndex: getEffectiveZIndex(el),
position: rectPosition(r)
};
if (bgImgT !== null) imgEl.transparency = bgImgT;
elements.push(imgEl);
}
}
}
}
// Check for borders - both uniform and partial
const borderTop = computed.borderTopWidth;
const borderRight = computed.borderRightWidth;
const borderBottom = computed.borderBottomWidth;
const borderLeft = computed.borderLeftWidth;
const borders = [borderTop, borderRight, borderBottom, borderLeft].map(b => parseFloat(b) || 0);
const hasBorder = borders.some(b => b > 0);
const borderColors = [
computed.borderTopColor,
computed.borderRightColor,
computed.borderBottomColor,
computed.borderLeftColor
];
const isTransparentBorderColor = (c) => {
const v = String(c || '').trim().toLowerCase();
return v === 'transparent' || extractAlpha(v) === 100;
};
// CSS triangles are commonly authored as width/height:0 plus large
// transparent side borders and one colored border. Rendering those as
// four PPT border lines creates huge white/gold blocks. They are
// decorative shape construction, not real borders.
const cssBoxW = parseFloat(computed.width) || 0;
const cssBoxH = parseFloat(computed.height) || 0;
const contentBoxTriangle = cssBoxW <= 0.5 && cssBoxH <= 0.5;
const borderBoxTriangle =
Math.abs(cssBoxW - (borders[1] + borders[3])) <= 1 &&
Math.abs(cssBoxH - (borders[0] + borders[2])) <= 1;
const isCssBorderTriangle =
(contentBoxTriangle || borderBoxTriangle) &&
borders.filter(b => b > 0).length >= 2 &&
borderColors.some(isTransparentBorderColor) &&
borderColors.some(c => !isTransparentBorderColor(c));
const hasExportableBorder = hasBorder && !isCssBorderTriangle;
const hasUniformBorder = hasExportableBorder && borders.every(b => b === borders[0]);
const borderLines = [];
if (hasExportableBorder && !hasUniformBorder) {
const rect = el.getBoundingClientRect();
const pos = rectPosition(rect);
const x = pos.x;
const y = pos.y;
const w = pos.w;
const h = pos.h;
// Helper: map border-style to PPT dash type
const styleToDash = (s) => s === 'dashed' ? 'dash' : s === 'dotted' ? 'dot' : 'solid';
// Collect lines to add after shape (inset by half the line width to center on edge)
if (parseFloat(borderTop) > 0) {
const widthPt = pxToPoints(borderTop);
const inset = (widthPt / 72) / 2; // Convert points to inches, then half
borderLines.push({
type: 'line',
x1: x, y1: y + inset, x2: x + w, y2: y + inset,
width: widthPt,
color: rgbToHex(computed.borderTopColor),
dashType: styleToDash(computed.borderTopStyle)
});
}
if (parseFloat(borderRight) > 0) {
const widthPt = pxToPoints(borderRight);
const inset = (widthPt / 72) / 2;
borderLines.push({
type: 'line',
x1: x + w - inset, y1: y, x2: x + w - inset, y2: y + h,
width: widthPt,
color: rgbToHex(computed.borderRightColor),
dashType: styleToDash(computed.borderRightStyle)
});
}
if (parseFloat(borderBottom) > 0) {
const widthPt = pxToPoints(borderBottom);
const inset = (widthPt / 72) / 2;
borderLines.push({
type: 'line',
x1: x, y1: y + h - inset, x2: x + w, y2: y + h - inset,
width: widthPt,
color: rgbToHex(computed.borderBottomColor),
dashType: styleToDash(computed.borderBottomStyle)
});
}
if (parseFloat(borderLeft) > 0) {
const widthPt = pxToPoints(borderLeft);
const inset = (widthPt / 72) / 2;
borderLines.push({
type: 'line',
x1: x + inset, y1: y, x2: x + inset, y2: y + h,
width: widthPt,
color: rgbToHex(computed.borderLeftColor),
dashType: styleToDash(computed.borderLeftStyle)
});
}
}
if ((hasBg && !isSlideCanvasElement) || hasExportableBorder) {
const rect = el.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
const shadow = parseBoxShadow(computed.boxShadow);
// Only add shape if there's background or uniform border
if (hasBg || hasUniformBorder) {
// gradientFill is currently always null (pptxgenjs cannot render
// shape gradients); the gradient parser populates bgFillValue
// with a representative solid hex. We may also have a
// gradientTransparency for "all-translucent" overlays.
let fillOut;
let fillTransparency = null;
if (gradientFill) {
fillOut = gradientFill;
} else if (hasBg) {
fillOut = bgFillValue.startsWith('#')
? bgFillValue.substring(1).toUpperCase()
: rgbToHex(bgFillValue);
// Combine bg color alpha with the container's CSS opacity so a
// 5%-opaque card panel renders as a faded panel in PPT, not solid.
fillTransparency = computeTransparency(bgFillValue, computed.opacity);
if (gradientTransparency !== null) {
// Prefer the average gradient alpha when present (the bg
// color itself is the rep stop, often opaque on its own).
fillTransparency = gradientTransparency;
}
} else {
fillOut = null;
}
// Determine shape kind: ellipse for ~square boxes with border-radius >= 50%
const radiusStr = computed.borderRadius || '0';
const radiusVal = parseFloat(radiusStr) || 0;
const isAlmostSquare = Math.abs(rect.width - rect.height) < 2;
const isFullRound = radiusStr.includes('%') && radiusVal >= 50;
const shapeKind = (isAlmostSquare && isFullRound) ? 'ellipse' : 'rect';
// Border styling — pick up dashed / dotted styles too
let lineSpec = null;
if (hasUniformBorder) {
const dashType = computed.borderTopStyle === 'dashed' ? 'dash'
: computed.borderTopStyle === 'dotted' ? 'dot' : 'solid';
lineSpec = {
color: rgbToHex(computed.borderColor),
width: pxToPoints(computed.borderWidth),
dashType
};
}
// No real border but an `inset 0 0 0 Npx COLOR` box-shadow:
// simulate as a colored stroke. Classic seal/stamp pattern in
// these decks (`.seal-rect` uses inset shadows to fake a double
// border around a red square); without this the seal would
// render as a flat fill with no border, losing the stamp look.
if (!lineSpec && computed.boxShadow && computed.boxShadow !== 'none' &&
/\binset\b/.test(computed.boxShadow)) {
// Browser serialises each shadow comma-separated. Find the
// first inset layer that's a uniform border (0 0 0 Npx COLOR).
const layers = computed.boxShadow.split(/,(?![^()]*\))/);
for (const layer of layers) {
if (!/\binset\b/.test(layer)) continue;
const cM = layer.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/);
// Match `0px 0px 0px Npx` (offsets and blur all zero, spread N)
const spreadM = layer.match(/0px\s+0px\s+0px\s+([\d.]+)px/);
if (cM && spreadM) {
const spreadPx = parseFloat(spreadM[1]);
if (spreadPx > 0) {
lineSpec = {
color: rgbToHex(cM[0]),
width: pxToPoints(`${spreadPx}px`),
dashType: 'solid'
};
break;
}
}
}
}
let shapePosition = rectPosition(rect);
const ownShapeText = normalizeWhitespaceForPpt(el.textContent || '', computed.whiteSpace).trim();
const shapePadX = (parseFloat(computed.paddingLeft) || 0) + (parseFloat(computed.paddingRight) || 0);
const shapePadY = (parseFloat(computed.paddingTop) || 0) + (parseFloat(computed.paddingBottom) || 0);
const isShortPaddedBadgeShape = ownShapeText && ownShapeText.length <= 24 &&
(shapePadX + shapePadY) > 0 && rect.height <= 48 && rect.width <= 260;
if (isShortPaddedBadgeShape) {
let extraX = Math.min(0.025, Math.max(0.012, shapePosition.w * 0.04));
const extraY = Math.min(0.014, Math.max(0.006, shapePosition.h * 0.06));
// Horizontal inflation gives an isolated badge visual breathing
// room, but when badges sit in a tight row (e.g. slide-26
// HTML/CSS/JS stack pills 6px apart, or slide-27 dep-chips)
// inflating both facing edges by ~1.5px collapses the gap and the
// borders look fused. Skip horizontal inflation when a same-row
// sibling is close; keep the (harmless) vertical inflation.
try {
const sibs = el.parentElement ? Array.from(el.parentElement.children) : [];
let minGapPx = Infinity;
for (const s of sibs) {
if (s === el) continue;
const r = s.getBoundingClientRect();
const vOverlap = Math.min(rect.bottom, r.bottom) - Math.max(rect.top, r.top);
if (vOverlap <= 2) continue; // not on the same row
let gap = Infinity;
if (r.left >= rect.right) gap = r.left - rect.right;
else if (r.right <= rect.left) gap = rect.left - r.right;
else continue; // horizontally overlapping — ignore
if (gap < minGapPx) minGapPx = gap;
}
if (Number.isFinite(minGapPx) && minGapPx < 16) extraX = 0;
} catch (e) { /* keep default extraX */ }
shapePosition = {
x: shapePosition.x - extraX,
y: shapePosition.y - extraY,
w: shapePosition.w + extraX * 2,
h: shapePosition.h + extraY * 2
};
}
if (lineSpec && !isShortPaddedBadgeShape) {
const strokeInset = (lineSpec.width / 72) / 2;
const insetX = Math.max(0, Math.min(strokeInset, shapePosition.w / 2 - 0.001));
const insetY = Math.max(0, Math.min(strokeInset, shapePosition.h / 2 - 0.001));
if (insetX > 0 || insetY > 0) {
shapePosition = {
x: shapePosition.x + insetX,
y: shapePosition.y + insetY,
w: Math.max(0.001, shapePosition.w - insetX * 2),
h: Math.max(0.001, shapePosition.h - insetY * 2)
};
}
}
// Rotation propagated to shape (so transformed cards rotate correctly)
const containerRotation = getRotation(computed.transform, computed.writingMode);
const ownTextForLine = ownShapeText.replace(/\s+/g, '');
const cssBoxW = el.offsetWidth || parseFloat(computed.width) || 0;
const cssBoxH = el.offsetHeight || parseFloat(computed.height) || 0;
const isThinRotatedFillBox = containerRotation !== null && hasBg && !lineSpec &&
!ownTextForLine && shapeKind === 'rect' && cssBoxW >= 8 && cssBoxH > 0 && cssBoxH <= 4;
if (isThinRotatedFillBox) {
const matrixMatch = computed.transform.match(/matrix\(([^)]+)\)/);
const vals = matrixMatch ? matrixMatch[1].split(',').map(v => parseFloat(v.trim())) : null;
const angleRad = containerRotation * Math.PI / 180;
const a = vals && Number.isFinite(vals[0]) ? vals[0] : Math.cos(angleRad);
const b = vals && Number.isFinite(vals[1]) ? vals[1] : Math.sin(angleRad);
const c = vals && Number.isFinite(vals[2]) ? vals[2] : -Math.sin(angleRad);
const d = vals && Number.isFinite(vals[3]) ? vals[3] : Math.cos(angleRad);
const originParts = String(computed.transformOrigin || '0 0').split(/\s+/);
const ox = parseFloat(originParts[0]) || 0;
const oy = parseFloat(originParts[1]) || 0;
const txPoint = (x0, y0) => ({
x: a * (x0 - ox) + c * (y0 - oy) + ox,
y: b * (x0 - ox) + d * (y0 - oy) + oy
});
const corners = [txPoint(0, 0), txPoint(cssBoxW, 0), txPoint(cssBoxW, cssBoxH), txPoint(0, cssBoxH)];
const minX = Math.min(...corners.map(p => p.x));
const minY = Math.min(...corners.map(p => p.y));
const untransformedLeft = rect.left - minX;
const untransformedTop = rect.top - minY;
const p1 = txPoint(0, cssBoxH / 2);
const p2 = txPoint(cssBoxW, cssBoxH / 2);
const lineEl = {
type: 'line',
x1: pxToInch(relX(untransformedLeft + p1.x)),
y1: pxToInch(relY(untransformedTop + p1.y)),
x2: pxToInch(relX(untransformedLeft + p2.x)),
y2: pxToInch(relY(untransformedTop + p2.y)),
width: Math.max(0.5, pxToPoints(String(Math.max(1, cssBoxH)) + 'px')),
color: fillOut || rgbToHex(bgFillValue),
dashType: 'solid',
zIndex: getEffectiveZIndex(el)
};
if (fillTransparency !== null && fillTransparency !== undefined) lineEl.transparency = fillTransparency;
elements.push(lineEl);
} else {
elements.push({
type: 'shape',
text: '', // Shape only - child text elements render on top
zIndex: getEffectiveZIndex(el),
position: shapePosition,
shape: {
kind: shapeKind,
fill: fillOut,
transparency: fillTransparency,
line: lineSpec,
rotate: containerRotation,
// Convert border-radius to rectRadius (in inches)
// % values: 50%+ = circle (1), <50% = percentage of min dimension
// pt values: divide by 72 (72pt = 1 inch)
// px values: divide by 96 (96px = 1 inch)
rectRadius: (() => {
if (shapeKind === 'ellipse') return 0; // ellipse ignores rectRadius
const radius = computed.borderRadius;
const radiusValue = parseFloat(radius);
if (!Number.isFinite(radiusValue) || radiusValue === 0) return 0;
if (radius.includes('%')) {
if (radiusValue >= 50) return 1;
// Calculate percentage of smaller dimension
const minDim = Math.min(rect.width, rect.height);
return (radiusValue / 100) * pxToInch(minDim);
}
if (radius.includes('pt')) return radiusValue / 72;
return radiusValue / PX_PER_IN;
})(),
shadow: shadow
}
});
}
}
// Add partial border lines
elements.push(...borderLines);
// If this shape-emitting container carries its own direct text
// (no block-level child handles it), fall through so that text
// renders on top of the shape as a separate text frame.
const BLOCK_TEXT_CHILD_TAGS = new Set([
'DIV','SECTION','HEADER','FOOTER','ARTICLE','ASIDE','MAIN','NAV','FIGURE',
'P','H1','H2','H3','H4','H5','H6','UL','OL','LI','TABLE','BLOCKQUOTE',
'FIGCAPTION','DT','DD'
]);
const hasBlockTextChild = Array.from(el.children).some(
c => BLOCK_TEXT_CHILD_TAGS.has(c.tagName) && c.textContent.trim()
);
// Also bail if any inline child (span/strong/em/…) carries text —
// those will be extracted as their own text frame via _treatAsP,
// so claiming their text here would duplicate it.
const directText = Array.from(el.childNodes)
.filter(n => n.nodeType === Node.TEXT_NODE)
.map(n => n.textContent).join('').trim();
const hasInlineTextChild = Array.from(el.children).some(
c => !BLOCK_TEXT_CHILD_TAGS.has(c.tagName) && c.textContent.trim()
);
const shouldClaimText = !hasBlockTextChild && (directText || !hasInlineTextChild);
if (shouldClaimText && el.textContent.trim()) {
el._treatAsP = true;
// fall through — don't mark processed, don't return
} else {
processed.add(el);
return;
}
}
} else {
// No background / no border: treat as a plain text container if it
// directly holds text with no block-level text descendants.
const BLOCK_TEXT_CHILD_TAGS = new Set([
'DIV','SECTION','HEADER','FOOTER','ARTICLE','ASIDE','MAIN','NAV','FIGURE',
'P','H1','H2','H3','H4','H5','H6','UL','OL','LI','TABLE','BLOCKQUOTE',
'FIGCAPTION','DT','DD'
]);
const hasBlockTextChild = Array.from(el.children).some(
c => BLOCK_TEXT_CHILD_TAGS.has(c.tagName) && c.textContent.trim()
);
// If any inline child (span/strong/em/…) carries text, it will be
// extracted as its own text frame via _treatAsP — don't swallow it
// into the parent unless the parent also has its own direct text.
const directText = Array.from(el.childNodes)
.filter(n => n.nodeType === Node.TEXT_NODE)
.map(n => n.textContent).join('').trim();
const hasInlineTextChild = Array.from(el.children).some(
c => !BLOCK_TEXT_CHILD_TAGS.has(c.tagName) && c.textContent.trim()
);
const shouldClaimText = !hasBlockTextChild && (directText || !hasInlineTextChild);
if (shouldClaimText && el.textContent.trim()) {
el._treatAsP = true;
}
}
}
// Extract bullet lists as single text block
if (el.tagName === 'UL' || el.tagName === 'OL') {
// Skip nested lists — the outer list will already encompass them.
// Without this guard, an inner <ul> placed inside a parent <ul>'s <li>
// would emit a second text frame on top of the first, duplicating bullets.
if (el.parentElement && el.parentElement.closest('ul, ol')) {
// Mark inner list (and items) processed so they aren't re-handled,
// but don't emit them as separate frames.
processed.add(el);
el.querySelectorAll('*').forEach(c => processed.add(c));
return;
}
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
const isOrdered = el.tagName === 'OL';
// Only collect direct LI children (so nested lists' items don't bleed in)
const liElements = Array.from(el.children).filter(c => c.tagName === 'LI');
const items = [];
const ulComputed = window.getComputedStyle(el);
const cssLengthToPoints = (value) => {
const n = parseFloat(value);
return Number.isFinite(n) ? n * PT_PER_PX * SCALE : 0;
};
const ulPaddingLeftPt = cssLengthToPoints(ulComputed.paddingLeft);
const isFlexColumnList =
(ulComputed.display === 'flex' || ulComputed.display === 'inline-flex') &&
String(ulComputed.flexDirection || '').startsWith('column');
const flexListGapPt = isFlexColumnList
? Math.max(cssLengthToPoints(ulComputed.rowGap), cssLengthToPoints(ulComputed.gap))
: 0;
const liMarginBottomPt = liElements.reduce((max, li) => {
const liStyle = window.getComputedStyle(li);
return Math.max(max, cssLengthToPoints(liStyle.marginBottom));
}, 0);
// Split: margin-left for bullet position, indent for text position
// margin-left + indent = ul padding-left
const marginLeft = ulPaddingLeftPt * 0.5;
const textIndent = ulPaddingLeftPt * 0.5;
// Detect custom CSS bullets: list-style: none + li::before with a visible
// background (rendered as a colored circle/square). PptxGenJS only emits
// a plain <a:buChar char="•"/> with no color control, so a styled CSS
// bullet would otherwise be lost. Capture the ::before color and emit
// the bullet as an inline run with that color instead.
let customBullet = null;
if (!isOrdered && liElements.length > 0) {
const firstLi = liElements[0];
const liStyle = window.getComputedStyle(firstLi);
const ulStyle = ulComputed;
const listStyleNone = (liStyle.listStyleType === 'none' && ulStyle.listStyleType === 'none')
|| liStyle.listStyleType === 'none';
if (listStyleNone) {
const before = window.getComputedStyle(firstLi, '::before');
const beforeContent = before && before.content;
// ::before is "active" if content is set (even empty string '')
const hasBefore = beforeContent && beforeContent !== 'none' && beforeContent !== 'normal';
if (hasBefore) {
const bg = before.backgroundColor;
const col = before.color;
const bgVisible = bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent';
const colorPick = bgVisible ? bg : col;
const liFs = pxToFontSize(liStyle.fontSize, firstLi.textContent) || 12;
customBullet = {
char: '●', // ● black circle — renders reliably
color: rgbToHex(colorPick),
fontSize: Math.max(6, Math.round(liFs * 0.55)),
};
}
}
}
liElements.forEach((li, idx) => {
const isLast = idx === liElements.length - 1;
const liComputed = window.getComputedStyle(li);
const liIsFlex = liComputed.display === 'flex' || liComputed.display === 'inline-flex';
const runs = liIsFlex
? [{
text: applyTextTransform(
normalizeWhitespaceForPpt(li.textContent, liComputed.whiteSpace).trim(),
liComputed.textTransform
),
options: { breakLine: false }
}]
: parseInlineFormatting(li, { breakLine: false });
// Clean manual bullets/numbering from first run
if (runs.length > 0) {
runs[0].text = runs[0].text.replace(/^[•·\-\*▪▸○●◆◇■□]\s*/, '');
// Strip explicit "1." / "1)" prefixes that authors sometimes type
runs[0].text = runs[0].text.replace(/^\d+[\.\)]\s+/, '');
if (customBullet) {
// Prepend bullet as an inline run with custom color/size and
// disable the PPT auto-bullet on this paragraph.
runs.unshift({
text: customBullet.char + ' ',
options: {
color: customBullet.color,
fontSize: customBullet.fontSize,
bullet: false,
}
});
} else {
// PptxGenJS bullet: { type: 'number' } yields auto-numbered list
runs[0].options.bullet = isOrdered
? { type: 'number', indent: textIndent }
: { indent: textIndent };
}
}
// Set breakLine on last run
if (runs.length > 0 && !isLast) {
runs[runs.length - 1].options.breakLine = true;
}
items.push(...runs);
});
const computed = window.getComputedStyle(liElements[0] || el);
const taLi = computed.textAlign;
const alignList = taLi === 'start' ? 'left'
: taLi === 'end' ? 'right'
: (taLi === 'left' || taLi === 'right' || taLi === 'center' || taLi === 'justify') ? taLi
: 'left';
const listTextPaint = resolveTextPaintStyle(computed, liElements[0] || el);
elements.push({
type: 'list',
items: items,
zIndex: getEffectiveZIndex(el),
position: rectPosition(rect),
style: {
fontSize: pxToFontSize(computed.fontSize, el.textContent),
fontFace: mapFontFace(computed.fontFamily, el.textContent),
color: listTextPaint.color,
transparency: listTextPaint.transparency,
align: alignList,
lineSpacing: computed.lineHeight && computed.lineHeight !== 'normal' ? pxToPoints(computed.lineHeight) : null,
charSpacing: computed.letterSpacing && computed.letterSpacing !== 'normal' ? pxToPoints(computed.letterSpacing) : undefined,
paraSpaceBefore: 0,
paraSpaceAfter: Math.max(cssLengthToPoints(computed.marginBottom), flexListGapPt, liMarginBottomPt),
_itemCount: liElements.length,
// PptxGenJS margin array is [left, right, bottom, top]
margin: [marginLeft, 0, 0, 0]
}
});
liElements.forEach(li => {
processed.add(li);
li.querySelectorAll('*').forEach(c => processed.add(c));
});
processed.add(el);
return;
}
// If a pre-pass marked this text element as splittable (block-display
// inline children handled separately), skip extracting it as one frame
// AND don't mark descendants processed — let each child be emitted on
// its own with the correct bounding box and per-paragraph line height.
if (el._skipTextExtraction) {
processed.add(el);
return;
}
// Elements used as block-level text containers outside of any text tag:
// 1. Inline elements (span/strong/b/em/i/a/label/code/…) misused as block containers
// 2. Leaf-block elements (figcaption/dt/dd/td/th/blockquote/…) that directly hold text
// — only when they contain no block-text descendants to avoid duplicate extraction
if (inlineAsBlockTags.has(el.tagName) && !el.closest('p,h1,h2,h3,h4,h5,h6,li')) {
el._treatAsP = true;
} else if (leafBlockTags.has(el.tagName) && !el.closest('p,h1,h2,h3,h4,h5,h6,li')) {
if (!el.querySelector('p,h1,h2,h3,h4,h5,h6,ul,ol')) {
el._treatAsP = true;
}
}
// Extract text elements (P, H1, H2, etc.)
if (!textTags.includes(el.tagName) && !el._treatAsP) return;
let rect = el.getBoundingClientRect();
const computed = window.getComputedStyle(el);
let text = normalizeWhitespaceForPpt(el.textContent, computed.whiteSpace).trim();
if (el._directTextOnly) {
const directTextNodes = Array.from(el.childNodes).filter(n =>
n.nodeType === Node.TEXT_NODE && n.textContent.trim()
);
text = normalizeWhitespaceForPpt(directTextNodes.map(n => n.textContent).join(' '), computed.whiteSpace).trim();
const rects = [];
for (const n of directTextNodes) {
const range = document.createRange();
range.selectNodeContents(n);
const rr = range.getBoundingClientRect();
if (rr.width > 0 && rr.height > 0) rects.push(rr);
range.detach?.();
}
if (rects.length > 0) {
const left = Math.min(...rects.map(r => r.left));
const top = Math.min(...rects.map(r => r.top));
const right = Math.max(...rects.map(r => r.right));
const bottom = Math.max(...rects.map(r => r.bottom));
rect = { left, top, right, bottom, width: right - left, height: bottom - top };
}
}
if (rect.width === 0 || rect.height === 0 || !text) return;
// Validate: Check for manual bullet symbols in text elements (not in lists)
if (el.tagName !== 'LI' && /^[•·\-\*▪▸○●◆◇■□]\s/.test(text.trimStart())) {
warnings.push(
`Text element <${el.tagName.toLowerCase()}> starts with bullet symbol "${text.substring(0, 20)}...". ` +
'Use <ul> or <ol> lists instead of manual bullet symbols.'
);
}
let rotation = getRotation(computed.transform, computed.writingMode);
// CJK vertical-text fix: `writing-mode: vertical-rl/lr` on CJK content
// should render as upright characters stacked top-to-bottom — that's
// what the browser shows. The default rotation path rotates the whole
// text box 90°, which makes Chinese characters lie on their sides
// (because CJK glyphs are not auto-uprighted in PPT the way the
// browser uprights them for vertical writing-mode). For Latin text in
// a vertical writing mode (e.g. `Natural · Aesthetics`), sideways is
// the conventional rendering — keep the rotation path.
const isVerticalWM = computed.writingMode === 'vertical-rl' || computed.writingMode === 'vertical-lr';
let cjkVerticalStack = false;
if (isVerticalWM && hasCJKChars(text)) {
const nonSpace = text.replace(/\s/g, '');
const cjkCount = Array.from(nonSpace).filter(c =>
/[一-鿿぀-ヿ가-힯]/.test(c)
).length;
if (nonSpace.length > 0 && cjkCount / nonSpace.length >= 0.7) {
cjkVerticalStack = true;
rotation = null; // Skip rotation; rect already has the right (narrow & tall) dims
}
}
const { x, y, w, h } = getPositionAndSize(el, rect, rotation);
// v3: Detect white-space: nowrap and z-index
const isRowFlexText = (computed.display === 'flex' || computed.display === 'inline-flex') &&
!String(computed.flexDirection || 'row').startsWith('column') &&
text.length <= 32;
const flexTextRightBound = getFlexTextRightBound(el, rect);
const isFlexRowTitleText = flexTextRightBound !== null &&
text.length <= 64 &&
!el.querySelector('br') &&
(isMediumOrBold(computed.fontWeight) || pxToFontSize(computed.fontSize, text) >= 8.5);
const noWrap = computed.whiteSpace === 'nowrap' || computed.whiteSpace === 'pre' ||
isRowFlexText || isFlexRowTitleText;
// Effective z-index propagates the highest ancestor z-index down so
// text inside a z-indexed panel paints above the panel's background.
const cssZIndex = getEffectiveZIndex(el);
// text-align mapping — start/end depend on writing direction (assume LTR);
// pass through left/right/center/justify directly.
const taRaw = computed.textAlign;
let alignOut;
if (taRaw === 'start') alignOut = 'left';
else if (taRaw === 'end') alignOut = 'right';
else if (taRaw === 'left' || taRaw === 'right' || taRaw === 'center' || taRaw === 'justify') alignOut = taRaw;
else alignOut = 'left';
// Parse text-shadow → run-level glow approximation
const parseTextShadow = (ts) => {
if (!ts || ts === 'none') return null;
const colorM = ts.match(/rgba?\([^)]+\)|hsla?\([^)]+\)|#[0-9a-fA-F]{3,8}/);
const numM = ts.match(/(-?[\d.]+)px/g);
if (!numM || numM.length < 2) return null;
const offsetX = parseFloat(numM[0]);
const offsetY = parseFloat(numM[1]);
const blur = numM.length > 2 ? parseFloat(numM[2]) : 0;
const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY) * PT_PER_PX;
const angle = (offsetX === 0 && offsetY === 0) ? 0
: ((Math.atan2(offsetY, offsetX) * 180 / Math.PI) + 360) % 360;
return {
type: 'outer',
color: colorM ? rgbToHex(colorM[0]) : '000000',
blur: blur * PT_PER_PX,
offset,
angle: Math.round(angle),
opacity: colorM && extractAlpha(colorM[0]) !== null ? (100 - extractAlpha(colorM[0])) / 100 : 0.5
};
};
const textShadow = parseTextShadow(computed.textShadow);
const textPaint = resolveTextPaintStyle(computed, el);
const baseStyle = {
fontSize: pxToFontSize(computed.fontSize, text),
fontFace: mapFontFace(computed.fontFamily, text),
color: textPaint.color,
align: alignOut,
charSpacing: computed.letterSpacing && computed.letterSpacing !== 'normal' ? pxToPoints(computed.letterSpacing) : undefined,
lineSpacing: computed.lineHeight && computed.lineHeight !== 'normal' ? pxToPoints(computed.lineHeight) : null,
paraSpaceBefore: pxToPoints(computed.marginTop),
paraSpaceAfter: pxToPoints(computed.marginBottom),
// PptxGenJS margin array is [left, right, bottom, top] (not [top, right, bottom, left] as documented)
margin: [
pxToPoints(computed.paddingLeft),
pxToPoints(computed.paddingRight),
pxToPoints(computed.paddingBottom),
pxToPoints(computed.paddingTop)
]
};
// _fontSizeClampRatio: >1 when the source font-size was clamped up to
// MIN_FONT_SIZE_PT. The browser laid the box out using the smaller
// intended font, so PPT lines are taller than the box can hold —
// addElements scales the text frame height by this ratio to compensate.
baseStyle._fontSizeClampRatio = fontSizeClampRatio(computed.fontSize, text);
// Merged inline row (big number + small tail on one baseline): the parent
// div's own line-height is sized for its small inherited font, so forcing
// it as the paragraph line spacing crushes the large child run's line box
// and the tall glyph overflows upward over the element above. Size the line
// spacing to the tallest child run instead (its own line-height ≈ 1).
if (el._mergeInlineRow) {
let maxChildFsPt = 0;
el.querySelectorAll('*').forEach((c) => {
if (!c.textContent || !c.textContent.trim()) return;
const fsPt = pxToFontSize(window.getComputedStyle(c).fontSize, c.textContent);
if (fsPt > maxChildFsPt) maxChildFsPt = fsPt;
});
baseStyle.lineSpacing = maxChildFsPt > 0 ? maxChildFsPt : null;
}
// Hints consumed by addElements (prefixed with _ so they don't accidentally
// pass through to PptxGenJS textOptions):
// - _hasBrBreaks: element contains <br>; pptxgenjs would otherwise apply
// paraSpaceBefore/After to each line-broken paragraph, inflating the gap.
// - _centerInFrame: element uses grid/flex centering (place-items:center
// etc.) — text should be vertically AND horizontally centered in its frame
// (think single-character circular badges like .teacher / .thumb-num).
// - _hasContainerPadding: element has its own horizontal padding (i.e. is
// a padded box that also drew a shape behind itself). Width compensation
// would push the text frame past the shape edge, causing visible overflow.
baseStyle._hasBrBreaks = !!el.querySelector('br');
const isGridCenter = computed.display === 'grid'
&& computed.alignItems === 'center'
&& (computed.justifyItems === 'center' || computed.justifyContent === 'center');
const isFlexCenter = (computed.display === 'flex' || computed.display === 'inline-flex')
&& computed.alignItems === 'center'
&& computed.justifyContent === 'center';
baseStyle._centerInFrame = isGridCenter || isFlexCenter;
const padLpx = parseFloat(computed.paddingLeft) || 0;
const padRpx = parseFloat(computed.paddingRight) || 0;
baseStyle._hasContainerPadding = (padLpx + padRpx) > 1;
const textIsIconOnly = isIconOnlyText(text);
if (textIsIconOnly) {
baseStyle.fontFace = _fc.symbol || "Segoe UI Symbol";
baseStyle.lineSpacing = null;
baseStyle.margin = [0, 0, 0, 0];
// An inline lead-in glyph (e.g. slide-27 dep-chip "→" before the label)
// has a following inline sibling that carries text. Centering it inside
// its own tight frame both wastes the natural margin before the label and
// risks a right-bleed collision. Left-anchor so the glyph stays at its
// measured x and the ~margin gap to the following text is preserved.
let inlineLeadGlyph = false;
try {
const sib = el.nextSibling;
inlineLeadGlyph = !!(sib && (
(sib.nodeType === 3 && sib.textContent.trim().length > 0) ||
(sib.nodeType === 1 && (sib.textContent || '').trim().length > 0)));
} catch (e) { /* default false */ }
if (inlineLeadGlyph) {
baseStyle.align = "left";
baseStyle._inlineLeadGlyph = true;
} else {
baseStyle.align = "center";
}
}
baseStyle._isIcon = textIsIconOnly;
baseStyle._clipBounds = getNearestTextClipBounds(el);
const nextFlowTextTopBound = getNextFlowTextTopBound(el, rect);
if (nextFlowTextTopBound !== null) baseStyle._nextTextTopBound = nextFlowTextTopBound;
if (flexTextRightBound !== null) baseStyle._preferredRightBound = flexTextRightBound;
const isShortPaddedBadgeText = baseStyle._hasContainerPadding &&
text.length <= 24 &&
rect.height <= 48 &&
(computed.whiteSpace === 'nowrap' || (parseFloat(computed.borderRadius) || 0) > 0);
// A compound inline chip has BOTH a child element that renders as its own
// box AND a direct text node (e.g. slide-27 dep-chip:
// `<span class="arrow">→</span>Order Processing`). Its two pieces sit
// side-by-side on one line, so centering the text piece inside its own
// tight, wrap:none frame makes it bleed leftward and collide with the
// preceding arrow glyph. Keep such text left-anchored.
const isCompoundInline = (() => {
try {
const hasChildText = Array.from(el.children || []).some(c => (c.textContent || '').trim().length > 0);
const hasDirectText = Array.from(el.childNodes).some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
return hasChildText && hasDirectText;
} catch (e) { return false; }
})();
if (isShortPaddedBadgeText) {
// The measured badge rect already includes CSS padding. Re-applying
// that padding as PPT text inset leaves very little usable width for
// compact labels such as T1/T2/T3/T4 — zero it either way.
baseStyle._zeroTextInset = true;
baseStyle.margin = [0, 0, 0, 0];
if (!isCompoundInline) {
baseStyle._centerInFrame = true;
if (baseStyle.align === 'left') baseStyle.align = 'center';
}
}
if (textShadow) baseStyle.shadow = textShadow;
// Combine color alpha + element opacity. The watermark span case in
// 五粮液 cover.html is the canonical example: color is opaque rgb(...) but
// opacity is 0.05, so without folding opacity in, the giant character
// renders at full strength in PowerPoint.
if (textPaint.transparency !== null) baseStyle.transparency = textPaint.transparency;
if (rotation !== null) baseStyle.rotate = rotation;
const pushInlineBackgroundShapes = () => {
const inlineNodes = Array.from(el.querySelectorAll("span,mark,b,strong,i,em,u,a,code,small,big,label"));
for (const node of inlineNodes) {
const nodeStyle = window.getComputedStyle(node);
const nodeBg = nodeStyle.backgroundColor;
const hasNodeBg = nodeBg && nodeBg !== "rgba(0, 0, 0, 0)" && nodeBg !== "transparent";
if (!hasNodeBg) continue;
const nodeRect = node.getBoundingClientRect();
if (nodeRect.width <= 0 || nodeRect.height <= 0) continue;
const radius = nodeStyle.borderRadius || "0";
const radiusValue = parseFloat(radius) || 0;
let rectRadius = 0;
if (radiusValue > 0) {
if (radius.includes("%")) rectRadius = (Math.min(nodeRect.width, nodeRect.height) * radiusValue / 100) / PX_PER_IN;
else if (radius.includes("pt")) rectRadius = radiusValue / 72;
else rectRadius = radiusValue / PX_PER_IN;
}
elements.push({
type: "shape",
text: "",
zIndex: cssZIndex,
position: rectPosition(nodeRect),
shape: {
kind: "rect",
fill: rgbToHex(nodeBg),
transparency: computeTransparency(nodeBg, nodeStyle.opacity),
line: null,
rectRadius
}
});
}
};
pushInlineBackgroundShapes();
let textPosition = { x: pxToInch(relX(x)), y: pxToInch(relY(y)), w: pxToInch(w), h: pxToInch(h) };
if (textIsIconOnly && baseStyle._centerInFrame && rotation === null) {
const fsPx = parseFloat(computed.fontSize) || Math.min(rect.width, rect.height);
const lineHeightPx = computed.lineHeight && computed.lineHeight !== "normal" ? parseFloat(computed.lineHeight) : fsPx * 1.2;
const iconBoxPx = Math.max(1, Math.min(Math.min(rect.width, rect.height), Math.max(fsPx, lineHeightPx)));
const iconRect = {
left: rect.left + (rect.width - iconBoxPx) / 2,
top: rect.top + (rect.height - iconBoxPx) / 2,
width: iconBoxPx,
height: iconBoxPx
};
textPosition = rectPosition(iconRect);
}
// Look for richer inline formatting too — links, sub/sup, code, mark, strike
const hasFormatting = !el._directTextOnly && el.querySelector('b, i, u, strong, em, span, br, a, sub, sup, s, del, strike, mark, code, small, big');
if (hasFormatting) {
// Text with inline formatting
const transformStr = computed.textTransform;
const baseTextDecoration = computed.textDecoration || "";
const baseRunOptions = {
fontSize: baseStyle.fontSize,
fontFace: baseStyle.fontFace,
color: baseStyle.color,
charSpacing: baseStyle.charSpacing,
transparency: baseStyle.transparency,
bold: shouldApplyPptBold(computed.fontWeight) && !shouldSkipBold(computed.fontFamily),
italic: computed.fontStyle === "italic",
underline: baseTextDecoration.includes("underline"),
strike: baseTextDecoration.includes("line-through")
};
const runs = parseInlineFormatting(el, baseRunOptions, [], (str) => applyTextTransform(str, transformStr));
// Adjust lineSpacing based on largest fontSize in runs.
const adjustedStyle = { ...baseStyle };
if (adjustedStyle.lineSpacing && adjustedStyle.fontSize) {
const maxFontSize = Math.max(
adjustedStyle.fontSize,
...runs.map(r => r.options?.fontSize || 0)
);
const lineHeightMultiplier = adjustedStyle.lineSpacing / adjustedStyle.fontSize;
if (maxFontSize > adjustedStyle.fontSize) {
// CAP the inflation. The old behaviour (`maxFontSize * multiplier`)
// blew up the global spacing to fit the *largest* inline run —
// an inline 88px `.qmark` inside a 32px pullquote would push every
// body line 2.7× apart, dragging text past the box bottom and into
// the columns/footer below. Capping at 1.5× of the base prevents
// the body lines from being yanked apart, while still giving the
// big run enough room to not crowd its neighbours.
const cappedMax = Math.min(maxFontSize, adjustedStyle.fontSize * 1.5);
adjustedStyle.lineSpacing = cappedMax * lineHeightMultiplier;
// Additionally publish a percent-based fallback so PPT viewers
// that honour `<a:spcPct>` per-paragraph can give each paragraph
// line spacing proportional to its own font. (pptxgenjs only
// applies this when `lineSpacing` is unset, so it's a no-op for
// the primary code path, but persists as a downstream hint.)
adjustedStyle.lineSpacingMultiple = lineHeightMultiplier;
}
}
elements.push({
type: el._treatAsP ? 'p' : el.tagName.toLowerCase(),
text: runs,
noWrap,
zIndex: cssZIndex,
position: textPosition,
style: adjustedStyle
});
} else {
// Plain text - inherit CSS formatting
const textTransform = computed.textTransform;
let transformedText = applyTextTransform(text, textTransform);
// CJK vertical text: use native PowerPoint East Asian vertical
// layout. The old fallback emitted one character per paragraph; that
// made spaces around separators become blank lines and could overflow
// short vertical labels such as "千年汴梁 · 梵音塔影".
const cjkStyle = { ...baseStyle };
if (cjkVerticalStack) {
transformedText = transformedText.trim();
cjkStyle._pptxVert = "eaVert";
cjkStyle._allowWrap = true;
cjkStyle.align = "center";
cjkStyle._centerInFrame = true;
}
const isBold = shouldApplyPptBold(computed.fontWeight);
const td = computed.textDecoration || "";
const plainStyle = {
...cjkStyle,
bold: isBold && !shouldSkipBold(computed.fontFamily),
italic: computed.fontStyle === "italic",
underline: td.includes("underline"),
strike: td.includes("line-through")
};
if (isMediumOrBold(computed.fontWeight) && shouldUseEmphasisFont(transformedText)) plainStyle.fontFace = _fc.emphasis || "Liberation Sans Narrow";
const fontRuns = splitTextIntoFontRuns(transformedText, plainStyle);
elements.push({
type: el._treatAsP ? "p" : el.tagName.toLowerCase(),
text: fontRuns || transformedText,
noWrap,
zIndex: cssZIndex,
position: textPosition,
style: plainStyle
});
}
processed.add(el);
// Also mark descendants as processed so their spans/inline elements
// aren't re-extracted as separate text frames. When direct text was
// split from block-display children, those children still need their
// own pass below.
if (!el._directTextOnly) el.querySelectorAll('*').forEach(d => processed.add(d));
});
return { background, elements, placeholders, errors, warnings, notes };
}, slideDims);
}
async function html2pptx(htmlFile, pres, options = {}) {
const {
tmpDir = process.env.TMPDIR || '/tmp',
slide = null,
fontConfig = null // { cjk, latin, emphasis, display, symbol }
} = options;
const effectiveFontConfig = { ...DEFAULT_FONT_CONFIG, ...(fontConfig || {}) };
try {
// Use Chrome on macOS, default Chromium on Unix
const launchOptions = { env: { TMPDIR: tmpDir } };
if (process.platform === 'darwin') {
launchOptions.channel = 'chrome';
}
const browser = await chromium.launch(launchOptions);
let bodyDimensions;
let slideData;
const filePath = path.isAbsolute(htmlFile) ? htmlFile : path.join(process.cwd(), htmlFile);
try {
const page = await browser.newPage();
page.on('console', (msg) => {
// Log the message text to your test runner's console
console.log(`Browser console: ${msg.text()}`);
});
// External web-font CSS can block parser-executed scripts and keep
// DOMContentLoaded from firing. Fonts are mapped to PPT-safe faces later,
// so remote font downloads must not block conversion.
//
// CRITICAL: A local <link href="global.css"> whose file starts with
// `@import url(<remote-font-css>)` will NOT apply ANY of its own rules
// until every pending remote @import resolves or errors. Chromium keeps
// those @import stylesheet requests pending indefinitely on file:// pages
// under waitUntil:'domcontentloaded', so the local stylesheet's rules
// (.slide { height:720px }, :root { --accent } …) never take effect — the
// slide collapses to height 0, backgrounds/colors vanish, and
// justify-center content is pushed to negative Y. To avoid this we
// short-circuit ALL remote requests by resource type:
// - stylesheet → fulfill empty CSS immediately (unblocks local sheets;
// fonts are remapped to PPT-safe faces downstream anyway)
// - font → abort (never needed for layout/measurement)
// - script → allow (e.g. cdn.tailwindcss.com — required for layout)
// - image/other→ allow (remote images may be real slide content)
try {
await page.route(/^https?:\/\//i, async (route) => {
let type = '';
try { type = route.request().resourceType(); } catch (e) { /* ignore */ }
try {
if (type === 'stylesheet') {
await route.fulfill({ status: 200, contentType: 'text/css', body: '' });
} else if (type === 'font') {
await route.abort();
} else {
await route.continue();
}
} catch (e) {
try { await route.abort(); } catch (e2) { /* ignore */ }
}
});
} catch (e) { /* ignore */ }
// Use 'domcontentloaded' to avoid hanging on slow/absent CDN resources
// (Google Fonts, Tailwind JIT). Then give fonts/JIT a chance to apply.
await page.goto(`file://${filePath}`, { waitUntil: 'domcontentloaded', timeout: 120000 });
// Inject font config into the page for extractSlideData to use
await page.evaluate((fc) => { window.__FONT_CONFIG__ = fc; }, effectiveFontConfig);
// domcontentloaded does NOT wait for <link rel="stylesheet"> resources, so
// local stylesheets like ./global.css may not be applied yet. Explicitly
// wait for each link element's sheet to be available, with a per-link
// timeout so remote/CDN stylesheets that hang don't block the whole run.
try {
await page.evaluate(async () => {
const links = Array.from(document.querySelectorAll('link[rel~="stylesheet"]'));
await Promise.all(links.map((link) => {
// .sheet is non-null once the CSS has been parsed.
if (link.sheet) return Promise.resolve();
return new Promise((resolve) => {
const done = () => resolve();
link.addEventListener('load', done, { once: true });
link.addEventListener('error', done, { once: true });
// Per-link timeout (ms): same-origin file:// CSS should resolve
// almost instantly; this caps wait for slow CDN links.
setTimeout(done, 3000);
});
}));
});
} catch (e) { /* ignore */ }
// Wait for web fonts to settle (Google Fonts etc.) and Tailwind JIT to emit styles.
// document.fonts.ready resolves once all @font-face loads have completed or failed.
try {
await page.evaluate(() => {
if (!document.fonts || !document.fonts.ready) return null;
return Promise.race([
document.fonts.ready,
new Promise((resolve) => setTimeout(resolve, 3000))
]);
});
} catch (e) { /* ignore */ }
// Tailwind Play CDN observes DOM and injects styles asynchronously on first paint.
// When it is present, prefer waiting for utility classes to actually affect
// computed styles instead of blindly falling back to unstyled layout.
try {
await page.evaluate(async () => {
const hasTailwindCdn = Array.from(document.scripts || []).some(s => /cdn\.tailwindcss\.com/i.test(s.src || ''));
if (!hasTailwindCdn) return;
const classTokens = (el) => String(el.getAttribute('class') || '').split(/\s+/).filter(Boolean);
const tailwindLooksActive = () => {
const nodes = Array.from(document.querySelectorAll('[class]'));
for (const el of nodes) {
const cs = getComputedStyle(el);
const tokens = classTokens(el);
if (tokens.includes('absolute') && cs.position === 'absolute') return true;
if (tokens.includes('flex') && cs.display === 'flex') return true;
if (tokens.includes('grid') && cs.display === 'grid') return true;
if (tokens.some(t => /^top-\[/.test(t)) && cs.top !== 'auto') return true;
if (tokens.some(t => /^right-\[/.test(t)) && cs.right !== 'auto') return true;
if (tokens.includes('-translate-y-1/2') && cs.transform && cs.transform !== 'none') return true;
}
return false;
};
if (tailwindLooksActive()) return;
const deadline = Date.now() + 5000;
await new Promise((resolve) => {
const tick = () => {
if (tailwindLooksActive() || Date.now() >= deadline) resolve();
else requestAnimationFrame(tick);
};
tick();
});
});
} catch (e) { /* ignore */ }
await page.waitForTimeout(200);
bodyDimensions = await getBodyDimensions(page);
// Defensive clamp: the viewport resize is meant to GROW the page to
// capture overflowing content, never to shrink it below the authored
// design size. If a stylesheet failed to apply (e.g. a fixed-size .slide
// measured 0 tall before its CSS loaded), setting the viewport to that
// degenerate size would collapse justify-center/absolute layouts and push
// content to negative coordinates. Keep at least the current viewport
// size and a sane 16:9 floor derived from the measured width.
const curVp = page.viewportSize() || { width: 1280, height: 720 };
const measuredW = Math.round(bodyDimensions.width) || curVp.width;
const minHeightFromWidth = Math.round(measuredW * (720 / 1280));
const vpW = Math.max(measuredW, curVp.width, 320);
const vpH = Math.max(Math.round(bodyDimensions.height), curVp.height, minHeightFromWidth, 240);
await page.setViewportSize({ width: vpW, height: vpH });
// Force a layout reflow after viewport resize so flex centering takes effect
await page.evaluate(() => void document.body.offsetHeight);
await page.waitForTimeout(200);
// Re-read body dimensions after reflow to capture correct layout
bodyDimensions = await getBodyDimensions(page);
// Chart libraries (ECharts / Chart.js) paint into <canvas> asynchronously
// after their CDN script loads, and re-draw (with entry animation) on the
// viewport resize above. Wait until every canvas has real pixels — polled
// via toDataURL length — so extractSlideData captures the finished chart
// instead of a blank canvas. Bounded so pages without charts (or with
// canvases that never paint, e.g. offline CDN) don't stall the run.
try {
const hasCanvas = await page.evaluate(() => document.querySelectorAll('canvas').length > 0);
if (hasCanvas) {
await page.waitForFunction(() => {
const cs = Array.from(document.querySelectorAll('canvas'));
if (cs.length === 0) return true;
return cs.every(c => {
const r = c.getBoundingClientRect();
if (r.width <= 1 || r.height <= 1) return true; // ignore hidden/degenerate
try { return c.toDataURL('image/png').length > 2000; } catch (e) { return true; }
});
}, { timeout: 6000, polling: 250 }).catch(() => {});
// Small settle for entry animations to reach their final frame.
await page.waitForTimeout(400);
}
} catch (e) { /* ignore — charts are best-effort */ }
const slideWIn = pres.presLayout ? pres.presLayout.width / EMU_PER_IN : 10;
const slideHIn = pres.presLayout ? pres.presLayout.height / EMU_PER_IN : 5.625;
slideData = await extractSlideData(page, { widthIn: slideWIn, heightIn: slideHIn });
} finally {
await browser.close();
}
// Apply emphasis/symbol fonts to numeric labels and direct emoji text.
applyEmphasisFont(slideData, effectiveFontConfig);
// v4: Rasterize any inline-SVG images to PNG before they reach pptxgenjs.
// pptxgenjs.addImage() rejects data:image/svg+xml URLs outright.
await rasterizeSvgImages(slideData, tmpDir);
// Materialize CSS object-fit before addImage(); pptxgenjs cannot infer original image dimensions.
await materializeObjectFitImages(slideData, tmpDir);
// Collect warnings
const overflowWarnings = [];
if (bodyDimensions.errors && bodyDimensions.errors.length > 0) {
overflowWarnings.push(...bodyDimensions.errors);
}
// v3: Collect all non-blocking warnings
const slideWidthIn = pres.presLayout ? pres.presLayout.width / EMU_PER_IN : 10;
const slideHeightIn = pres.presLayout ? pres.presLayout.height / EMU_PER_IN : 5.625;
const allWarnings = [...overflowWarnings];
if (slideData.errors) allWarnings.push(...slideData.errors);
if (slideData.warnings) allWarnings.push(...slideData.warnings);
allWarnings.push(...checkElementBounds(slideData, slideWidthIn, slideHeightIn));
allWarnings.push(...checkTextOverlaps(slideData));
const targetSlide = slide || pres.addSlide();
await addBackground(slideData, targetSlide, pres, tmpDir);
addElements(slideData, targetSlide, pres, tmpDir);
if (slideData.notes && typeof targetSlide.addNotes === 'function') {
targetSlide.addNotes(slideData.notes);
}
// Print warnings after successful conversion (non-blocking)
if (allWarnings.length > 0) {
const suggestions = allWarnings.map((w, i) => ` ${i + 1}. ${w}`).join('\n');
console.warn(`[html2pptx] ${htmlFile}: ${allWarnings.length} warning(s):\n${suggestions}`);
}
return { slide: targetSlide, placeholders: slideData.placeholders, warnings: allWarnings };
} catch (error) {
if (!error.message.startsWith(htmlFile)) {
throw new Error(`${htmlFile}: ${error.message}`);
}
throw error;
}
}
module.exports = html2pptx;