0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

HTMLからパワポに変換する方法

Posted at

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())

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?