PM: html2pptx v3 - add rich text preservation + PPTX adapt

- extractRuns(): recursive DOM walker to extract inline formatting
  (bold, italic, color, fontSize, fontFamily) from strong/em/span
- Element data now includes 'runs' array alongside flat text
- PPTX generation: rich text uses pptxgenjs run array format,
  plain text keeps original single-style logic
- Added bounds constraints to prevent textbox overflow
- Verified: 14 slides from dashi-deck, 10 from simple,
  54 rich text elements detected

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 20:20:22 +08:00
co-authored by Claude Sonnet 5
parent ca1e4dfa72
commit 0f8a797b7a
+98 -17
View File
@@ -177,6 +177,30 @@ async function main() {
style.visibility !== 'hidden'; style.visibility !== 'hidden';
}); });
// Recursively extract inline text runs with computed styles
function extractRuns(node) {
var runs = [];
for (var ri = 0; ri < node.childNodes.length; ri++) {
var child = node.childNodes[ri];
if (child.nodeType === 3) {
var t = (child.textContent || '').trim();
if (t) runs.push({ text: t });
} else if (child.nodeType === 1) {
var cs = window.getComputedStyle(child);
var childRuns = extractRuns(child);
for (var cj = 0; cj < childRuns.length; cj++) {
childRuns[cj].bold = childRuns[cj].bold || parseInt(cs.fontWeight) >= 600;
childRuns[cj].italic = childRuns[cj].italic || cs.fontStyle === 'italic';
childRuns[cj].color = childRuns[cj].color || cs.color;
childRuns[cj].fontSize = childRuns[cj].fontSize || parseFloat(cs.fontSize);
childRuns[cj].fontFamily = childRuns[cj].fontFamily || cs.fontFamily;
}
runs = runs.concat(childRuns);
}
}
return runs.length > 0 ? runs : [{ text: node.textContent || '' }];
}
// Tags whose full text content should be a single text box // Tags whose full text content should be a single text box
var TEXT_BLOCK_TAGS = new Set([ var TEXT_BLOCK_TAGS = new Set([
'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
@@ -207,7 +231,9 @@ async function main() {
} }
if (skip) continue; if (skip) continue;
var text = (el.textContent || '').trim(); var runs = extractRuns(el);
if (!runs || runs.length === 0) continue;
var text = runs.map(function (r) { return r.text || ''; }).join(' ').trim();
if (!text) continue; if (!text) continue;
var rect = el.getBoundingClientRect(); var rect = el.getBoundingClientRect();
@@ -215,7 +241,13 @@ async function main() {
var style = window.getComputedStyle(el); var style = window.getComputedStyle(el);
// Truncate overly long runs
runs.forEach(function (r) {
if (r.text && r.text.length > 2000) r.text = r.text.substring(0, 2000);
});
elements.push({ elements.push({
runs: runs,
text: text.substring(0, 2000), text: text.substring(0, 2000),
x: rect.left - slideRect.left, x: rect.left - slideRect.left,
y: rect.top - slideRect.top, y: rect.top - slideRect.top,
@@ -285,25 +317,74 @@ async function main() {
var w = Math.max(el.width * scaleX, 0.3); var w = Math.max(el.width * scaleX, 0.3);
var h = Math.max(el.height * scaleY, 0.2); var h = Math.max(el.height * scaleY, 0.2);
// Font size: CSS px → pt (1pt = 1/72in, CSS 1px = 1/96in, so px × 72/96 = pt) // Bounds constraints: prevent textbox from overflowing slide
var MAX_W = 10 - x;
var MAX_H = 5.625 - y;
w = Math.min(w, MAX_W);
h = Math.min(h, MAX_H);
// Base font size: CSS px → pt (1pt = 1/72in, CSS 1px = 1/96in)
var fontSize = el.fontSize * 72 / 96; var fontSize = el.fontSize * 72 / 96;
fontSize = Math.max(fontSize, 8); fontSize = Math.max(fontSize, 8);
slide.addText(t, { // Check if rich text is needed (runs with differing styles)
x: x, var hasRuns = el.runs && el.runs.length > 0;
y: y, var isSimpleRun = hasRuns && el.runs.length === 1 &&
w: w, el.runs[0].bold === undefined &&
h: h, el.runs[0].italic === undefined &&
fontSize: fontSize, el.runs[0].color === undefined &&
fontFace: (el.fontFamily || 'Arial').split(',')[0].trim().replace(/['"]/g, ''), el.runs[0].fontSize === undefined &&
color: parseCSSColor(el.color, '#000000'), el.runs[0].fontFamily === undefined;
bold: parseFontWeight(el.fontWeight),
italic: el.fontStyle === 'italic' || el.fontStyle === 'oblique', if (hasRuns && !isSimpleRun && el.runs.length > 1) {
align: normalizeAlign(el.textAlign), // Rich text: render each run with its own style
valign: 'top', var pptxRuns = el.runs.map(function (run) {
wrap: true, if (!run.text || !run.text.trim()) return null;
margin: 0, var baseSize = el.fontSize * 72 / 96;
}); return {
text: run.text,
options: {
fontSize: run.fontSize ? Math.max(run.fontSize * 72 / 96, 8) : baseSize,
bold: run.bold === true || (run.bold === undefined && parseFontWeight(el.fontWeight)),
italic: run.italic === true || (run.italic === undefined && (el.fontStyle === 'italic' || el.fontStyle === 'oblique')),
color: run.color ? parseCSSColor(run.color, '#000000') : parseCSSColor(el.color, '#000000'),
fontFace: run.fontFamily
? run.fontFamily.split(',')[0].trim().replace(/['"]/g, '')
: (el.fontFamily || 'Arial').split(',')[0].trim().replace(/['"]/g, ''),
},
};
}).filter(Boolean);
if (pptxRuns.length > 0) {
slide.addText(pptxRuns, {
x: x,
y: y,
w: w,
h: h,
align: normalizeAlign(el.textAlign),
valign: 'top',
wrap: true,
margin: 0,
});
}
} else {
// Plain text: original single-style logic
slide.addText(t, {
x: x,
y: y,
w: w,
h: h,
fontSize: fontSize,
fontFace: (el.fontFamily || 'Arial').split(',')[0].trim().replace(/['"]/g, ''),
color: parseCSSColor(el.color, '#000000'),
bold: parseFontWeight(el.fontWeight),
italic: el.fontStyle === 'italic' || el.fontStyle === 'oblique',
align: normalizeAlign(el.textAlign),
valign: 'top',
wrap: true,
margin: 0,
});
}
} catch (err) { } catch (err) {
// Skip individual element failure // Skip individual element failure
} }