Skip to content

Commit ff4d6e6

Browse files
authored
Merge pull request #91 from dofliu/claude/pptx-native-augment
feat(pptx): 上傳 PPTX 原檔就地補圖 — 原文字保持可編輯
2 parents 7df2dc5 + bf41117 commit ff4d6e6

11 files changed

Lines changed: 593 additions & 5 deletions

File tree

core/pptx_augment.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""core/pptx_augment.py — 在「原始 .pptx」上就地補圖 (原文字保持可編輯)。
2+
3+
定位
4+
----
5+
slides_pdf 補圖是把每頁 PDF 渲成 PNG, 原頁文字因此變成圖、不可再編輯。若使用者
6+
手上是 .pptx 原檔, 更好的作法是直接在原檔上動手:
7+
8+
1. pptx → pdf → 逐頁 PNG (僅供分析, 用 LibreOffice + PyMuPDF)。
9+
2. 偵測缺圖頁 + 每頁空白區 (複用 core.slide_image_gen)。
10+
3. 為缺圖頁生 AI 配圖 (複用 generate_slide_image; prompt 取自該頁文字)。
11+
4. 打開「原始 .pptx」, 把配圖**加進**該頁空白區 — 原本的文字方塊 / 圖形全部
12+
原封不動, 仍可在 PowerPoint 內編輯。
13+
14+
如此匯出的新簡報文字可改、圖在空白處, 解決「整頁變成一張圖」的問題。
15+
16+
依賴
17+
----
18+
- python-pptx (匯入/匯出 .pptx)
19+
- LibreOffice (soffice) — pptx→pdf 渲染; 缺它則 render_pptx_to_pdf raise。
20+
- PyMuPDF (fitz) — pdf→png。
21+
mock=True 走 PIL 佔位圖 (不打 Gemini), 但仍需 LibreOffice 渲染原頁 (除非 caller
22+
直接給 page_pngs)。
23+
"""
24+
from __future__ import annotations
25+
26+
import logging
27+
import re
28+
import shutil
29+
import subprocess
30+
from pathlib import Path
31+
32+
logger = logging.getLogger(__name__)
33+
34+
35+
def render_pptx_to_pdf(src_pptx: str | Path, out_dir: str | Path, *, timeout: int = 180) -> Path:
36+
"""用 LibreOffice headless 把 .pptx 轉成 .pdf, 回傳 pdf 路徑。"""
37+
soffice = shutil.which("soffice") or shutil.which("libreoffice")
38+
if not soffice:
39+
raise RuntimeError(
40+
"找不到 LibreOffice (soffice), 無法把 PPTX 轉成 PDF 做分析。"
41+
"請安裝 libreoffice-impress。"
42+
)
43+
src_pptx = Path(src_pptx)
44+
out_dir = Path(out_dir)
45+
out_dir.mkdir(parents=True, exist_ok=True)
46+
# 用獨立 profile 避免併發鎖
47+
profile = (out_dir / "_lo_profile").resolve().as_uri()
48+
cmd = [
49+
soffice, "--headless", f"-env:UserInstallation={profile}",
50+
"--convert-to", "pdf", "--outdir", str(out_dir), str(src_pptx),
51+
]
52+
res = subprocess.run(cmd, capture_output=True, timeout=timeout)
53+
pdf = out_dir / (src_pptx.stem + ".pdf")
54+
if res.returncode != 0 or not pdf.exists():
55+
raise RuntimeError(
56+
f"LibreOffice 轉檔失敗 (code {res.returncode}): "
57+
f"{res.stderr.decode('utf-8', 'replace')[:300]}"
58+
)
59+
return pdf
60+
61+
62+
def _render_pdf_pages(pdf: Path, out_dir: Path, *, zoom: float = 2.0) -> list[Path]:
63+
import fitz
64+
out_dir.mkdir(parents=True, exist_ok=True)
65+
doc = fitz.open(pdf)
66+
paths = []
67+
try:
68+
for i, page in enumerate(doc, start=1):
69+
p = out_dir / f"p{i:03d}.png"
70+
page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)).save(str(p))
71+
paths.append(p)
72+
finally:
73+
doc.close()
74+
return paths
75+
76+
77+
def extract_pptx_slide_texts(prs) -> list[tuple[str, str]]:
78+
"""每張投影片 → (title, body) 文字, 供生圖 prompt 用。"""
79+
out = []
80+
for slide in prs.slides:
81+
lines = []
82+
for sh in slide.shapes:
83+
if sh.has_text_frame:
84+
t = sh.text_frame.text.strip()
85+
if t:
86+
lines.append(t)
87+
title = lines[0].split("\n")[0][:60] if lines else ""
88+
body = " ".join(lines)[:240]
89+
out.append((title, body))
90+
return out
91+
92+
93+
def insert_images_into_pptx(
94+
src_pptx: str | Path,
95+
out_pptx: str | Path,
96+
items: list[tuple[int, Path, tuple | list | None]],
97+
) -> int:
98+
"""在 src_pptx 上插圖後另存 out_pptx (原文字/圖形不動)。回傳插入張數。
99+
100+
items: list of (slide_index_0based, image_path, placement)。placement 為正規化
101+
(x,y,w,h)∈[0,1]; None → 右下角浮貼。圖等比 fit 進框 (置中, 不變形)。
102+
"""
103+
from pptx import Presentation
104+
from pptx.util import Emu
105+
106+
prs = Presentation(str(src_pptx))
107+
SW, SH = prs.slide_width, prs.slide_height
108+
slides = list(prs.slides)
109+
n = 0
110+
for idx, img, placement in items:
111+
if idx < 0 or idx >= len(slides):
112+
continue
113+
if not Path(img).exists():
114+
continue
115+
if placement:
116+
nx, ny, nw, nh = placement
117+
else:
118+
nx, ny, nw, nh = 0.68, 0.62, 0.30, 0.30
119+
inner = 0.06
120+
bx = (nx + nw * inner) * SW
121+
by = (ny + nh * inner) * SH
122+
bw = nw * (1 - 2 * inner) * SW
123+
bh = nh * (1 - 2 * inner) * SH
124+
# 用實際圖比例等比 fit (預設配圖近 1:1, 仍精確處理)
125+
try:
126+
from PIL import Image
127+
with Image.open(img) as im:
128+
iw, ih = im.size
129+
except Exception:
130+
iw, ih = 1, 1
131+
scale = min(bw / iw, bh / ih)
132+
w, h = iw * scale, ih * scale
133+
left = Emu(int(bx + (bw - w) / 2))
134+
top = Emu(int(by + (bh - h) / 2))
135+
slides[idx].shapes.add_picture(str(img), left, top, Emu(int(w)), Emu(int(h)))
136+
n += 1
137+
Path(out_pptx).parent.mkdir(parents=True, exist_ok=True)
138+
prs.save(str(out_pptx))
139+
return n
140+
141+
142+
def augment_pptx(
143+
src_pptx: str | Path,
144+
out_pptx: str | Path,
145+
*,
146+
work_dir: str | Path,
147+
only_missing: bool = True,
148+
mock: bool = False,
149+
api_key: str | None = None,
150+
max_images: int | None = None,
151+
page_pngs: list[Path] | None = None,
152+
) -> dict:
153+
"""在原始 .pptx 上為缺圖頁就地補圖, 另存 out_pptx。回傳 summary。
154+
155+
page_pngs 給定時跳過 LibreOffice 渲染 (測試 / caller 已有逐頁圖時用)。
156+
"""
157+
from pptx import Presentation
158+
from core import slide_image_gen as sig
159+
from core.diagram_image_gen import _build_diagram_prompt # noqa: F401 (確保模組可用)
160+
161+
src_pptx = Path(src_pptx)
162+
work_dir = Path(work_dir)
163+
work_dir.mkdir(parents=True, exist_ok=True)
164+
fig_dir = work_dir / "figures"
165+
fig_dir.mkdir(exist_ok=True)
166+
167+
# 1) 逐頁 PNG (供分析) + PDF (供缺圖偵測)
168+
if page_pngs is None:
169+
pdf = render_pptx_to_pdf(src_pptx, work_dir)
170+
page_pngs = _render_pdf_pages(pdf, work_dir / "pages")
171+
imageless = set(sig.detect_imageless_pages(pdf))
172+
else:
173+
page_pngs = [Path(p) for p in page_pngs]
174+
# 無 pdf 時退化: 只要 only_missing 仍想要偵測, 改用每頁 PNG 的內容判斷
175+
imageless = set(range(1, len(page_pngs) + 1))
176+
177+
# 2) 每頁文字 (生圖 prompt)
178+
prs = Presentation(str(src_pptx))
179+
texts = extract_pptx_slide_texts(prs)
180+
deck_title = src_pptx.stem
181+
182+
# 3) 逐頁: 缺圖才生圖 + 算空白框
183+
items: list[tuple[int, Path, tuple | None]] = []
184+
generated = 0
185+
for i, png in enumerate(page_pngs, start=1): # i = 1-based 頁碼
186+
if max_images is not None and generated >= max_images:
187+
break
188+
if only_missing and i not in imageless:
189+
continue
190+
title, body = texts[i - 1] if i - 1 < len(texts) else ("", "")
191+
slide = {"id": f"p{i:03d}", "title": title or f"投影片 {i}", "narration": body}
192+
ai_path = fig_dir / f"ai_p{i:03d}.png"
193+
ok, err = sig.generate_slide_image(
194+
slide, ai_path, deck_title=deck_title, api_key=api_key, mock=mock,
195+
)
196+
if not ok:
197+
logger.warning("PPTX 補圖跳過 p%d: %s", i, err)
198+
continue
199+
placement = sig.find_empty_region(png)
200+
items.append((i - 1, ai_path, placement)) # 0-based slide index
201+
generated += 1
202+
203+
# 4) 在原檔上插圖另存
204+
inserted = insert_images_into_pptx(src_pptx, out_pptx, items)
205+
summary = {
206+
"pages": len(page_pngs),
207+
"imageless": sorted(imageless) if imageless else [],
208+
"generated": generated,
209+
"inserted": inserted,
210+
"mock": mock,
211+
}
212+
logger.info("PPTX 就地補圖完成: %s", summary)
213+
return summary

server/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
from .routes import themes as themes_routes
4242
from .routes import uploads as uploads_routes
4343
from .routes import uploads_html as uploads_html_routes
44+
from .routes import uploads_pptx as uploads_pptx_routes
4445
from .routes import voices as voices_routes
4546
from .routes import youtube as youtube_routes
4647

@@ -149,6 +150,7 @@ def create_app() -> FastAPI:
149150
app.include_router(slides_routes.router)
150151
app.include_router(uploads_routes.router)
151152
app.include_router(uploads_html_routes.router) # HTML 動畫 → MP4 → 接既有上傳
153+
app.include_router(uploads_pptx_routes.router) # PPTX 原檔就地補圖 (文字可編輯)
152154
app.include_router(voices_routes.router)
153155
app.include_router(library_routes.router)
154156
app.include_router(editor_routes.router)

server/routes/uploads_pptx.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""POST /upload/pptx — 上傳 .pptx 原檔, 為缺圖頁就地補圖 (原文字可編輯)。
2+
3+
跟 slides_pdf 補圖的差別: slides_pdf 把每頁渲成圖, 原文字變不可編輯。這條走
4+
core.pptx_augment, 直接在原始 .pptx 上把 AI 配圖加進缺圖頁的空白區, 原本的文字
5+
方塊全部保留可編輯。產出的 <stem>_augmented.pptx 落在 job artifacts/ 供下載。
6+
7+
獨立背景 task (PENDING → RENDERING → DONE/FAILED), 不經 deck/ingest pipeline。
8+
需要 LibreOffice (pptx→pdf 分析) — 缺它則 job FAILED 並說明。
9+
"""
10+
from __future__ import annotations
11+
12+
import asyncio
13+
import json
14+
import logging
15+
from pathlib import Path
16+
17+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile, status
18+
19+
from ..jobs import JobStore, get_default_store
20+
from ..ratelimit import rate_limit
21+
from ..schemas import (
22+
CreateJobRequest, CreateJobResponse, JobOptions, JobSource, JobState, SourceType,
23+
)
24+
from .uploads import MAX_UPLOAD_SIZE, _sanitize_filename, _unique_target_path
25+
26+
logger = logging.getLogger(__name__)
27+
28+
router = APIRouter(prefix="/upload", tags=["uploads"])
29+
30+
_PPTX_EXTS = {".pptx"}
31+
32+
33+
async def _render_pptx_job(
34+
store: JobStore, job_id: str, *, src_pptx: Path, out_pptx: Path,
35+
only_missing: bool, mock: bool,
36+
) -> None:
37+
store.update(job_id, state=JobState.RENDERING)
38+
try:
39+
from core.pptx_augment import augment_pptx
40+
summary = await asyncio.to_thread(
41+
augment_pptx, src_pptx, out_pptx,
42+
work_dir=store.job_dir(job_id) / "work",
43+
only_missing=only_missing, mock=mock,
44+
)
45+
logger.info("PPTX 補圖 job %s: %s", job_id, summary)
46+
except Exception as e: # noqa: BLE001
47+
logger.exception("PPTX 補圖失敗 (job=%s)", job_id)
48+
store.update(job_id, state=JobState.FAILED, error=f"PPTX 補圖失敗: {e}")
49+
return
50+
store.refresh_artifacts(job_id)
51+
store.update(job_id, state=JobState.DONE)
52+
53+
54+
@router.post(
55+
"/pptx",
56+
response_model=CreateJobResponse,
57+
status_code=status.HTTP_201_CREATED,
58+
dependencies=[Depends(rate_limit)],
59+
)
60+
async def upload_pptx(
61+
request: Request,
62+
file: UploadFile = File(..., description=".pptx 原檔"),
63+
only_missing: bool = Form(True, description="True 只補偵測到的缺圖頁; False 每頁都生"),
64+
options_json: str = Form("{}", description="JobOptions JSON (目前只用 mock)"),
65+
project_id: str = Form("", description="可選: 歸屬的 Project"),
66+
store: JobStore = Depends(get_default_store),
67+
) -> CreateJobResponse:
68+
"""收 .pptx, 背景就地補圖, 產出可編輯的 <stem>_augmented.pptx 供下載。"""
69+
if not file.filename or Path(file.filename).suffix.lower() not in _PPTX_EXTS:
70+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "只接受 .pptx 檔")
71+
72+
content_length = request.headers.get("content-length")
73+
if content_length and content_length.isdigit() and int(content_length) > MAX_UPLOAD_SIZE:
74+
raise HTTPException(
75+
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
76+
f"檔案過大, 超過上限 {MAX_UPLOAD_SIZE // (1024 * 1024)} MB",
77+
)
78+
contents = await file.read()
79+
if not contents:
80+
raise HTTPException(status.HTTP_400_BAD_REQUEST, "上傳的 PPTX 是空的")
81+
if len(contents) > MAX_UPLOAD_SIZE:
82+
raise HTTPException(
83+
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
84+
f"檔案過大, 超過上限 {MAX_UPLOAD_SIZE // (1024 * 1024)} MB",
85+
)
86+
87+
try:
88+
opts = json.loads(options_json) if options_json else {}
89+
if not isinstance(opts, dict):
90+
raise ValueError("options_json 不是 JSON 物件")
91+
except Exception as e:
92+
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"options_json 解析失敗: {e}")
93+
options = JobOptions(mock=bool(opts.get("mock", False)), require_review=False)
94+
95+
rec = store.create(CreateJobRequest(
96+
source_type=SourceType.PPTX, source=JobSource(), options=options,
97+
))
98+
99+
# 原檔落地到 jobs/<id>/source/
100+
src_dir = store.job_dir(rec.id) / "source"
101+
src_dir.mkdir(parents=True, exist_ok=True)
102+
src_pptx = _unique_target_path(src_dir, _sanitize_filename(file.filename))
103+
src_pptx.write_bytes(contents)
104+
rec = store.update(rec.id, source=JobSource(path=str(src_pptx.resolve())))
105+
106+
if project_id:
107+
from core.project import ProjectNotFoundError
108+
from .projects import get_default_project_store
109+
try:
110+
get_default_project_store().add_job(project_id, rec.id)
111+
except ProjectNotFoundError as e:
112+
store.delete(rec.id)
113+
raise HTTPException(status.HTTP_404_NOT_FOUND, f"project 不存在: {project_id}") from e
114+
115+
stem = Path(_sanitize_filename(file.filename)).stem or "deck"
116+
out_pptx = store.artifacts_dir(rec.id) / f"{stem}_augmented.pptx"
117+
asyncio.create_task(_render_pptx_job(
118+
store, rec.id, src_pptx=src_pptx, out_pptx=out_pptx,
119+
only_missing=only_missing, mock=bool(options.mock),
120+
))
121+
122+
return CreateJobResponse(job_id=rec.id, state=rec.state, status_url=f"/jobs/{rec.id}")

server/schemas.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class SourceType(str, Enum):
4444
URL = "url" # 靜態 HTML 文章 -> url adapter
4545
SONG = "song" # 歌曲音檔 + 歌詞時間軸 -> AI 生圖 MV (M3, 第 4 track)
4646
HTML_ANIMATION = "html_animation" # HTML 動畫網頁 -> 逐 frame 截圖 -> MP4 (core.html_video)
47+
PPTX = "pptx" # PPTX 原檔 -> 缺圖頁就地補圖 (原文字可編輯) (core.pptx_augment)
4748

4849

4950
class JobState(str, Enum):

0 commit comments

Comments
 (0)