Pythonを利用してHTMLからPPTXを一発で作成する方法
やってみたけど先は長い笑
とりあえず現状を下記に記載します!
使い方はAIに聞いておくれ!!
コード
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
HTML -> PPTX converter (v14.9) [Final push version]
What this version fixes (based on your feedback + the uploaded stage2 pptx):
A) 文字が二重になる
- Root cause: table(=PPT Table) に文字を入れた上で、table配下の td/th のテキストも text_block として別shape生成していた。
- Fix: "table配下の text_block 生成禁止" を強化 + td/th 内の子要素も data-processed-text を付与して二重化を遮断。
B) 箇条書きにならない(特に table cell 内)
- Root cause: 箇条書きが CSS ::before で描かれていて、tableセル内では bullet情報が失われる。
- Fix: table cell の中で `::before` が bullet('•','・','-') の要素を検出し、
「段落配列(paragraphs)」として返し、PPT側で “本物のbullet段落” を作る。
C) 枠があるのに文字が出ない(取り漏れ)
- Root cause: flex/grid などで hasBlockChild 判定により text_block 候補が落ちていたケース。
- Fix: "direct text node があるなら block child があっても拾う" + skipAggregate条件を緩める。
Workflow:
- Stage 1: geometry only (shapes/lines/tables). NO text.
- Stage 2: keep geometry, then add text into shapes & table cells.
Usage:
PPT_STAGE=1 python3 master_convert.py
PPT_STAGE=2 python3 master_convert.py
Optional (no overflow safety valve):
PPT_TEXT_FIT=1 PPT_STAGE=2 python3 master_convert.py
"""
import asyncio
import glob
import math
import os
import re
import shutil
from typing import Any, Dict, List, Optional, Tuple
from playwright.async_api import async_playwright
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.dml import MSO_LINE_DASH_STYLE
from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN, MSO_AUTO_SIZE
from pptx.oxml.ns import qn
from pptx.oxml.xmlchemy import OxmlElement
from pptx.util import Inches, Pt
# ----------------------------------------------------------
# Config
# ----------------------------------------------------------
SLIDE_WIDTH_PX = 960
SLIDE_HEIGHT_PX = 540
PX_TO_INCH = 1.0 / 96.0
PX_TO_PT = 72.0 / 96.0 # 0.75
TMP_SVG_DIR = "svg_temp_v14"
EMOJI_FONT = os.getenv("PPT_EMOJI_FONT", "Apple Color Emoji")
PPT_STAGE = int(os.getenv("PPT_STAGE", "2")) # default Stage 2
PPT_TEXT_FIT = os.getenv("PPT_TEXT_FIT", "0").strip() == "1"
# ----------------------------------------------------------
# Helpers
# ----------------------------------------------------------
def px_to_in(px: float) -> Inches:
return Inches(float(px) * PX_TO_INCH)
def px_to_pt(px: float) -> Pt:
return Pt(float(px) * PX_TO_PT)
def clamp(v: float, lo: float, hi: float) -> float:
return max(lo, min(hi, v))
def parse_rgba(rgba_str: Optional[str]) -> Tuple[Optional[Tuple[int, int, int]], Optional[float]]:
if not rgba_str or rgba_str in ("transparent", "rgba(0, 0, 0, 0)"):
return None, None
m = re.search(r"rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d\.]+))?\)", rgba_str)
if not m:
return None, None
r, g, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
a = float(m.group(4)) if m.group(4) else 1.0
return (r, g, b), a
def css_dash_to_ppt(style: str) -> Optional[MSO_LINE_DASH_STYLE]:
style = (style or "").lower()
if style == "dashed":
return MSO_LINE_DASH_STYLE.DASH
if style == "dotted":
return MSO_LINE_DASH_STYLE.ROUND_DOT
return None
# Emoji detection (heuristic)
_EMOJI_RE = re.compile(
"["
"\U0001F1E6-\U0001F1FF"
"\U0001F300-\U0001F5FF"
"\U0001F600-\U0001F64F"
"\U0001F680-\U0001F6FF"
"\U0001F700-\U0001F77F"
"\U0001F780-\U0001F7FF"
"\U0001F800-\U0001F8FF"
"\U0001F900-\U0001F9FF"
"\U0001FA00-\U0001FAFF"
"\u2600-\u26FF"
"\u2700-\u27BF"
"\uFE0F"
"]+"
)
def split_text_by_emoji(text: str) -> List[Tuple[str, bool]]:
if not text:
return []
out: List[Tuple[str, bool]] = []
i = 0
while i < len(text):
m = _EMOJI_RE.match(text, i)
if m:
out.append((m.group(0), True))
i = m.end()
continue
j = i + 1
while j < len(text) and not _EMOJI_RE.match(text, j):
j += 1
out.append((text[i:j], False))
i = j
return [(s, is_e) for s, is_e in out if s]
def add_arrowhead_triangle(
slide,
tip_x_px: float,
tip_y_px: float,
angle_deg: float,
rgb: Tuple[int, int, int],
w_px: float = 10.0,
h_px: float = 7.0,
) -> None:
"""Approx arrowhead with triangle while keeping editability."""
try:
shp = slide.shapes.add_shape(
MSO_SHAPE.ISOSCELES_TRIANGLE,
px_to_in(tip_x_px - w_px / 2),
px_to_in(tip_y_px - h_px / 2),
px_to_in(w_px),
px_to_in(h_px),
)
shp.fill.solid()
shp.fill.fore_color.rgb = RGBColor(*rgb)
shp.line.fill.background()
shp.rotation = 90.0 + float(angle_deg)
except Exception:
pass
def set_rounded_rect_adjustment(shape, radius_px: float, w_px: float, h_px: float) -> None:
try:
if radius_px <= 0:
return
min_side_in = min(w_px, h_px) * PX_TO_INCH
if min_side_in <= 0:
return
r_in = radius_px * PX_TO_INCH
adj = clamp(r_in / min_side_in, 0.0, 0.5)
if hasattr(shape, "adjustments") and len(shape.adjustments) > 0:
shape.adjustments[0] = adj
except Exception:
pass
def _apply_run_style(run, rd: Dict[str, Any], base_fs_px: float) -> None:
fs_px = float(rd.get("fontSize") or base_fs_px or 12)
run.font.size = px_to_pt(fs_px)
run.font.bold = bool(rd.get("isBold"))
run.font.italic = bool(rd.get("isItalic"))
fam = rd.get("fontFamily")
if fam:
run.font.name = fam.split(",")[0].strip().strip("'\"")
c_rgb, _ = parse_rgba(rd.get("color"))
if c_rgb:
run.font.color.rgb = RGBColor(*c_rgb)
if rd.get("underline"):
run.font.underline = True
href = rd.get("href")
if href:
run.hyperlink.address = href
def _write_run_text_with_soft_breaks(
paragraph,
rd: Dict[str, Any],
text: str,
base_fs_px: float,
break_positions: List[int],
cursor: int,
) -> int:
"""Write `text` into paragraph with injected <a:br/> from browser-measured softBreaks."""
if not text:
return cursor
local_breaks = []
start = cursor
end = cursor + len(text)
for bp in break_positions:
if start < bp < end:
local_breaks.append(bp - start)
if not local_breaks:
for seg, is_emoji in split_text_by_emoji(text):
if not seg:
continue
run = paragraph.add_run()
run.text = seg
_apply_run_style(run, rd, base_fs_px)
if is_emoji:
run.font.name = EMOJI_FONT
return end
cut_points = [0] + local_breaks + [len(text)]
for i in range(len(cut_points) - 1):
a = cut_points[i]
b = cut_points[i + 1]
seg_text = text[a:b]
if seg_text:
for seg, is_emoji in split_text_by_emoji(seg_text):
if not seg:
continue
run = paragraph.add_run()
run.text = seg
_apply_run_style(run, rd, base_fs_px)
if is_emoji:
run.font.name = EMOJI_FONT
cursor += (b - a)
if i < len(cut_points) - 2:
paragraph.add_line_break()
return start + len(text)
def _set_paragraph_alignment(p, align: str) -> None:
a = (align or "").lower()
if a == "center":
p.alignment = PP_ALIGN.CENTER
elif a == "right":
p.alignment = PP_ALIGN.RIGHT
elif a == "justify":
p.alignment = PP_ALIGN.JUSTIFY
else:
p.alignment = PP_ALIGN.LEFT
def _set_textframe_margins(tf, pad: Dict[str, Any]) -> None:
tf.margin_top = px_to_in(float(pad.get("top") or 0))
tf.margin_bottom = px_to_in(float(pad.get("bottom") or 0))
tf.margin_left = px_to_in(float(pad.get("left") or 0))
tf.margin_right = px_to_in(float(pad.get("right") or 0))
def _apply_vertical_anchor_from_css(valign: str) -> MSO_ANCHOR:
v = (valign or "").lower()
if "middle" in v or "center" in v:
return MSO_ANCHOR.MIDDLE
if "bottom" in v:
return MSO_ANCHOR.BOTTOM
return MSO_ANCHOR.TOP
def _auto_size_mode() -> MSO_AUTO_SIZE:
return MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE if PPT_TEXT_FIT else MSO_AUTO_SIZE.NONE
def _ensure_pPr(paragraph):
p = paragraph._p
pPr = p.find(qn("a:pPr"))
if pPr is None:
pPr = OxmlElement("a:pPr")
p.insert(0, pPr)
return pPr
def _apply_bullet(paragraph, bullet_char: str, pad_left_px: float) -> None:
"""Apply a real PPT bullet to the paragraph."""
try:
pPr = _ensure_pPr(paragraph)
buNone = pPr.find(qn("a:buNone"))
if buNone is not None:
pPr.remove(buNone)
buChar = pPr.find(qn("a:buChar"))
if buChar is None:
buChar = OxmlElement("a:buChar")
pPr.insert(0, buChar)
buChar.set("char", bullet_char)
# Indent by padding-left (roughly aligns with HTML)
marL_pt = max(14.0, float(pad_left_px) * PX_TO_PT)
marL_emu = int(Pt(marL_pt).emu)
indent_emu = int(Pt(-12).emu) # hanging
pPr.set("marL", str(marL_emu))
pPr.set("indent", str(indent_emu))
except Exception:
pass
def _fill_paragraph_from_runs(paragraph, runs: List[Dict[str, Any]], base_fs_px: float, soft_breaks: List[int]) -> None:
cursor = 0
for rd in runs or []:
if rd.get("isBreak"):
paragraph.add_line_break()
continue
text = rd.get("text") or ""
if not text:
continue
cursor = _write_run_text_with_soft_breaks(paragraph, rd, text, base_fs_px, soft_breaks, cursor)
if paragraph.runs:
paragraph.runs[-1].text = paragraph.runs[-1].text.rstrip(" \t")
# ----------------------------------------------------------
# Main
# ----------------------------------------------------------
async def convert_html_to_pptx_v14() -> None:
html_files = glob.glob("*.html")
if not html_files:
print("エラー: HTMLファイルが見つかりません。")
return
target_html = html_files[0]
output_pptx = f"PolishV14_9_{os.path.splitext(target_html)[0]}_stage{PPT_STAGE}.pptx"
if os.path.exists(TMP_SVG_DIR):
shutil.rmtree(TMP_SVG_DIR)
os.makedirs(TMP_SVG_DIR, exist_ok=True)
print(f"v14.9 start: {target_html}")
print(f" - stage: {PPT_STAGE} (1=layout only, 2=add text)")
print(f" - no-overflow safety valve (PPT_TEXT_FIT): {PPT_TEXT_FIT}")
print(f" - emoji font fallback: {EMOJI_FONT}")
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(
viewport={"width": SLIDE_WIDTH_PX, "height": SLIDE_HEIGHT_PX},
device_scale_factor=2,
)
await page.goto("file://" + os.path.abspath(target_html))
data = await page.evaluate(
r"""() => {
const results = { slides: [] };
const RE_NUM = new RegExp("-?([0-9.]+)");
const RE_TABS_NL = new RegExp("[\\t\\n\\r]+", "g");
const RE_ALL_WS = new RegExp("\\s+", "g");
const RE_STRIP_DQ = new RegExp('^"|"$', "g");
const RE_STRIP_SQ = new RegExp("^'|'$", "g");
const RE_PATH_ML = new RegExp("M\\s*([0-9.]+)\\s*([0-9.]+)\\s*L\\s*([0-9.]+)\\s*([0-9.]+)", "i");
const px = (v) => {
if (!v) return 0;
if (typeof v === 'number') return v;
const m = String(v).match(RE_NUM);
return m ? parseFloat(m[1]) : 0;
};
const isTransparent = (c) => !c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)';
const normalizeForIndex = (txt) => {
if (txt === undefined || txt === null) return '';
return String(txt).replace(RE_TABS_NL, ' ');
};
const hasVisibleTextNode = (node) => {
if (node.nodeType !== 3) return false;
const s = (node.textContent || '');
return s.replace(RE_ALL_WS, '').length > 0;
};
const isInlineDisplay = (el) => {
const d = window.getComputedStyle(el).display;
return d.startsWith('inline') || ['SPAN','A','B','STRONG','I','EM','BR'].includes(el.tagName);
};
const hasNonBrElementChildren = (el) => {
for (const ch of el.children) {
if (ch.tagName !== 'BR') return true;
}
return false;
};
const getTextNodes = (el) => {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
acceptNode: (n) => hasVisibleTextNode(n) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT
});
const nodes = [];
let n;
while ((n = walker.nextNode())) nodes.push(n);
return nodes;
};
const mapOffsetToNode = (nodes, offset) => {
let cur = 0;
for (const n of nodes) {
const t = normalizeForIndex(n.textContent);
const L = t.length;
if (offset < cur + L) return { node: n, local: offset - cur, normText: t };
cur += L;
}
return null;
};
const computeSoftBreaks = (el) => {
try {
const nodes = getTextNodes(el);
if (nodes.length === 0) return [];
let full = '';
for (const n of nodes) full += normalizeForIndex(n.textContent);
const total = full.length;
if (total < 2) return [];
if (total > 1200) return []; // slightly relaxed, still guarded
const breaks = [];
let prevTop = null;
const getCharTop = (globalPos) => {
const mapped = mapOffsetToNode(nodes, globalPos);
if (!mapped) return null;
const r = document.createRange();
const localStart = mapped.local;
const localEnd = Math.min(localStart + 1, (mapped.node.textContent || '').length);
r.setStart(mapped.node, localStart);
r.setEnd(mapped.node, localEnd);
const rects = r.getClientRects();
if (!rects || rects.length === 0) return null;
const last = rects[rects.length - 1];
return last.top;
};
for (let i = 0; i < total; i++) {
const top = getCharTop(i);
if (top == null) continue;
if (prevTop == null) {
prevTop = top;
continue;
}
if (Math.abs(top - prevTop) > 1.0) {
breaks.push(i);
prevTop = top;
}
}
breaks.sort((a,b) => a-b);
const uniq = [];
let last = -1;
for (const b of breaks) {
if (b !== last && b > 0 && b < total) uniq.push(b);
last = b;
}
return uniq;
} catch (e) {
return [];
}
};
const uniqSortTol = (arr, tol=0.75) => {
arr.sort((a,b)=>a-b);
const out = [];
for (const v of arr) {
if (out.length === 0 || Math.abs(v - out[out.length-1]) > tol) out.push(v);
}
return out;
};
const idxNear = (edges, v) => {
let best = 0;
let bd = 1e9;
for (let i=0;i<edges.length;i++) {
const d = Math.abs(edges[i]-v);
if (d < bd) { bd = d; best = i; }
}
return best;
};
const stripQuotes = (content) => {
if (!content) return '';
let s = String(content);
s = s.replace(RE_STRIP_DQ, '');
s = s.replace(RE_STRIP_SQ, '');
return s;
};
const getBulletCharFromBefore = (el) => {
try {
const b = window.getComputedStyle(el, '::before');
if (!b || !b.content || b.content === 'none') return null;
const txt = stripQuotes(b.content).trim();
if (txt === '•' || txt === '・' || txt === '-') return txt;
return null;
} catch (e) {
return null;
}
};
// Extract runs (simple, but stable for PPT)
const extractRunsSimple = (rootEl) => {
const runs = [];
const pushRun = (run) => {
if (!run) return;
if (run.isBreak) { runs.push(run); return; }
const t = (run.text !== undefined && run.text !== null) ? run.text : '';
if (t.length === 0) return;
runs.push(run);
};
const processNode = (node) => {
if (node.nodeType === 3) {
const pEl = node.parentElement;
if (!pEl) return;
const ps = window.getComputedStyle(pEl);
const t = normalizeForIndex(node.textContent);
if (t.replace(RE_ALL_WS,'').length === 0) return;
const hasBottomBorder = px(ps.borderBottomWidth) > 0 && ps.borderBottomStyle !== 'none';
const underline = (ps.textDecorationLine && ps.textDecorationLine.includes('underline')) || hasBottomBorder;
const underlineColor = hasBottomBorder ? ps.borderBottomColor : null;
const a = pEl.closest('a');
const href = a ? a.href : null;
pushRun({
text: t,
color: ps.color,
fontSize: px(ps.fontSize),
fontWeight: ps.fontWeight,
fontStyle: ps.fontStyle,
fontFamily: ps.fontFamily,
isBold: (parseInt(ps.fontWeight) >= 600) || ps.fontWeight === 'bold',
isItalic: ps.fontStyle === 'italic' || ps.fontStyle === 'oblique',
underline,
underlineColor,
href
});
} else if (node.nodeType === 1) {
if (node.tagName === 'BR') { pushRun({ isBreak: true }); return; }
const d = window.getComputedStyle(node).display;
const isInlineNode = d.startsWith('inline') || ['A','SPAN','B','STRONG','I','EM'].includes(node.tagName);
if (isInlineNode) {
node.childNodes.forEach(processNode);
} else {
node.childNodes.forEach(processNode);
if (node.tagName === 'DIV' || node.tagName === 'P' || node.tagName === 'LI') {
pushRun({ isBreak: true });
}
}
}
};
rootEl.childNodes.forEach(processNode);
while (runs.length && runs[runs.length-1].isBreak) runs.pop();
return runs;
};
let globalOrder = 0;
document.querySelectorAll('.slide').forEach((slide, sIdx) => {
const slideRect = slide.getBoundingClientRect();
const slideData = { index: sIdx, elements: [], tables: [], svgs: [], svg_rasters: [] };
// ============ SVG ============
slide.querySelectorAll('svg').forEach((svg, i) => {
const r = svg.getBoundingClientRect();
const s = window.getComputedStyle(svg);
if (r.width < 5 || r.height < 5) return;
const id = `svg_${sIdx}_${i}`;
svg.setAttribute('data-svg-id', id);
const allowed = new Set(['svg','path','line','polyline','defs','marker','polygon']);
let simple = true;
svg.querySelectorAll('*').forEach(n => {
const tag = n.tagName ? n.tagName.toLowerCase() : '';
if (!allowed.has(tag)) simple = false;
});
const segments = [];
if (simple) {
const addSeg = (x1,y1,x2,y2, stroke, sw, dashed, arrowEnd) => {
segments.push({x1,y1,x2,y2, stroke, sw, dashed, arrowEnd});
};
svg.querySelectorAll('path').forEach(p => {
const d = p.getAttribute('d') || '';
const m = d.match(RE_PATH_ML);
if (!m) { simple = false; return; }
const cs = window.getComputedStyle(p);
addSeg(
parseFloat(m[1]), parseFloat(m[2]), parseFloat(m[3]), parseFloat(m[4]),
cs.stroke, px(cs.strokeWidth),
cs.strokeDasharray && cs.strokeDasharray !== 'none',
!!p.getAttribute('marker-end')
);
});
svg.querySelectorAll('line').forEach(l => {
const cs = window.getComputedStyle(l);
addSeg(
px(l.getAttribute('x1')), px(l.getAttribute('y1')),
px(l.getAttribute('x2')), px(l.getAttribute('y2')),
cs.stroke, px(cs.strokeWidth),
cs.strokeDasharray && cs.strokeDasharray !== 'none',
false
);
});
svg.querySelectorAll('polyline').forEach(pl => {
const pts = (pl.getAttribute('points') || '').trim();
const cs = window.getComputedStyle(pl);
if (!pts) { simple = false; return; }
const arr = pts.split(new RegExp("\\s+")).map(p => p.split(',').map(v => parseFloat(v)));
if (arr.length < 2) { simple = false; return; }
for (let k=0;k<arr.length-1;k++) {
addSeg(
arr[k][0], arr[k][1], arr[k+1][0], arr[k+1][1],
cs.stroke, px(cs.strokeWidth),
cs.strokeDasharray && cs.strokeDasharray !== 'none',
false
);
}
});
if (segments.length === 0) simple = false;
}
if (simple) {
slideData.svgs.push({
id,
kind: 'lines',
x: r.left - slideRect.left,
y: r.top - slideRect.top,
w: r.width,
h: r.height,
zIndex: parseInt(s.zIndex) || 0,
order: globalOrder++,
segments
});
} else {
slideData.svg_rasters.push({
id,
kind: 'raster',
x: r.left - slideRect.left,
y: r.top - slideRect.top,
w: r.width,
h: r.height,
zIndex: parseInt(s.zIndex) || 0,
order: globalOrder++
});
}
svg.querySelectorAll('*').forEach(el => el.setAttribute('data-processed', 'true'));
svg.setAttribute('data-processed', 'true');
});
// ============ TABLE ============
slide.querySelectorAll('table').forEach((table, tIdx) => {
if (table.hasAttribute('data-processed')) return;
const tr = table.getBoundingClientRect();
if (tr.width < 1 || tr.height < 1) return;
const tableData = {
id: `tbl_${sIdx}_${tIdx}`,
x: tr.left - slideRect.left,
y: tr.top - slideRect.top,
w: tr.width,
h: tr.height,
order: globalOrder++,
zIndex: parseInt(window.getComputedStyle(table).zIndex) || 0,
xEdges: [],
yEdges: [],
cells: []
};
const xEdges = [];
const yEdges = [];
table.querySelectorAll('th, td').forEach(cell => {
const r = cell.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return;
const left = r.left - slideRect.left;
const top = r.top - slideRect.top;
const right = r.right - slideRect.left;
const bottom = r.bottom - slideRect.top;
xEdges.push(left, right);
yEdges.push(top, bottom);
// Block later text_block extraction inside tables (strong)
cell.setAttribute('data-processed-text', 'true');
cell.querySelectorAll('*').forEach(x => x.setAttribute('data-processed-text','true'));
});
tableData.xEdges = uniqSortTol(xEdges, 0.75);
tableData.yEdges = uniqSortTol(yEdges, 0.75);
table.querySelectorAll('th, td').forEach(cell => {
const r = cell.getBoundingClientRect();
const s = window.getComputedStyle(cell);
if (r.width < 1 || r.height < 1) return;
const left = r.left - slideRect.left;
const top = r.top - slideRect.top;
const right = r.right - slideRect.left;
const bottom = r.bottom - slideRect.top;
const c0 = idxNear(tableData.xEdges, left);
const c1e = idxNear(tableData.xEdges, right);
const r0 = idxNear(tableData.yEdges, top);
const r1e = idxNear(tableData.yEdges, bottom);
const colSpanGeom = Math.max(1, c1e - c0);
const rowSpanGeom = Math.max(1, r1e - r0);
const colSpan = Math.max(colSpanGeom, cell.colSpan || 1);
const rowSpan = Math.max(rowSpanGeom, cell.rowSpan || 1);
// Detect bullet paragraphs inside this cell (for CSS ::before bullets)
const bulletParas = [];
cell.querySelectorAll('*').forEach(el => {
const bc = getBulletCharFromBefore(el);
if (!bc) return;
const t = (el.innerText || '').trim();
if (!t) return;
const rr = el.getBoundingClientRect();
if (rr.width < 1 || rr.height < 1) return;
const es = window.getComputedStyle(el);
bulletParas.push({
bulletChar: bc,
textAlign: es.textAlign || s.textAlign,
lineHeightPx: px(es.lineHeight) || px(s.lineHeight),
fontSizePx: px(es.fontSize) || px(s.fontSize),
padLeftPx: px(es.paddingLeft) || px(s.paddingLeft),
runs: extractRunsSimple(el),
softBreaks: computeSoftBreaks(el)
});
});
// If bulletParas exist, use them as structured paragraphs; else fallback to whole-cell runs
const cellObj = {
x: left, y: top, w: r.width, h: r.height,
startRow: r0, startCol: c0,
rowSpan, colSpan,
bgColor: s.backgroundColor,
textAlign: s.textAlign,
verticalAlign: s.verticalAlign,
lineHeightPx: px(s.lineHeight),
fontSizePx: px(s.fontSize),
padding: {
top: px(s.paddingTop),
right: px(s.paddingRight),
bottom: px(s.paddingBottom),
left: px(s.paddingLeft),
}
};
if (bulletParas.length > 0) {
cellObj.paragraphs = bulletParas;
} else {
cellObj.runs = extractRunsSimple(cell);
cellObj.softBreaks = computeSoftBreaks(cell);
}
tableData.cells.push(cellObj);
});
slideData.tables.push(tableData);
table.setAttribute('data-processed', 'true');
});
// ============ GENERAL ELEMENTS ============
const allElements = slide.querySelectorAll('*');
allElements.forEach(el => {
if (el === slide) return;
if (el.hasAttribute('data-processed')) return;
if (el.hasAttribute('data-processed-text')) return;
// do not create text blocks from inside tables (prevents duplicate text)
if (el.closest('table')) return;
const r = el.getBoundingClientRect();
const s = window.getComputedStyle(el);
if (r.width < 1 || r.height < 1 || s.display === 'none' || s.visibility === 'hidden') return;
const domOrder = globalOrder++;
// ---- RECT (background/border) ----
const hasBg = !isTransparent(s.backgroundColor);
const bTop = px(s.borderTopWidth) > 0 && !isTransparent(s.borderTopColor) && s.borderTopStyle !== 'none';
const bRight = px(s.borderRightWidth) > 0 && !isTransparent(s.borderRightColor) && s.borderRightStyle !== 'none';
const bBottom = px(s.borderBottomWidth) > 0 && !isTransparent(s.borderBottomColor) && s.borderBottomStyle !== 'none';
const bLeft = px(s.borderLeftWidth) > 0 && !isTransparent(s.borderLeftColor) && s.borderLeftStyle !== 'none';
const hasBorder = bTop || bRight || bBottom || bLeft;
const isInlineLike = s.display.startsWith('inline');
const borderOnlyBottom = (!bTop && !bRight && bBottom && !bLeft);
const inlineUnderlineLike = isInlineLike && !hasBg && borderOnlyBottom;
if ((hasBg || hasBorder) && !inlineUnderlineLike && !['TR','TBODY','THEAD','TABLE','TD','TH'].includes(el.tagName)) {
slideData.elements.push({
type: 'rect',
x: r.left - slideRect.left,
y: r.top - slideRect.top,
w: r.width,
h: r.height,
bgColor: s.backgroundColor,
borderTop: { w: px(s.borderTopWidth), c: s.borderTopColor, s: s.borderTopStyle },
borderRight: { w: px(s.borderRightWidth), c: s.borderRightColor, s: s.borderRightStyle },
borderBottom: { w: px(s.borderBottomWidth), c: s.borderBottomColor, s: s.borderBottomStyle },
borderLeft: { w: px(s.borderLeftWidth), c: s.borderLeftColor, s: s.borderLeftStyle },
borderRadius: px(s.borderRadius),
zIndex: parseInt(s.zIndex) || 0,
order: domOrder
});
}
// ---- ::before (dots etc) ----
const before = window.getComputedStyle(el, '::before');
if (before && before.content && before.content !== 'none') {
const bw = px(before.width);
const bh = px(before.height);
const leftPx = px(before.left);
const topPx = px(before.top);
const canPos = String(before.left).includes('px') && String(before.top).includes('px');
const hasBox = bw > 0 && bh > 0 && !isTransparent(before.backgroundColor);
const content = before.content;
const hasText = content && content !== '""' && content !== "''";
if (canPos && (hasBox || hasText)) {
if (hasBox) {
slideData.elements.push({
type: 'pseudo_shape',
shape: 'oval',
x: (r.left - slideRect.left) + leftPx,
y: (r.top - slideRect.top) + topPx,
w: bw,
h: bh,
bgColor: before.backgroundColor,
zIndex: (parseInt(s.zIndex) || 0) + 1,
order: domOrder + 0.1
});
} else if (hasText) {
let txt = stripQuotes(content).trim();
const isBullet = (txt === '•' || txt === '・' || txt === '-');
if (isBullet) {
el.setAttribute('data-bullet-before', txt);
} else {
slideData.elements.push({
type: 'pseudo_text',
x: (r.left - slideRect.left) + leftPx,
y: (r.top - slideRect.top) + topPx,
w: Math.max(10, bw || 12),
h: Math.max(10, bh || px(before.fontSize) * 1.2),
text: txt,
color: before.color,
fontSize: px(before.fontSize) || px(s.fontSize),
fontWeight: before.fontWeight || s.fontWeight,
fontFamily: before.fontFamily || s.fontFamily,
zIndex: (parseInt(s.zIndex) || 0) + 1,
order: domOrder + 0.1
});
}
}
}
}
// ---- TEXT BLOCK ----
const display = s.display;
const layoutContainer = (display === 'flex' || display === 'grid');
let hasDirectText = false;
el.childNodes.forEach(n => { if (hasVisibleTextNode(n)) hasDirectText = true; });
let hasInlineChild = false;
el.childNodes.forEach(n => {
if (n.nodeType === 1 && isInlineDisplay(n) && n.textContent && n.textContent.trim().length > 0) hasInlineChild = true;
});
let hasBlockChild = false;
for (const ch of el.children) {
const cd = window.getComputedStyle(ch).display;
const isInlineChildDisplay = cd.startsWith('inline') || ch.tagName === 'BR';
if (!isInlineChildDisplay && ch.textContent && ch.textContent.trim().length > 0) {
hasBlockChild = true;
break;
}
}
// Relaxed: if direct text exists, accept even if block children exist.
// If only inline child text exists, keep the original "no block child" rule.
const isTextCandidate = (
(hasDirectText) ||
(hasInlineChild && !hasBlockChild)
) && !['SVG','TR','TABLE','TBODY','THEAD','TD','TH'].includes(el.tagName);
// skipAggregate only when layout container has no direct text (otherwise we would lose it)
const skipAggregate = layoutContainer && hasNonBrElementChildren(el) && !hasDirectText;
if (!isTextCandidate || skipAggregate) return;
const runs = [];
const pushRun = (run) => {
if (!run) return;
if (run.isBreak) { runs.push(run); return; }
const t = (run.text !== undefined && run.text !== null) ? run.text : '';
if (t.length === 0) return;
runs.push(run);
};
const processNode = (node) => {
if (node.nodeType === 3) {
const pEl = node.parentElement;
if (!pEl) return;
const ps = window.getComputedStyle(pEl);
const t = normalizeForIndex(node.textContent);
if (t.replace(RE_ALL_WS,'').length === 0) return;
const hasBottomBorder = px(ps.borderBottomWidth) > 0 && ps.borderBottomStyle !== 'none';
const underline = (ps.textDecorationLine && ps.textDecorationLine.includes('underline')) || hasBottomBorder;
const underlineColor = hasBottomBorder ? ps.borderBottomColor : null;
const a = pEl.closest('a');
const href = a ? a.href : null;
pushRun({
text: t,
color: ps.color,
fontSize: px(ps.fontSize),
fontWeight: ps.fontWeight,
fontStyle: ps.fontStyle,
fontFamily: ps.fontFamily,
isBold: (parseInt(ps.fontWeight) >= 600) || ps.fontWeight === 'bold',
isItalic: ps.fontStyle === 'italic' || ps.fontStyle === 'oblique',
underline,
underlineColor,
href
});
} else if (node.nodeType === 1) {
if (node.tagName === 'BR') { pushRun({ isBreak: true }); return; }
const d = window.getComputedStyle(node).display;
const isInlineNode = d.startsWith('inline') || ['A','SPAN','B','STRONG','I','EM'].includes(node.tagName);
if (isInlineNode) {
node.childNodes.forEach(processNode);
node.setAttribute('data-processed-text', 'true');
}
}
};
el.childNodes.forEach(processNode);
if (runs.length === 0) return;
const softBreaks = computeSoftBreaks(el);
const bulletChar = el.getAttribute('data-bullet-before');
slideData.elements.push({
type: 'text_block',
x: r.left - slideRect.left,
y: r.top - slideRect.top,
w: r.width,
h: r.height,
runs,
softBreaks,
bulletChar,
textAlign: s.textAlign,
lineHeightPx: px(s.lineHeight),
fontSizePx: px(s.fontSize),
display: s.display,
justifyContent: s.justifyContent,
padding: {
top: px(s.paddingTop),
right: px(s.paddingRight),
bottom: px(s.paddingBottom),
left: px(s.paddingLeft),
},
zIndex: (parseInt(s.zIndex) || 0) + 2,
order: domOrder
});
el.setAttribute('data-processed-text', 'true');
});
results.slides.push(slideData);
});
return results;
}"""
)
# Rasterize non-simple SVGs
for slide in data["slides"]:
for svg in slide.get("svg_rasters", []):
el = page.locator(f'[data-svg-id="{svg["id"]}"]')
path = os.path.join(TMP_SVG_DIR, f"{svg['id']}.png")
await el.screenshot(path=path, omit_background=True)
svg["path"] = path
await browser.close()
# ======================================================
# Build PPTX
# ======================================================
def _item_priority(it: Dict[str, Any]) -> int:
# Lower = drawn earlier (back). Higher = drawn later (front).
# Key idea: background rectangles should be behind tables; tables behind text.
# Also keep SVG connectors above tables when needed.
if it.get("id", "").startswith("tbl_"):
return 10
if it.get("kind") == "raster":
return 10
if it.get("kind") == "lines":
return 15
t = it.get("type")
if t in ("rect",):
return 0
if t in ("pseudo_shape",):
return 5
if t in ("pseudo_text",):
return 20
if t in ("text_block",):
return 30
return 10
prs = Presentation()
prs.slide_width = Inches(SLIDE_WIDTH_PX * PX_TO_INCH)
prs.slide_height = Inches(SLIDE_HEIGHT_PX * PX_TO_INCH)
for sData in data["slides"]:
slide = prs.slides.add_slide(prs.slide_layouts[6])
queue: List[Dict[str, Any]] = []
queue.extend(sData.get("tables", []))
queue.extend(sData.get("svgs", []))
queue.extend(sData.get("svg_rasters", []))
queue.extend(sData.get("elements", []))
queue.sort(key=lambda it: (int(it.get("zIndex", 0)), _item_priority(it), float(it.get("order", 0))))
for item in queue:
# ---------------- TABLE ----------------
if item.get("id", "").startswith("tbl_") and item.get("xEdges") and item.get("yEdges"):
xEdges = [float(x) for x in item["xEdges"]]
yEdges = [float(y) for y in item["yEdges"]]
rows = max(1, len(yEdges) - 1)
cols = max(1, len(xEdges) - 1)
gf = slide.shapes.add_table(
rows,
cols,
px_to_in(float(item["x"])),
px_to_in(float(item["y"])),
px_to_in(float(item["w"])),
px_to_in(float(item["h"])),
)
tbl = gf.table
try:
tbl.first_row = False
tbl.first_col = False
tbl.last_row = False
tbl.last_col = False
tbl.horz_banding = False
tbl.vert_banding = False
except Exception:
pass
for c in range(cols):
w = max(0.1, xEdges[c + 1] - xEdges[c])
tbl.columns[c].width = px_to_in(w)
for r in range(rows):
h = max(0.1, yEdges[r + 1] - yEdges[r])
tbl.rows[r].height = px_to_in(h)
# Merge + fill (no text in stage1)
for cdef in item.get("cells", []):
r0 = int(cdef.get("startRow") or 0)
c0 = int(cdef.get("startCol") or 0)
rs = int(cdef.get("rowSpan") or 1)
cs = int(cdef.get("colSpan") or 1)
r1 = min(rows - 1, r0 + rs - 1)
c1 = min(cols - 1, c0 + cs - 1)
try:
if rs > 1 or cs > 1:
tbl.cell(r0, c0).merge(tbl.cell(r1, c1))
except Exception:
pass
rgb, a = parse_rgba(cdef.get("bgColor"))
if rgb:
try:
cell = tbl.cell(r0, c0)
cell.fill.solid()
cell.fill.fore_color.rgb = RGBColor(*rgb)
except Exception:
pass
if PPT_STAGE <= 1:
try:
tbl.cell(r0, c0).text = ""
except Exception:
pass
# Stage 2: insert text into cells
if PPT_STAGE >= 2:
for cdef in item.get("cells", []):
r0 = int(cdef.get("startRow") or 0)
c0 = int(cdef.get("startCol") or 0)
try:
cell = tbl.cell(r0, c0)
tf = cell.text_frame
tf.clear()
tf.word_wrap = False
tf.auto_size = _auto_size_mode()
pad = cdef.get("padding") or {}
_set_textframe_margins(tf, pad)
try:
cell.vertical_anchor = _apply_vertical_anchor_from_css(cdef.get("verticalAlign"))
except Exception:
pass
# Case 1: structured bullet paragraphs
if cdef.get("paragraphs"):
paras = cdef["paragraphs"] or []
first = True
for pd in paras:
if first:
p = tf.paragraphs[0]
first = False
else:
p = tf.add_paragraph()
p.text = ""
_set_paragraph_alignment(p, pd.get("textAlign") or cdef.get("textAlign") or "")
base_fs_px = float(pd.get("fontSizePx") or cdef.get("fontSizePx") or 0)
line_h_px = float(pd.get("lineHeightPx") or cdef.get("lineHeightPx") or 0)
if line_h_px and base_fs_px:
try:
p.line_spacing = float(line_h_px / base_fs_px)
except Exception:
pass
bullet_char = pd.get("bulletChar") or ""
if bullet_char:
_apply_bullet(p, bullet_char, float(pd.get("padLeftPx") or pad.get("left") or 0.0))
soft_breaks = pd.get("softBreaks") or []
soft_breaks = sorted({int(x) for x in soft_breaks if isinstance(x, (int, float)) and x > 0})
_fill_paragraph_from_runs(p, pd.get("runs") or [], base_fs_px, soft_breaks)
# Case 2: normal cell runs
else:
p = tf.paragraphs[0]
p.text = ""
_set_paragraph_alignment(p, cdef.get("textAlign") or "")
base_fs_px = float(cdef.get("fontSizePx") or 0)
line_h_px = float(cdef.get("lineHeightPx") or 0)
if line_h_px and base_fs_px:
try:
p.line_spacing = float(line_h_px / base_fs_px)
except Exception:
pass
soft_breaks = cdef.get("softBreaks") or []
soft_breaks = sorted({int(x) for x in soft_breaks if isinstance(x, (int, float)) and x > 0})
_fill_paragraph_from_runs(p, cdef.get("runs") or [], base_fs_px, soft_breaks)
except Exception:
pass
continue
# ---------------- SVG lines -> connectors ----------------
if item.get("kind") == "lines" and item.get("segments") is not None:
base_x = float(item["x"])
base_y = float(item["y"])
for seg in item["segments"]:
x1 = base_x + float(seg["x1"])
y1 = base_y + float(seg["y1"])
x2 = base_x + float(seg["x2"])
y2 = base_y + float(seg["y2"])
ln = slide.shapes.add_connector(
MSO_CONNECTOR.STRAIGHT,
px_to_in(x1),
px_to_in(y1),
px_to_in(x2),
px_to_in(y2),
)
c_rgb, _ = parse_rgba(seg.get("stroke"))
if c_rgb:
ln.line.color.rgb = RGBColor(*c_rgb)
sw = float(seg.get("sw") or 1.0)
ln.line.width = px_to_pt(sw)
if seg.get("dashed"):
ln.line.dash_style = MSO_LINE_DASH_STYLE.DASH
if seg.get("arrowEnd"):
dx = x2 - x1
dy = y2 - y1
angle = math.degrees(math.atan2(dy, dx)) if (dx or dy) else 0.0
if c_rgb:
add_arrowhead_triangle(slide, x2, y2, angle, c_rgb)
continue
# ---------------- SVG raster fallback ----------------
if item.get("kind") == "raster" and item.get("path"):
path = item["path"]
if os.path.exists(path):
slide.shapes.add_picture(
path,
px_to_in(item["x"]),
px_to_in(item["y"]),
width=px_to_in(item["w"]),
)
continue
itype = item.get("type")
if PPT_STAGE <= 1 and itype in ("text_block", "pseudo_text"):
continue
if itype == "pseudo_shape":
sh = slide.shapes.add_shape(
MSO_SHAPE.OVAL,
px_to_in(item["x"]),
px_to_in(item["y"]),
px_to_in(item["w"]),
px_to_in(item["h"]),
)
rgb, a = parse_rgba(item.get("bgColor"))
if rgb:
sh.fill.solid()
sh.fill.fore_color.rgb = RGBColor(*rgb)
if a is not None and a < 1.0:
sh.fill.transparency = 1.0 - a
else:
sh.fill.background()
sh.line.fill.background()
continue
if itype == "pseudo_text":
tx = slide.shapes.add_textbox(
px_to_in(item["x"]),
px_to_in(item["y"]),
px_to_in(item["w"]),
px_to_in(item["h"]),
)
tf = tx.text_frame
tf.word_wrap = False
tf.auto_size = _auto_size_mode()
tf.margin_left = Inches(0)
tf.margin_right = Inches(0)
tf.margin_top = Inches(0)
tf.margin_bottom = Inches(0)
tf.vertical_anchor = MSO_ANCHOR.TOP
p = tf.paragraphs[0]
p.text = ""
r = p.add_run()
r.text = item.get("text", "")
r.font.size = px_to_pt(float(item.get("fontSize") or 10))
r.font.bold = (
(str(item.get("fontWeight") or "").isdigit() and int(item["fontWeight"]) >= 600)
or str(item.get("fontWeight")) == "bold"
)
fam = item.get("fontFamily")
if fam:
r.font.name = fam.split(",")[0].strip().strip("'\"")
c_rgb, _ = parse_rgba(item.get("color"))
if c_rgb:
r.font.color.rgb = RGBColor(*c_rgb)
continue
if itype == "rect":
left = px_to_in(item["x"])
top = px_to_in(item["y"])
w = px_to_in(item["w"])
h = px_to_in(item["h"])
radius = float(item.get("borderRadius") or 0.0)
shape_type = MSO_SHAPE.ROUNDED_RECTANGLE if radius > 0 else MSO_SHAPE.RECTANGLE
shape = slide.shapes.add_shape(shape_type, left, top, w, h)
rgb, alpha = parse_rgba(item.get("bgColor"))
if rgb:
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor(*rgb)
if alpha is not None and alpha < 1.0:
shape.fill.transparency = 1.0 - alpha
else:
shape.fill.background()
shape.shadow.inherit = False
shape.line.fill.background()
borders = [
item.get("borderTop"),
item.get("borderRight"),
item.get("borderBottom"),
item.get("borderLeft"),
]
best = None
best_w = 0.0
for b in borders:
if not b:
continue
bw = float(b.get("w") or 0.0)
if bw > best_w and (b.get("s") != "none"):
best_w = bw
best = b
if best and best_w > 0:
b_rgb, _ = parse_rgba(best.get("c"))
if b_rgb:
shape.line.color.rgb = RGBColor(*b_rgb)
shape.line.width = px_to_pt(best_w)
ds = css_dash_to_ppt(best.get("s"))
if ds:
shape.line.dash_style = ds
if radius > 0:
set_rounded_rect_adjustment(shape, radius, float(item["w"]), float(item["h"]))
continue
if itype == "text_block":
tx = slide.shapes.add_textbox(
px_to_in(item["x"]),
px_to_in(item["y"]),
px_to_in(item["w"]),
px_to_in(item["h"]),
)
tf = tx.text_frame
tf.word_wrap = False
tf.auto_size = _auto_size_mode()
pad = item.get("padding") or {}
_set_textframe_margins(tf, pad)
display = (item.get("display") or "").lower()
v_anchor = MSO_ANCHOR.TOP
if display == "flex":
jc = (item.get("justifyContent") or "").lower()
if "center" in jc:
v_anchor = MSO_ANCHOR.MIDDLE
elif "flex-end" in jc or "end" in jc:
v_anchor = MSO_ANCHOR.BOTTOM
tf.vertical_anchor = v_anchor
p = tf.paragraphs[0]
p.text = ""
_set_paragraph_alignment(p, item.get("textAlign") or "")
bullet_char = item.get("bulletChar") or ""
if bullet_char:
_apply_bullet(p, bullet_char, float(pad.get("left") or 0.0))
line_h_px = float(item.get("lineHeightPx") or 0)
base_fs_px = float(item.get("fontSizePx") or 0)
if line_h_px and base_fs_px:
try:
p.line_spacing = float(line_h_px / base_fs_px)
except Exception:
pass
soft_breaks = item.get("softBreaks") or []
soft_breaks = sorted({int(x) for x in soft_breaks if isinstance(x, (int, float)) and x > 0})
_fill_paragraph_from_runs(p, item.get("runs") or [], base_fs_px, soft_breaks)
continue
prs.save(output_pptx)
if os.path.exists(TMP_SVG_DIR):
shutil.rmtree(TMP_SVG_DIR)
print(f"done: {output_pptx}")
if __name__ == "__main__":
asyncio.run(convert_html_to_pptx_v14())