ppt-tool/backend/utils/export_utils.py
Vadym Samoilenko 58e738e79b Replace PPTX export pipeline: Puppeteer/python-pptx → PptxGenJS
- New POST /api/generate-pptx route (Next.js) uses PptxGenJS to build
  PPTX directly from the Phase 8 JSON element model — no headless Chrome
- export_utils.py queries DB for slides + layout codes, POSTs payload to
  Next.js, saves binary response to disk (removes python-pptx/Puppeteer)
- Coordinate conversion: px / 96 → inches (1280×720 = 13.333×7.5 in)
- CSS color/font-size parsing (hex, rgb/rgba, px→pt at 0.75pt/px)
- Fallback renderer for slides without a JSON layout schema
- PDF export (Puppeteer) unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 21:04:31 +00:00

127 lines
4.4 KiB
Python

import os
import uuid
from typing import Literal, Optional
import aiohttp
from fastapi import HTTPException
from pathvalidate import sanitize_filename
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from models.presentation_and_path import PresentationAndPath
from models.sql.presentation import PresentationModel
from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sql.slide import SlideModel
from services.database import async_session_maker
from utils.asset_directory_utils import get_exports_directory
async def export_presentation(
presentation_id: uuid.UUID,
title: str,
export_as: Literal["pptx", "pdf"],
client_id: Optional[uuid.UUID] = None,
session: Optional[AsyncSession] = None,
) -> PresentationAndPath:
next_url = os.environ.get("NEXT_INTERNAL_URL", "http://localhost:3000")
if export_as == "pptx":
# Query presentation, slides, and layout codes from the DB
async with async_session_maker() as db:
presentation = await db.get(PresentationModel, presentation_id)
slides_result = await db.scalars(
select(SlideModel)
.where(
SlideModel.presentation == presentation_id,
SlideModel.deleted_at.is_(None),
)
.order_by(SlideModel.index)
)
slides = list(slides_result.all())
# Fetch layout codes if the presentation uses a custom template
template_id_str = (
getattr(presentation, "template_name", None) if presentation else None
)
layouts = []
if template_id_str:
try:
template_uuid = uuid.UUID(template_id_str)
layouts_result = await db.scalars(
select(PresentationLayoutCodeModel).where(
PresentationLayoutCodeModel.presentation == template_uuid
)
)
layouts = list(layouts_result.all())
except (ValueError, TypeError):
pass
# Build JSON payload for the Next.js PptxGenJS endpoint
payload = {
"title": title,
"slides": [
{
"id": str(s.id),
"layout": s.layout,
"layout_group": s.layout_group,
"index": s.index,
"content": s.content or {},
"speaker_note": s.speaker_note,
}
for s in slides
],
"layouts": [
{
"layout_id": lay.layout_id,
"layout_name": lay.layout_name,
"layout_code": lay.layout_code,
}
for lay in layouts
],
}
export_directory = get_exports_directory()
pptx_path = os.path.join(
export_directory,
f"{sanitize_filename(title or str(uuid.uuid4()))}.pptx",
)
async with aiohttp.ClientSession() as http:
async with http.post(
f"{next_url}/api/generate-pptx",
json=payload,
) as response:
if response.status != 200:
error_text = await response.text()
print(f"[export_utils] generate-pptx failed ({response.status}): {error_text}")
raise HTTPException(
status_code=500,
detail="Failed to generate PPTX",
)
pptx_bytes = await response.read()
os.makedirs(export_directory, exist_ok=True)
with open(pptx_path, "wb") as f:
f.write(pptx_bytes)
return PresentationAndPath(
presentation_id=presentation_id,
path=pptx_path,
)
else:
async with aiohttp.ClientSession() as http:
async with http.post(
f"{next_url}/api/export-as-pdf",
json={
"id": str(presentation_id),
"title": sanitize_filename(title or str(uuid.uuid4())),
},
) as response:
response_json = await response.json()
return PresentationAndPath(
presentation_id=presentation_id,
path=response_json["path"],
)