refactor: cleans old unused export files from both docker and electron and uses package for export

This commit is contained in:
sauravniraula 2026-04-24 10:12:23 +05:45
parent 9272907a30
commit 11904c6cb0
No known key found for this signature in database
GPG key ID: 60FCC1B5A5E83326
49 changed files with 874 additions and 9669 deletions

View file

@ -31,8 +31,7 @@ FROM node:20-bookworm-slim AS nextjs-builder
WORKDIR /app/servers/nextjs WORKDIR /app/servers/nextjs
ENV NEXT_TELEMETRY_DISABLED=1 \ ENV NEXT_TELEMETRY_DISABLED=1
PUPPETEER_SKIP_DOWNLOAD=true
COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./ COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \ RUN --mount=type=cache,target=/root/.npm \
@ -71,14 +70,12 @@ FROM python:3.11-slim-trixie AS runtime
WORKDIR /app WORKDIR /app
ARG INSTALL_CHROMIUM=true
ARG INSTALL_TESSERACT=true ARG INSTALL_TESSERACT=true
ARG INSTALL_LIBREOFFICE=true ARG INSTALL_LIBREOFFICE=true
# LiteParse uses Node + @llamaindex/liteparse (same runner as Electron); OCR uses Tesseract. # LiteParse uses Node + @llamaindex/liteparse (same runner as Electron); OCR uses Tesseract.
ENV APP_DATA_DIRECTORY=/app_data \ ENV APP_DATA_DIRECTORY=/app_data \
TEMP_DIRECTORY=/tmp/presenton \ TEMP_DIRECTORY=/tmp/presenton \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
EXPORT_PACKAGE_ROOT=/app/presentation-export \ EXPORT_PACKAGE_ROOT=/app/presentation-export \
EXPORT_RUNTIME_DIR=/app/presentation-export \ EXPORT_RUNTIME_DIR=/app/presentation-export \
BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \ BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \
@ -90,7 +87,6 @@ ENV APP_DATA_DIRECTORY=/app_data \
RUN set -eux; \ RUN set -eux; \
packages="ca-certificates curl nginx fontconfig imagemagick zstd"; \ packages="ca-certificates curl nginx fontconfig imagemagick zstd"; \
if [ "$INSTALL_LIBREOFFICE" = "true" ]; then packages="$packages libreoffice"; fi; \ if [ "$INSTALL_LIBREOFFICE" = "true" ]; then packages="$packages libreoffice"; fi; \
if [ "$INSTALL_CHROMIUM" = "true" ]; then packages="$packages chromium"; fi; \
if [ "$INSTALL_TESSERACT" = "true" ]; then packages="$packages tesseract-ocr tesseract-ocr-eng"; fi; \ if [ "$INSTALL_TESSERACT" = "true" ]; then packages="$packages tesseract-ocr tesseract-ocr-eng"; fi; \
apt-get update; \ apt-get update; \
apt-get install -y --no-install-recommends $packages; \ apt-get install -y --no-install-recommends $packages; \

View file

@ -20,7 +20,7 @@ export function setupExportHandlers() {
return { success }; return { success };
}); });
ipcMain.handle("export-presentation", async (_, id: string, title: string, exportAs: "pptx" | "pdf" | "png") => { ipcMain.handle("export-presentation", async (_, id: string, title: string, exportAs: "pptx" | "pdf") => {
try { try {
const params = new URLSearchParams({ id }); const params = new URLSearchParams({ id });
if (process.env.NEXT_PUBLIC_FAST_API) { if (process.env.NEXT_PUBLIC_FAST_API) {
@ -258,4 +258,4 @@ async function moveFile(sourcePath: string, destinationPath: string) {
await fs.promises.copyFile(sourcePath, destinationPath); await fs.promises.copyFile(sourcePath, destinationPath);
await fs.promises.unlink(sourcePath); await fs.promises.unlink(sourcePath);
} }
} }

View file

@ -4,20 +4,18 @@ import { setupSlideMetadataHandlers } from "./slide_metadata";
import { setupReadFile } from "./read_file"; import { setupReadFile } from "./read_file";
import { setupFooterHandlers } from "./footer_handlers"; import { setupFooterHandlers } from "./footer_handlers";
import { setupThemeHandlers } from "./theme_handlers"; import { setupThemeHandlers } from "./theme_handlers";
import { setupUploadImage } from "./upload_image"; import { setupUploadImage } from "./upload_image";
import { setupLogHandler } from "./log_handler"; import { setupLogHandler } from "./log_handler";
import { setupApiHandlers } from "./api_handlers"; import { setupApiHandlers } from "./api_handlers";
import { setupPresentationToPptxModelHandlers } from "./presentation_to_pptx_model_handlers";
export function setupIpcHandlers() {
export function setupIpcHandlers() {
setupExportHandlers(); setupExportHandlers();
setupUserConfigHandlers(); setupUserConfigHandlers();
setupSlideMetadataHandlers(); setupSlideMetadataHandlers();
setupReadFile(); setupReadFile();
setupFooterHandlers(); setupFooterHandlers();
setupThemeHandlers(); setupThemeHandlers();
setupUploadImage(); setupUploadImage();
setupLogHandler(); setupLogHandler();
setupApiHandlers(); setupApiHandlers();
setupPresentationToPptxModelHandlers(); }
}

File diff suppressed because it is too large Load diff

View file

@ -9,11 +9,10 @@ contextBridge.exposeInMainWorld('env', {
}); });
contextBridge.exposeInMainWorld('electron', { contextBridge.exposeInMainWorld('electron', {
fileDownloaded: (filePath: string) => ipcRenderer.invoke("file-downloaded", filePath), fileDownloaded: (filePath: string) => ipcRenderer.invoke("file-downloaded", filePath),
exportAsPDF: (id: string, title: string) => ipcRenderer.invoke("export-as-pdf", id, title), exportPresentation: (id: string, title: string, format: "pptx" | "pdf") =>
exportPresentation: (id: string, title: string, format: "pptx" | "pdf" | "png") => ipcRenderer.invoke("export-presentation", id, title, format),
ipcRenderer.invoke("export-presentation", id, title, format),
getUserConfig: () => ipcRenderer.invoke("get-user-config"), getUserConfig: () => ipcRenderer.invoke("get-user-config"),
setUserConfig: (userConfig: UserConfig) => ipcRenderer.invoke("set-user-config", userConfig), setUserConfig: (userConfig: UserConfig) => ipcRenderer.invoke("set-user-config", userConfig),
getCanChangeKeys: () => ipcRenderer.invoke("get-can-change-keys"), getCanChangeKeys: () => ipcRenderer.invoke("get-can-change-keys"),
@ -28,11 +27,10 @@ contextBridge.exposeInMainWorld('electron', {
writeNextjsLog: (logData: string) => ipcRenderer.invoke("write-nextjs-log", logData), writeNextjsLog: (logData: string) => ipcRenderer.invoke("write-nextjs-log", logData),
clearNextjsLogs: () => ipcRenderer.invoke("clear-nextjs-logs"), clearNextjsLogs: () => ipcRenderer.invoke("clear-nextjs-logs"),
// API handlers // API handlers
hasRequiredKey: () => ipcRenderer.invoke("api:has-required-key"), hasRequiredKey: () => ipcRenderer.invoke("api:has-required-key"),
telemetryStatus: () => ipcRenderer.invoke("api:telemetry-status"), telemetryStatus: () => ipcRenderer.invoke("api:telemetry-status"),
getTemplates: () => ipcRenderer.invoke("api:templates"), getTemplates: () => ipcRenderer.invoke("api:templates"),
getPresentationPptxModel: (presentationId: string) => ipcRenderer.invoke("presentation-to-pptx-model", presentationId), onStartupStatus: (callback: (payload: { name: string; status: string }) => void) =>
onStartupStatus: (callback: (payload: { name: string; status: string }) => void) => ipcRenderer.on("startup:status", (_event, payload) => callback(payload)),
ipcRenderer.on("startup:status", (_event, payload) => callback(payload)), getStartupStatus: () => ipcRenderer.invoke("startup:get-status"),
getStartupStatus: () => ipcRenderer.invoke("startup:get-status"), });
});

View file

@ -23,7 +23,6 @@ from models.presentation_outline_model import (
) )
from enums.tone import Tone from enums.tone import Tone
from enums.verbosity import Verbosity from enums.verbosity import Verbosity
from models.pptx_models import PptxPresentationModel
from models.presentation_structure_model import PresentationStructureModel from models.presentation_structure_model import PresentationStructureModel
from models.presentation_with_slides import ( from models.presentation_with_slides import (
PresentationWithSlides, PresentationWithSlides,
@ -40,14 +39,12 @@ from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
from services.database import get_async_session from services.database import get_async_session
from services.temp_file_service import TEMP_FILE_SERVICE
from services.concurrent_service import CONCURRENT_SERVICE from services.concurrent_service import CONCURRENT_SERVICE
from models.sql.presentation import PresentationModel from models.sql.presentation import PresentationModel
from services.pptx_presentation_creator import PptxPresentationCreator
from models.sql.async_presentation_generation_status import ( from models.sql.async_presentation_generation_status import (
AsyncPresentationGenerationTaskModel, AsyncPresentationGenerationTaskModel,
) )
from utils.asset_directory_utils import get_exports_directory, get_images_directory from utils.asset_directory_utils import get_images_directory
from utils.llm_calls.generate_presentation_structure import ( from utils.llm_calls.generate_presentation_structure import (
generate_presentation_structure, generate_presentation_structure,
) )
@ -489,56 +486,6 @@ async def update_presentation(
slides=response_slides, slides=response_slides,
fonts=fonts, fonts=fonts,
) )
@PRESENTATION_ROUTER.post("/export/pptx", response_model=str)
async def export_presentation_as_pptx(
pptx_model: Annotated[PptxPresentationModel, Body()],
):
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
await pptx_creator.create_ppt()
export_directory = get_exports_directory()
pptx_path = os.path.join(
export_directory, f"{pptx_model.name or uuid.uuid4()}.pptx"
)
pptx_creator.save(pptx_path)
return pptx_path
@PRESENTATION_ROUTER.post("/export", response_model=PresentationPathAndEditPath)
async def export_presentation_as_pptx_or_pdf(
id: Annotated[uuid.UUID, Body(description="Presentation ID to export")],
export_as: Annotated[
Literal["pptx", "pdf"], Body(description="Format to export the presentation as")
] = "pptx",
sql_session: AsyncSession = Depends(get_async_session),
):
"""
Export a presentation as PPTX or PDF.
This Api is used to export via the nextjs app i.e using the puppeteer to export the presentation.
"""
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_and_path = await export_presentation(
id,
presentation.title or str(uuid.uuid4()),
export_as,
)
return PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={id}",
)
async def check_if_api_request_is_valid( async def check_if_api_request_is_valid(
request: GeneratePresentationRequest, request: GeneratePresentationRequest,
sql_session: AsyncSession = Depends(get_async_session), sql_session: AsyncSession = Depends(get_async_session),

View file

@ -1,198 +0,0 @@
from enum import Enum
from typing import Annotated, List, Literal, Optional, Union
from annotated_types import Len
from pydantic import BaseModel, Discriminator, field_validator
from pptx.util import Pt
from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR_TYPE
class PptxBoxShapeEnum(Enum):
RECTANGLE = "rectangle"
CIRCLE = "circle"
class PptxObjectFitEnum(Enum):
CONTAIN = "contain"
COVER = "cover"
FILL = "fill"
class PptxSpacingModel(BaseModel):
top: int = 0
bottom: int = 0
left: int = 0
right: int = 0
@classmethod
def all(cls, num: int):
return PptxSpacingModel(top=num, left=num, bottom=num, right=num)
class PptxPositionModel(BaseModel):
left: int = 0
top: int = 0
width: int = 0
height: int = 0
@classmethod
def for_textbox(cls, left: int, top: int, width: int):
return cls(left=left, top=top, width=width, height=100)
def to_pt_list(self) -> List[int]:
return [Pt(self.left), Pt(self.top), Pt(self.width), Pt(self.height)]
def to_pt_xyxy(self) -> List[int]:
return [
Pt(self.left),
Pt(self.top),
Pt(self.left + self.width),
Pt(self.top + self.height),
]
class PptxFontModel(BaseModel):
name: str = "Inter"
size: int = 16
italic: bool = False
color: str = "000000"
font_weight: Optional[int] = 400
underline: Optional[bool] = None
strike: Optional[bool] = None
class PptxFillModel(BaseModel):
color: str
opacity: float = 1.0
class PptxStrokeModel(BaseModel):
color: str
thickness: float
opacity: float = 1.0
class PptxShadowModel(BaseModel):
radius: int
offset: int = 0
color: str = "000000"
opacity: float = 0.5
angle: int = 0
class PptxTextRunModel(BaseModel):
text: str
font: Optional[PptxFontModel] = None
class PptxParagraphModel(BaseModel):
spacing: Optional[PptxSpacingModel] = None
alignment: Optional[PP_ALIGN] = None
font: Optional[PptxFontModel] = None
line_height: Optional[float] = None
text: Optional[str] = None
text_runs: Optional[List[PptxTextRunModel]] = None
class PptxObjectFitModel(BaseModel):
fit: Optional[PptxObjectFitEnum] = None
focus: Optional[
Annotated[List[Optional[float]], Len(min_length=2, max_length=2)]
] = None
class PptxPictureModel(BaseModel):
is_network: bool
path: str
class PptxShapeModel(BaseModel):
shape_type: Literal["textbox", "autoshape", "picture", "connector"]
class PptxTextBoxModel(PptxShapeModel):
shape_type: Literal["textbox"] = "textbox"
margin: Optional[PptxSpacingModel] = None
fill: Optional[PptxFillModel] = None
position: PptxPositionModel
text_wrap: bool = True
paragraphs: List[PptxParagraphModel]
class PptxAutoShapeBoxModel(PptxShapeModel):
shape_type: Literal["autoshape"] = "autoshape"
type: MSO_AUTO_SHAPE_TYPE = MSO_AUTO_SHAPE_TYPE.RECTANGLE
margin: Optional[PptxSpacingModel] = None
fill: Optional[PptxFillModel] = None
stroke: Optional[PptxStrokeModel] = None
shadow: Optional[PptxShadowModel] = None
position: PptxPositionModel
text_wrap: bool = True
border_radius: Optional[int] = None
paragraphs: Optional[List[PptxParagraphModel]] = None
@field_validator('border_radius', mode='before')
@classmethod
def convert_border_radius_to_int(cls, v):
"""Convert float border_radius values to int."""
if v is None:
return None
if isinstance(v, float):
return int(round(v))
return v
class PptxPictureBoxModel(PptxShapeModel):
shape_type: Literal["picture"] = "picture"
position: PptxPositionModel
margin: Optional[PptxSpacingModel] = None
clip: bool = True
opacity: Optional[float] = None
invert: bool = False
border_radius: Optional[List[int]] = None
shape: Optional[PptxBoxShapeEnum] = None
object_fit: Optional[PptxObjectFitModel] = None
picture: PptxPictureModel
@field_validator('border_radius', mode='before')
@classmethod
def convert_border_radius_list_to_int(cls, v):
"""Convert float values in border_radius list to int."""
if v is None:
return None
if isinstance(v, list):
return [int(round(item)) if isinstance(item, float) else int(item) for item in v]
return v
class PptxConnectorModel(PptxShapeModel):
shape_type: Literal["connector"] = "connector"
type: MSO_CONNECTOR_TYPE = MSO_CONNECTOR_TYPE.STRAIGHT
position: PptxPositionModel
thickness: float = 0.5
color: str = "000000"
opacity: float = 1.0
# Define a discriminated union for shapes
PptxShapeUnion = Annotated[
Union[
PptxTextBoxModel,
PptxAutoShapeBoxModel,
PptxConnectorModel,
PptxPictureBoxModel,
],
Discriminator("shape_type"),
]
class PptxSlideModel(BaseModel):
background: Optional[PptxFillModel] = None
note: Optional[str] = None
shapes: List[PptxShapeUnion]
class PptxPresentationModel(BaseModel):
name: Optional[str] = None
shapes: Optional[List[PptxShapeModel]] = None
slides: List[PptxSlideModel]

View file

@ -4,7 +4,7 @@ import os
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
from typing import Mapping from typing import Literal, Mapping
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import BaseModel from pydantic import BaseModel
@ -27,6 +27,10 @@ class PptxToHtmlDocument(BaseModel):
fonts_dir: str fonts_dir: str
class PresentationExportTaskResult(BaseModel):
path: str
class ExportTaskService: class ExportTaskService:
def __init__(self, timeout_seconds: int = 300): def __init__(self, timeout_seconds: int = 300):
self.timeout_seconds = timeout_seconds self.timeout_seconds = timeout_seconds
@ -154,29 +158,24 @@ class ExportTaskService:
detail="PPTX-to-HTML task completed without a valid output path", detail="PPTX-to-HTML task completed without a valid output path",
) )
async def convert_pptx_to_html( @staticmethod
self, pptx_path: str, get_fonts: bool = False def _create_task_paths() -> tuple[str, str, str]:
) -> PptxToHtmlDocument: temp_root = get_temp_directory_env() or os.path.join(
self._ensure_runtime_ready() tempfile.gettempdir(), "presenton"
if not os.path.isfile(pptx_path): )
raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}")
temp_root = get_temp_directory_env() or os.path.join(tempfile.gettempdir(), "presenton")
os.makedirs(temp_root, exist_ok=True) os.makedirs(temp_root, exist_ok=True)
temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root) temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root)
task_path = os.path.join(temp_dir, "export_task.json") task_path = os.path.join(temp_dir, "export_task.json")
response_path = os.path.join(temp_dir, "export_task.response.json") response_path = os.path.join(temp_dir, "export_task.response.json")
return temp_dir, task_path, response_path
async def _run_task(self, task_payload: dict, response_error_detail: str) -> dict:
self._ensure_runtime_ready()
temp_dir, task_path, response_path = self._create_task_paths()
try: try:
with open(task_path, "w", encoding="utf-8") as task_file: with open(task_path, "w", encoding="utf-8") as task_file:
json.dump( json.dump(task_payload, task_file)
{
"type": "pptx-to-html",
"pptx_path": pptx_path,
"get_fonts": get_fonts,
},
task_file,
)
result = await asyncio.to_thread( result = await asyncio.to_thread(
subprocess.run, subprocess.run,
@ -192,7 +191,7 @@ class ExportTaskService:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=( detail=(
"PPTX-to-HTML export task failed. " "Export task failed. "
f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}" f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}"
), ),
) )
@ -200,34 +199,77 @@ class ExportTaskService:
if not os.path.isfile(response_path): if not os.path.isfile(response_path):
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="PPTX-to-HTML export task did not produce a response file", detail=response_error_detail,
) )
with open(response_path, "r", encoding="utf-8") as response_file: with open(response_path, "r", encoding="utf-8") as response_file:
response_data = json.load(response_file) return json.load(response_file)
except subprocess.TimeoutExpired as exc:
raise HTTPException(
status_code=500,
detail=f"Export task timed out after {self.timeout_seconds} seconds",
) from exc
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=500,
detail="Export task produced invalid JSON output",
) from exc
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to run export task: {exc}",
) from exc
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
async def export_from_url(
self,
url: str,
title: str,
export_as: Literal["pdf", "pptx"],
fastapi_url: str | None = None,
) -> PresentationExportTaskResult:
response_data = await self._run_task(
{
"type": "export",
"url": url,
"format": export_as,
"title": title,
"fastapiUrl": fastapi_url or None,
},
"Export task did not produce a response file",
)
return PresentationExportTaskResult(
path=self._resolve_output_path(response_data),
)
async def convert_pptx_to_html(
self, pptx_path: str, get_fonts: bool = False
) -> PptxToHtmlDocument:
if not os.path.isfile(pptx_path):
raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}")
try:
response_data = await self._run_task(
{
"type": "pptx-to-html",
"pptx_path": pptx_path,
"get_fonts": get_fonts,
},
"PPTX-to-HTML export task did not produce a response file",
)
output_path = self._resolve_output_path(response_data) output_path = self._resolve_output_path(response_data)
with open(output_path, "r", encoding="utf-8") as output_file: with open(output_path, "r", encoding="utf-8") as output_file:
output_data = json.load(output_file) output_data = json.load(output_file)
return PptxToHtmlDocument(**output_data) return PptxToHtmlDocument(**output_data)
except subprocess.TimeoutExpired as exc:
raise HTTPException(
status_code=500,
detail=f"PPTX-to-HTML export timed out after {self.timeout_seconds} seconds",
) from exc
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="PPTX-to-HTML export produced invalid JSON output", detail="PPTX-to-HTML export produced invalid JSON output",
) from exc ) from exc
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to run PPTX-to-HTML export task: {exc}",
) from exc
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def sys_platform() -> str: def sys_platform() -> str:

View file

@ -1,65 +0,0 @@
from html.parser import HTMLParser
from typing import List, Optional
from models.pptx_models import PptxFontModel, PptxTextRunModel
class InlineHTMLToRunsParser(HTMLParser):
def __init__(self, base_font: PptxFontModel):
super().__init__(convert_charrefs=True)
self.base_font = base_font
self.tag_stack: List[str] = []
self.text_runs: List[PptxTextRunModel] = []
def _current_font(self) -> PptxFontModel:
font_json = self.base_font.model_dump()
is_bold = any(tag in ("strong", "b") for tag in self.tag_stack)
is_italic = any(tag in ("em", "i") for tag in self.tag_stack)
is_underline = any(tag == "u" for tag in self.tag_stack)
is_strike = any(tag in ("s", "strike", "del") for tag in self.tag_stack)
is_code = any(tag == "code" for tag in self.tag_stack)
if is_bold:
font_json["font_weight"] = 700
if is_italic:
font_json["italic"] = True
if is_underline:
font_json["underline"] = True
if is_strike:
font_json["strike"] = True
if is_code:
font_json["name"] = "Courier New"
return PptxFontModel(**font_json)
def handle_starttag(self, tag, attrs):
tag = tag.lower()
if tag == "br":
self.text_runs.append(PptxTextRunModel(text="\n"))
return
self.tag_stack.append(tag)
def handle_endtag(self, tag):
tag = tag.lower()
for i in range(len(self.tag_stack) - 1, -1, -1):
if self.tag_stack[i] == tag:
del self.tag_stack[i]
break
def handle_data(self, data):
if data == "":
return
self.text_runs.append(PptxTextRunModel(text=data, font=self._current_font()))
def parse_html_text_to_text_runs(
text: str, base_font: Optional[PptxFontModel] = None
) -> List[PptxTextRunModel]:
normalized_text = text.replace("\r\n", "\n").replace("\r", "\n")
normalized_text = normalized_text.replace("\n", "<br>")
parser = InlineHTMLToRunsParser(base_font if base_font else PptxFontModel())
parser.feed(normalized_text)
return parser.text_runs

View file

@ -1,632 +0,0 @@
import os
from typing import List, Optional
from lxml import etree
from services.html_to_text_runs_service import (
parse_html_text_to_text_runs as parse_inline_html_to_runs,
)
import tempfile
import zipfile
from pptx import Presentation
from pptx.shapes.autoshape import Shape
from pptx.slide import Slide
from pptx.text.text import _Paragraph, TextFrame, Font, _Run
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
from lxml.etree import fromstring, tostring
from PIL import Image
from pptx.oxml.xmlchemy import OxmlElement
from pptx.util import Pt
from pptx.dml.color import RGBColor
from models.pptx_models import (
PptxAutoShapeBoxModel,
PptxBoxShapeEnum,
PptxConnectorModel,
PptxFillModel,
PptxFontModel,
PptxParagraphModel,
PptxPictureBoxModel,
PptxPositionModel,
PptxPresentationModel,
PptxShadowModel,
PptxSlideModel,
PptxSpacingModel,
PptxStrokeModel,
PptxTextBoxModel,
PptxTextRunModel,
)
from utils.asset_directory_utils import get_images_directory, resolve_image_path_to_filesystem
from utils.download_helpers import download_files
from utils.get_env import get_app_data_directory_env
from utils.image_utils import (
clip_image,
create_circle_image,
fit_image,
invert_image,
round_image_corners,
set_image_opacity,
)
import uuid
BLANK_SLIDE_LAYOUT = 6
class PptxPresentationCreator:
def __init__(self, ppt_model: PptxPresentationModel, temp_dir: str):
self._temp_dir = temp_dir
self._ppt_model = ppt_model
self._slide_models = ppt_model.slides
self._ppt = Presentation()
self._ppt.slide_width = Pt(1280)
self._ppt.slide_height = Pt(720)
def get_sub_element(self, parent, tagname, **kwargs):
"""Helper method to create XML elements"""
element = OxmlElement(tagname)
element.attrib.update(kwargs)
parent.append(element)
return element
def fix_keynote_compatibility(self, pptx_path: str):
"""Patch pptx XML for stricter parsers like Keynote."""
PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
NOTES_MASTER_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster"
)
def ensure_grp_sppr_xfrm(slide_path: str):
slide_tree = etree.parse(slide_path)
slide_root = slide_tree.getroot()
grp_sppr_elements = slide_root.findall(
f".//{{{PRESENTATION_NS}}}grpSpPr"
)
changed = False
for grp_sppr in grp_sppr_elements:
xfrm = grp_sppr.find(f"{{{DRAWING_NS}}}xfrm")
if xfrm is None:
xfrm = etree.SubElement(grp_sppr, f"{{{DRAWING_NS}}}xfrm")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}off", x="0", y="0")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}ext", cx="0", cy="0")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chOff", x="0", y="0")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chExt", cx="0", cy="0")
changed = True
if changed:
slide_tree.write(
slide_path,
xml_declaration=True,
encoding="UTF-8",
standalone="yes",
)
with tempfile.TemporaryDirectory() as temp_dir:
extract_dir = os.path.join(temp_dir, "pptx_contents")
os.makedirs(extract_dir, exist_ok=True)
with zipfile.ZipFile(pptx_path, "r") as existing_zip:
existing_zip.extractall(extract_dir)
ppt_dir = os.path.join(extract_dir, "ppt")
slides_dir = os.path.join(ppt_dir, "slides")
if os.path.isdir(slides_dir):
for file_name in os.listdir(slides_dir):
if file_name.endswith(".xml"):
ensure_grp_sppr_xfrm(os.path.join(slides_dir, file_name))
rels_path = os.path.join(ppt_dir, "_rels", "presentation.xml.rels")
presentation_path = os.path.join(ppt_dir, "presentation.xml")
if os.path.exists(rels_path) and os.path.exists(presentation_path):
rels_tree = etree.parse(rels_path)
rels_root = rels_tree.getroot()
rel_tag = f"{{{PACKAGE_REL_NS}}}Relationship"
notes_master_rel = None
existing_ids = set()
for rel in rels_root.findall(rel_tag):
rel_id = rel.get("Id")
if rel_id:
existing_ids.add(rel_id)
if rel.get("Type") == NOTES_MASTER_REL_TYPE:
notes_master_rel = rel
notes_masters_dir = os.path.join(ppt_dir, "notesMasters")
has_notes_master = (
os.path.isdir(notes_masters_dir)
and any(
name.endswith(".xml") for name in os.listdir(notes_masters_dir)
)
)
if has_notes_master and notes_master_rel is None:
next_id = 1
while f"rId{next_id}" in existing_ids:
next_id += 1
notes_master_rel = etree.SubElement(rels_root, rel_tag)
notes_master_rel.set("Id", f"rId{next_id}")
notes_master_rel.set("Type", NOTES_MASTER_REL_TYPE)
notes_master_rel.set(
"Target", "notesMasters/notesMaster1.xml"
)
rels_tree.write(
rels_path,
xml_declaration=True,
encoding="UTF-8",
standalone="yes",
)
if has_notes_master and notes_master_rel is not None:
presentation_tree = etree.parse(presentation_path)
presentation_root = presentation_tree.getroot()
notes_master_id_lst = presentation_root.find(
f"{{{PRESENTATION_NS}}}notesMasterIdLst"
)
if notes_master_id_lst is None:
notes_master_id_lst = etree.Element(
f"{{{PRESENTATION_NS}}}notesMasterIdLst"
)
sld_master_id_lst = presentation_root.find(
f"{{{PRESENTATION_NS}}}sldMasterIdLst"
)
if sld_master_id_lst is not None:
insert_index = list(presentation_root).index(
sld_master_id_lst
) + 1
presentation_root.insert(insert_index, notes_master_id_lst)
else:
presentation_root.insert(0, notes_master_id_lst)
if not notes_master_id_lst.findall(
f"{{{PRESENTATION_NS}}}notesMasterId"
):
notes_master_id = etree.SubElement(
notes_master_id_lst,
f"{{{PRESENTATION_NS}}}notesMasterId",
)
notes_master_id.set(
f"{{{REL_NS}}}id",
notes_master_rel.get("Id"),
)
presentation_tree.write(
presentation_path,
xml_declaration=True,
encoding="UTF-8",
standalone="yes",
)
with zipfile.ZipFile(pptx_path, "w", zipfile.ZIP_DEFLATED) as new_zip:
for root, _, files in os.walk(extract_dir):
for file_name in files:
full_path = os.path.join(root, file_name)
archive_name = os.path.relpath(full_path, extract_dir)
new_zip.write(full_path, archive_name)
async def fetch_network_assets(self):
image_urls = []
models_with_network_asset: List[PptxPictureBoxModel] = []
def _process_image_path(each_shape, image_path):
if not image_path.startswith("http"):
return
if "app_data/" in image_path:
relative_path = image_path.split("app_data/")[1]
app_data_dir = get_app_data_directory_env()
if app_data_dir:
each_shape.picture.path = os.path.join(app_data_dir, relative_path)
else:
each_shape.picture.path = os.path.join("/app_data", relative_path)
each_shape.picture.is_network = False
return
# Resolve HTTP URLs that contain absolute filesystem paths (Mac/Electron)
local_path = resolve_image_path_to_filesystem(image_path)
if local_path:
each_shape.picture.path = local_path
each_shape.picture.is_network = False
return
image_urls.append(image_path)
models_with_network_asset.append(each_shape)
if self._ppt_model.shapes:
for each_shape in self._ppt_model.shapes:
if isinstance(each_shape, PptxPictureBoxModel):
_process_image_path(each_shape, each_shape.picture.path)
for each_slide in self._slide_models:
for each_shape in each_slide.shapes:
if isinstance(each_shape, PptxPictureBoxModel):
_process_image_path(each_shape, each_shape.picture.path)
if image_urls:
image_paths = await download_files(image_urls, self._temp_dir)
for each_shape, each_image_path in zip(
models_with_network_asset, image_paths
):
if each_image_path:
each_shape.picture.path = each_image_path
each_shape.picture.is_network = False
async def create_ppt(self):
await self.fetch_network_assets()
for slide_model in self._slide_models:
# Adding global shapes to slide
if self._ppt_model.shapes:
slide_model.shapes.append(self._ppt_model.shapes)
self.add_and_populate_slide(slide_model)
def set_presentation_theme(self):
slide_master = self._ppt.slide_master
slide_master_part = slide_master.part
theme_part = slide_master_part.part_related_by(RT.THEME)
theme = fromstring(theme_part.blob)
theme_colors = self._theme.colors.theme_color_mapping
nsmap = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
for color_name, hex_value in theme_colors.items():
if color_name:
color_element = theme.xpath(
f"a:themeElements/a:clrScheme/a:{color_name}/a:srgbClr",
namespaces=nsmap,
)[0]
color_element.set("val", hex_value.encode("utf-8"))
theme_part._blob = tostring(theme)
def add_and_populate_slide(self, slide_model: PptxSlideModel):
slide = self._ppt.slides.add_slide(self._ppt.slide_layouts[BLANK_SLIDE_LAYOUT])
if slide_model.background:
self.apply_fill_to_shape(slide.background, slide_model.background)
if slide_model.note:
slide.notes_slide.notes_text_frame.text = slide_model.note
for shape_model in slide_model.shapes:
model_type = type(shape_model)
if model_type is PptxPictureBoxModel:
self.add_picture(slide, shape_model)
elif model_type is PptxAutoShapeBoxModel:
self.add_autoshape(slide, shape_model)
elif model_type is PptxTextBoxModel:
self.add_textbox(slide, shape_model)
elif model_type is PptxConnectorModel:
self.add_connector(slide, shape_model)
def add_connector(self, slide: Slide, connector_model: PptxConnectorModel):
if connector_model.thickness == 0:
return
connector_shape = slide.shapes.add_connector(
connector_model.type, *connector_model.position.to_pt_xyxy()
)
connector_shape.line.width = Pt(connector_model.thickness)
connector_shape.line.color.rgb = RGBColor.from_string(connector_model.color)
self.set_fill_opacity(connector_shape, connector_model.opacity)
def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel):
image_path = picture_model.picture.path
# Resolve /app_data/... to actual filesystem path (Electron)
if image_path.startswith("/app_data/"):
app_data_dir = get_app_data_directory_env()
if app_data_dir:
relative = image_path[len("/app_data/"):]
image_path = os.path.join(app_data_dir, relative)
if (
picture_model.clip
or picture_model.border_radius
or picture_model.invert
or picture_model.opacity
or picture_model.object_fit
or picture_model.shape
):
try:
image = Image.open(image_path)
except Exception:
print(f"Could not open image: {image_path}")
return
image = image.convert("RGBA")
# ? Applying border radius twice to support both clip and object fit
if picture_model.border_radius:
image = round_image_corners(image, picture_model.border_radius)
if picture_model.object_fit:
image = fit_image(
image,
picture_model.position.width,
picture_model.position.height,
picture_model.object_fit,
)
elif picture_model.clip:
image = clip_image(
image,
picture_model.position.width,
picture_model.position.height,
)
if picture_model.border_radius:
image = round_image_corners(image, picture_model.border_radius)
if picture_model.shape == PptxBoxShapeEnum.CIRCLE:
image = create_circle_image(image)
if picture_model.invert:
image = invert_image(image)
if picture_model.opacity:
image = set_image_opacity(image, picture_model.opacity)
image_path = os.path.join(self._temp_dir, f"{uuid.uuid4()}.png")
image.save(image_path)
margined_position = self.get_margined_position(
picture_model.position, picture_model.margin
)
slide.shapes.add_picture(image_path, *margined_position.to_pt_list())
def add_autoshape(self, slide: Slide, autoshape_box_model: PptxAutoShapeBoxModel):
position = autoshape_box_model.position
if autoshape_box_model.margin:
position = self.get_margined_position(position, autoshape_box_model.margin)
autoshape = slide.shapes.add_shape(
autoshape_box_model.type, *position.to_pt_list()
)
textbox = autoshape.text_frame
textbox.word_wrap = autoshape_box_model.text_wrap
self.apply_fill_to_shape(autoshape, autoshape_box_model.fill)
self.apply_margin_to_text_box(textbox, autoshape_box_model.margin)
self.apply_stroke_to_shape(autoshape, autoshape_box_model.stroke)
self.apply_shadow_to_shape(autoshape, autoshape_box_model.shadow)
self.apply_border_radius_to_shape(autoshape, autoshape_box_model.border_radius)
if autoshape_box_model.paragraphs:
self.add_paragraphs(textbox, autoshape_box_model.paragraphs)
def add_textbox(self, slide: Slide, textbox_model: PptxTextBoxModel):
position = textbox_model.position
textbox_shape = slide.shapes.add_textbox(*position.to_pt_list())
textbox_shape.width += Pt(2)
textbox = textbox_shape.text_frame
textbox.word_wrap = textbox_model.text_wrap
self.apply_fill_to_shape(textbox_shape, textbox_model.fill)
self.apply_margin_to_text_box(textbox, textbox_model.margin)
self.add_paragraphs(textbox, textbox_model.paragraphs)
def add_paragraphs(
self, textbox: TextFrame, paragraph_models: List[PptxParagraphModel]
):
for index, paragraph_model in enumerate(paragraph_models):
paragraph = textbox.add_paragraph() if index > 0 else textbox.paragraphs[0]
self.populate_paragraph(paragraph, paragraph_model)
def populate_paragraph(
self, paragraph: _Paragraph, paragraph_model: PptxParagraphModel
):
if paragraph_model.spacing:
self.apply_spacing_to_paragraph(paragraph, paragraph_model.spacing)
if paragraph_model.line_height:
paragraph.line_spacing = paragraph_model.line_height
if paragraph_model.alignment:
paragraph.alignment = paragraph_model.alignment
if paragraph_model.font:
self.apply_font_to_paragraph(paragraph, paragraph_model.font)
text_runs = []
if paragraph_model.text:
text_runs = self.parse_html_text_to_text_runs(
paragraph_model.font, paragraph_model.text
)
elif paragraph_model.text_runs:
text_runs = paragraph_model.text_runs
for text_run_model in text_runs:
text_run = paragraph.add_run()
self.populate_text_run(text_run, text_run_model)
def parse_html_text_to_text_runs(self, font: Optional[PptxFontModel], text: str):
return parse_inline_html_to_runs(text, font)
def populate_text_run(self, text_run: _Run, text_run_model: PptxTextRunModel):
text_run.text = text_run_model.text
if text_run_model.font:
self.apply_font(text_run.font, text_run_model.font)
def apply_border_radius_to_shape(self, shape: Shape, border_radius: Optional[int]):
if not border_radius:
return
try:
normalized_border_radius = Pt(border_radius) / min(
shape.width, shape.height
)
shape.adjustments[0] = normalized_border_radius
except Exception:
print("Could not apply border radius.")
def apply_fill_to_shape(self, shape: Shape, fill: Optional[PptxFillModel] = None):
if not fill:
shape.fill.background()
else:
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor.from_string(fill.color)
self.set_fill_opacity(shape.fill, fill.opacity)
def apply_stroke_to_shape(
self, shape: Shape, stroke: Optional[PptxStrokeModel] = None
):
if not stroke or stroke.thickness == 0:
shape.line.fill.background()
else:
shape.line.fill.solid()
shape.line.fill.fore_color.rgb = RGBColor.from_string(stroke.color)
shape.line.width = Pt(stroke.thickness)
self.set_fill_opacity(shape.line.fill, stroke.opacity)
def apply_shadow_to_shape(
self, shape: Shape, shadow: Optional[PptxShadowModel] = None
):
# Access the XML for the shape
sp_element = shape._element
sp_pr = sp_element.xpath("p:spPr")[0] # Shape properties XML element
nsmap = sp_pr.nsmap
# # Remove existing shadow effects if present
effect_list = sp_pr.find("a:effectLst", namespaces=nsmap)
if effect_list:
old_outer_shadow = effect_list.find("a:outerShdw")
if old_outer_shadow:
effect_list.remove(
old_outer_shadow, namespaces=nsmap
) # Remove the old shadow
old_inner_shadow = effect_list.find("a:innerShdw")
if old_inner_shadow:
effect_list.remove(
old_inner_shadow, namespaces=nsmap
) # Remove the old shadow
old_prst_shadow = effect_list.find("a:prstShdw")
if old_prst_shadow:
effect_list.remove(
old_prst_shadow, namespaces=nsmap
) # Remove the old shadow
if not effect_list:
effect_list = etree.SubElement(
sp_pr, f"{{{nsmap['a']}}}effectLst", nsmap=nsmap
)
if shadow is None:
# Apply shadow with zero values when shadow is None
outer_shadow = etree.SubElement(
effect_list,
f"{{{nsmap['a']}}}outerShdw",
{
"blurRad": "0",
"dist": "0",
"dir": "0",
},
nsmap=nsmap,
)
color_element = etree.SubElement(
outer_shadow,
f"{{{nsmap['a']}}}srgbClr",
{"val": "000000"},
nsmap=nsmap,
)
etree.SubElement(
color_element,
f"{{{nsmap['a']}}}alpha",
{"val": "0"},
nsmap=nsmap,
)
else:
# Apply the provided shadow
# dir expects 60000ths of a degree in OOXML
angle_dir = (
int(round((shadow.angle % 360) * 60000))
if shadow.angle is not None
else 0
)
outer_shadow = etree.SubElement(
effect_list,
f"{{{nsmap['a']}}}outerShdw",
{
"blurRad": f"{Pt(shadow.radius)}",
"dir": f"{angle_dir}",
"dist": f"{Pt(shadow.offset)}",
"rotWithShape": "0",
},
nsmap=nsmap,
)
color_element = etree.SubElement(
outer_shadow,
f"{{{nsmap['a']}}}srgbClr",
{"val": f"{shadow.color}"},
nsmap=nsmap,
)
etree.SubElement(
color_element,
f"{{{nsmap['a']}}}alpha",
{"val": f"{int(shadow.opacity * 100000)}"},
nsmap=nsmap,
)
def set_fill_opacity(self, fill, opacity):
if opacity is None or opacity >= 1.0:
return
alpha = int((opacity) * 100000)
try:
ts = fill._xPr.solidFill
sF = ts.get_or_change_to_srgbClr()
self.get_sub_element(sF, "a:alpha", val=str(alpha))
except Exception as e:
print(f"Could not set fill opacity: {e}")
def get_margined_position(
self, position: PptxPositionModel, margin: Optional[PptxSpacingModel]
) -> PptxPositionModel:
if not margin:
return position
left = position.left + margin.left
top = position.top + margin.top
width = max(position.width - margin.left - margin.right, 0)
height = max(position.height - margin.top - margin.bottom, 0)
return PptxPositionModel(left=left, top=top, width=width, height=height)
def apply_margin_to_text_box(
self, text_frame: TextFrame, margin: Optional[PptxSpacingModel]
) -> PptxPositionModel:
text_frame.margin_left = Pt(margin.left if margin else 0)
text_frame.margin_right = Pt(margin.right if margin else 0)
text_frame.margin_top = Pt(margin.top if margin else 0)
text_frame.margin_bottom = Pt(margin.bottom if margin else 0)
def apply_spacing_to_paragraph(
self, paragraph: _Paragraph, spacing: PptxSpacingModel
):
paragraph.space_before = Pt(spacing.top)
paragraph.space_after = Pt(spacing.bottom)
def apply_font_to_paragraph(self, paragraph: _Paragraph, font: PptxFontModel):
self.apply_font(paragraph.font, font)
def apply_font(self, font: Font, font_model: PptxFontModel):
font.name = font_model.name
font.color.rgb = RGBColor.from_string(font_model.color)
font.italic = font_model.italic
font.size = Pt(font_model.size)
font.bold = font_model.font_weight >= 600
if font_model.underline is not None:
font.underline = bool(font_model.underline)
if font_model.strike is not None:
self.apply_strike_to_font(font, font_model.strike)
def apply_strike_to_font(self, font: Font, strike: Optional[bool]):
try:
rPr = font._element
if strike is True:
rPr.set("strike", "sngStrike")
elif strike is False:
rPr.set("strike", "noStrike")
except Exception as e:
print(f"Could not apply strikethrough: {e}")
def save(self, path: str):
self._ppt.save(path)
self.fix_keynote_compatibility(path)

View file

@ -1,40 +0,0 @@
import asyncio
from models.pptx_models import (
PptxAutoShapeBoxModel,
PptxFillModel,
PptxPositionModel,
PptxPresentationModel,
PptxSlideModel,
)
from services.pptx_presentation_creator import PptxPresentationCreator
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
pptx_model = PptxPresentationModel(
slides=[
PptxSlideModel(
shapes=[
PptxAutoShapeBoxModel(
type=MSO_AUTO_SHAPE_TYPE.RECTANGLE,
position=PptxPositionModel(
left=20,
right=20,
width=100,
height=100,
),
fill=PptxFillModel(
color="000000",
opacity=0.5,
),
)
]
)
]
)
def test_pptx_creator():
temp_dir = "/tmp/presenton"
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
asyncio.run(pptx_creator.create_ppt())
pptx_creator.save("debug/test.pptx")

View file

@ -1,67 +1,47 @@
import json
import os import os
import aiohttp
from typing import Literal from typing import Literal
from urllib.parse import urlencode
import uuid import uuid
from fastapi import HTTPException
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
from models.pptx_models import PptxPresentationModel
from models.presentation_and_path import PresentationAndPath from models.presentation_and_path import PresentationAndPath
from services.pptx_presentation_creator import PptxPresentationCreator from services.export_task_service import EXPORT_TASK_SERVICE
from services.temp_file_service import TEMP_FILE_SERVICE
from utils.asset_directory_utils import get_exports_directory
import uuid def _get_next_public_url() -> str:
return (os.getenv("NEXT_PUBLIC_URL") or "").strip() or "http://127.0.0.1"
def _get_next_public_fastapi_url() -> str | None:
value = (os.getenv("NEXT_PUBLIC_FAST_API") or "").strip()
return value or None
def _build_presentation_export_url(presentation_id: uuid.UUID) -> tuple[str, str | None]:
params = {"id": str(presentation_id)}
fastapi_url = _get_next_public_fastapi_url()
if fastapi_url:
params["fastapiUrl"] = fastapi_url
return (
f"{_get_next_public_url().rstrip('/')}/pdf-maker?{urlencode(params)}",
fastapi_url,
)
async def export_presentation( async def export_presentation(
presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"] presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"]
) -> PresentationAndPath: ) -> PresentationAndPath:
if export_as == "pptx": export_url, fastapi_url = _build_presentation_export_url(presentation_id)
export_result = await EXPORT_TASK_SERVICE.export_from_url(
url=export_url,
title=sanitize_filename(title or str(uuid.uuid4())),
export_as=export_as,
fastapi_url=fastapi_url,
)
# Get the converted PPTX model from the Next.js service return PresentationAndPath(
async with aiohttp.ClientSession() as session: presentation_id=presentation_id,
async with session.get( path=export_result.path,
f"http://localhost/api/presentation_to_pptx_model?id={presentation_id}" )
) as response:
if response.status != 200:
error_text = await response.text()
print(f"Failed to get PPTX model: {error_text}")
raise HTTPException(
status_code=500,
detail="Failed to convert presentation to PPTX model",
)
pptx_model_data = await response.json()
# Create PPTX file using the converted model
pptx_model = PptxPresentationModel(**pptx_model_data)
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
await pptx_creator.create_ppt()
export_directory = get_exports_directory()
pptx_path = os.path.join(
export_directory,
f"{sanitize_filename(title or str(uuid.uuid4()))}.pptx",
)
pptx_creator.save(pptx_path)
return PresentationAndPath(
presentation_id=presentation_id,
path=pptx_path,
)
else:
async with aiohttp.ClientSession() as session:
async with session.post(
"http://localhost/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"],
)

View file

@ -1,258 +0,0 @@
from typing import List
from PIL import Image, ImageDraw
from models.pptx_models import PptxObjectFitEnum, PptxObjectFitModel
def clip_image(
image: Image.Image,
width: int,
height: int,
focus_x: float = 50.0,
focus_y: float = 50.0,
) -> Image.Image:
img_width, img_height = image.size
img_aspect = img_width / img_height
box_aspect = width / height
if img_aspect > box_aspect:
new_height = height
new_width = int(new_height * img_aspect)
else:
new_width = width
new_height = int(new_width / img_aspect)
resized_image = image.resize((new_width, new_height), Image.LANCZOS)
# Calculate clipping position based on focus
# Convert focus percentages (0-100) to position in the resized image
focus_x = max(0.0, min(100.0, focus_x)) # Clamp to 0-100 range
focus_y = max(0.0, min(100.0, focus_y)) # Clamp to 0-100 range
# Calculate the center point based on focus
center_x = int((new_width - width) * (focus_x / 100.0))
center_y = int((new_height - height) * (focus_y / 100.0))
# Calculate clipping box
left = center_x
top = center_y
right = left + width
bottom = top + height
clipped_image = resized_image.crop((left, top, right, bottom))
return clipped_image
def round_image_corners(image: Image.Image, radii: List[int]) -> Image.Image:
if len(radii) != 4:
raise ValueError(
"Image Border Radius - radii must contain exactly 4 values for each corner"
)
w, h = image.size
# Clamp border radius to not exceed half the width or height
max_radius = min(w // 2, h // 2)
clamped_radii = [min(radius, max_radius) for radius in radii]
# Ensure the image has an alpha channel (RGBA)
if image.mode != "RGBA":
image = image.convert("RGBA")
# Create a mask for the rounded corners (start with fully transparent)
rounded_mask = Image.new("L", image.size, 0)
# Create a rectangular mask (fully opaque)
rectangular_mask = Image.new("L", image.size, 255)
# Process each corner
for i, radius in enumerate(clamped_radii):
if radius > 0: # Only process if radius is positive
# Create a circle for this radius
circle = Image.new("L", (radius * 2, radius * 2), 0)
draw = ImageDraw.Draw(circle)
draw.ellipse((0, 0, radius * 2 - 1, radius * 2 - 1), fill=255)
# Calculate position based on corner index
if i == 0: # top-left
rounded_mask.paste(circle.crop((0, 0, radius, radius)), (0, 0))
rectangular_mask.paste(0, (0, 0, radius, radius))
elif i == 1: # top-right
rounded_mask.paste(
circle.crop((radius, 0, radius * 2, radius)), (w - radius, 0)
)
rectangular_mask.paste(0, (w - radius, 0, w, radius))
elif i == 2: # bottom-right
rounded_mask.paste(
circle.crop((radius, radius, radius * 2, radius * 2)),
(w - radius, h - radius),
)
rectangular_mask.paste(0, (w - radius, h - radius, w, h))
else: # bottom-left
rounded_mask.paste(
circle.crop((0, radius, radius, radius * 2)), (0, h - radius)
)
rectangular_mask.paste(0, (0, h - radius, radius, h))
# Get the original alpha channel
original_alpha = image.getchannel("A")
# Combine the rectangular mask with the rounded corners
corner_mask = Image.composite(rounded_mask, rectangular_mask, rounded_mask)
# Combine the corner mask with the original alpha channel
final_alpha = Image.composite(
original_alpha, Image.new("L", image.size, 0), corner_mask
)
# Create a new image with the modified alpha channel
result = Image.new("RGBA", image.size)
result.paste(image.convert("RGB"), (0, 0))
result.putalpha(final_alpha)
return result
def invert_image(img: Image.Image) -> Image.Image:
# Get image data
data = img.getdata()
# Process each pixel
new_data = []
for item in data:
# Get current pixel values
r, g, b, a = item
# Invert RGB values while preserving transparency
if a != 0: # Skip fully transparent pixels
new_data.append((255 - r, 255 - g, 255 - b, a))
else:
new_data.append((0, 0, 0, 0))
# Create new image with modified data
new_img = Image.new("RGBA", img.size)
new_img.putdata(new_data)
return new_img
def create_circle_image(
image: Image.Image,
) -> Image.Image:
# Convert to RGBA if not already
img = image.convert("RGBA")
# Get the original image size
size = img.size
# Use the smaller dimension for the circle
circle_size = min(size)
# Create a transparent image of the same size as original
mask = Image.new("RGBA", size, color=(0, 0, 0, 0))
draw = ImageDraw.Draw(mask)
# Calculate center position
center_x = size[0] // 2
center_y = size[1] // 2
radius = circle_size // 2
# Create a circular mask
draw.ellipse(
(
center_x - radius,
center_y - radius,
center_x + radius,
center_y + radius,
),
fill=(255, 255, 255, 255),
)
# Apply the circular mask
result = Image.composite(img, mask, mask)
return result
def set_image_opacity(image: Image.Image, opacity: float) -> Image.Image:
# Clamp opacity to valid range
opacity = max(0.0, min(1.0, opacity))
# Convert to RGBA if not already
if image.mode != "RGBA":
image = image.convert("RGBA")
# Get the original alpha channel
original_alpha = image.getchannel("A")
# Create new alpha channel with adjusted opacity
new_alpha = original_alpha.point(lambda x: int(x * opacity))
# Create new image with modified alpha channel
result = Image.new("RGBA", image.size)
result.paste(image.convert("RGB"), (0, 0))
result.putalpha(new_alpha)
return result
def fit_image(
image: Image.Image, width: int, height: int, object_fit: PptxObjectFitModel
) -> Image.Image:
if not object_fit.fit:
return image
img_width, img_height = image.size
img_aspect = img_width / img_height
box_aspect = width / height
if object_fit.fit == PptxObjectFitEnum.CONTAIN:
# Scale image to fit within the box while maintaining aspect ratio
if img_aspect > box_aspect:
new_width = width
new_height = int(width / img_aspect)
else:
new_height = height
new_width = int(height * img_aspect)
resized_image = image.resize((new_width, new_height), Image.LANCZOS)
# Use focus point for positioning if available
focus_x = 50.0
focus_y = 50.0
if object_fit.focus and len(object_fit.focus) == 2:
focus_x, focus_y = object_fit.focus[0], object_fit.focus[1]
# Calculate paste position based on focus
paste_x = int((width - new_width) * (focus_x / 100.0))
paste_y = int((height - new_height) * (focus_y / 100.0))
result = Image.new("RGBA", (width, height), (0, 0, 0, 0))
result.paste(resized_image, (paste_x, paste_y))
return result
elif object_fit.fit == PptxObjectFitEnum.COVER:
# Scale image to cover the box while maintaining aspect ratio
if img_aspect > box_aspect:
new_height = height
new_width = int(height * img_aspect)
else:
new_width = width
new_height = int(width / img_aspect)
resized_image = image.resize((new_width, new_height), Image.LANCZOS)
# Use focus point for positioning if available
focus_x = 50.0
focus_y = 50.0
if object_fit.focus and len(object_fit.focus) == 2:
focus_x, focus_y = object_fit.focus[0], object_fit.focus[1]
# Calculate paste position based on focus
paste_x = int((new_width - width) * (focus_x / 100.0))
paste_y = int((new_height - height) * (focus_y / 100.0))
# Clip the image to the box size
return resized_image.crop((paste_x, paste_y, paste_x + width, paste_y + height))
elif object_fit.fit == PptxObjectFitEnum.FILL:
# Stretch image to fill the box exactly
return image.resize((width, height), Image.LANCZOS)
return image

View file

@ -25,7 +25,6 @@ import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store"; import { RootState } from "@/store/store";
import { toast } from "sonner"; import { toast } from "sonner";
import { PptxPresentationModel } from "@/types/pptx_models";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo"; import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
import ToolTip from "@/components/ToolTip"; import ToolTip from "@/components/ToolTip";
@ -42,6 +41,39 @@ import { Theme } from "../../services/api/types";
import MarkdownRenderer from "@/components/MarkDownRender"; import MarkdownRenderer from "@/components/MarkDownRender";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MAX_EXPORT_TITLE_LENGTH = 40;
const buildSafeExportFileName = (
rawTitle: string | null | undefined,
extension: "pdf" | "pptx"
) => {
const normalizedTitle = (rawTitle || "presentation").trim();
const titleWithoutExtension = normalizedTitle.replace(
/\.(pdf|pptx)$/i,
""
);
let safeBase = titleWithoutExtension
.replace(/[^a-zA-Z0-9\s_-]+/g, "-")
.replace(/\s+/g, "-")
.replace(/[-_]{2,}/g, "-")
.replace(/^[-_]+|[-_]+$/g, "");
if (!safeBase) {
safeBase = "presentation";
}
if (safeBase.length > MAX_EXPORT_TITLE_LENGTH) {
safeBase = safeBase.slice(0, MAX_EXPORT_TITLE_LENGTH).replace(/[-_]+$/g, "");
}
if (!safeBase) {
safeBase = "presentation";
}
return `${safeBase}.${extension}`;
};
const PresentationHeader = ({ const PresentationHeader = ({
presentation_id, presentation_id,
isPresentationSaving, isPresentationSaving,
@ -138,15 +170,13 @@ const PresentationHeader = ({
titleBlurIntentRef.current = "cancel"; titleBlurIntentRef.current = "cancel";
}; };
const get_presentation_pptx_model = async (id: string): Promise<PptxPresentationModel> => { const exportViaIpc = async (
const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`); format: "pptx" | "pdf",
const pptx_model = await response.json(); title: string
return pptx_model; ): Promise<void> => {
}; if (typeof window === "undefined" || !(window as any).electron?.exportPresentation) {
throw new Error("Electron export bridge is unavailable");
const exportViaIpc = async (format: "pptx" | "pdf"): Promise<boolean> => { }
if (typeof window === 'undefined') return false;
if (!(window as any).electron?.exportPresentation) return false;
trackEvent( trackEvent(
format === "pptx" format === "pptx"
? MixpanelEvent.Header_ExportAsPPTX_API_Call ? MixpanelEvent.Header_ExportAsPPTX_API_Call
@ -154,13 +184,12 @@ const PresentationHeader = ({
); );
const result = await (window as any).electron.exportPresentation( const result = await (window as any).electron.exportPresentation(
presentation_id, presentation_id,
presentationData?.title || 'presentation', title,
format format
); );
if (!result?.success) { if (!result?.success) {
throw new Error(result?.message || 'Export failed'); throw new Error(result?.message || "Export failed");
} }
return true;
}; };
const handleExportPptx = async () => { const handleExportPptx = async () => {
@ -172,25 +201,13 @@ const PresentationHeader = ({
// Save the presentation data before exporting // Save the presentation data before exporting
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call); trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
await PresentationGenerationApi.updatePresentationContent(presentationData); await PresentationGenerationApi.updatePresentationContent(presentationData);
const safePptxFileName = buildSafeExportFileName(
if (await exportViaIpc("pptx")) { presentationData?.title,
toast.success("PPTX exported successfully!"); "pptx"
return; );
} const safePptxTitle = safePptxFileName.replace(/\.pptx$/i, "");
await exportViaIpc("pptx", safePptxTitle);
trackEvent(MixpanelEvent.Header_GetPptxModel_API_Call); toast.success("PPTX exported successfully!");
const pptx_model = await get_presentation_pptx_model(presentation_id);
if (!pptx_model) {
throw new Error("Failed to get presentation PPTX model");
}
trackEvent(MixpanelEvent.Header_ExportAsPPTX_API_Call);
const pptx_path = await PresentationGenerationApi.exportAsPPTX(pptx_model);
if (pptx_path) {
// window.open(pptx_path, '_self');
downloadLink(pptx_path);
} else {
throw new Error("No path returned from export");
}
} catch (error) { } catch (error) {
console.error("Export failed:", error); console.error("Export failed:", error);
toast.error("Having trouble exporting!", { toast.error("Having trouble exporting!", {
@ -211,27 +228,13 @@ const PresentationHeader = ({
// Save the presentation data before exporting // Save the presentation data before exporting
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call); trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
await PresentationGenerationApi.updatePresentationContent(presentationData); await PresentationGenerationApi.updatePresentationContent(presentationData);
const safePdfFileName = buildSafeExportFileName(
trackEvent(MixpanelEvent.Header_ExportAsPDF_API_Call); presentationData?.title,
if (await exportViaIpc("pdf")) { "pdf"
toast.success("PDF exported successfully!"); );
return; const safePdfTitle = safePdfFileName.replace(/\.pdf$/i, "");
} await exportViaIpc("pdf", safePdfTitle);
const response = await fetch('/api/export-as-pdf', { toast.success("PDF exported successfully!");
method: 'POST',
body: JSON.stringify({
id: presentation_id,
title: presentationData?.title,
})
});
if (response.ok) {
const { path: pdfPath } = await response.json();
// window.open(pdfPath, '_blank');
downloadLink(pdfPath);
} else {
throw new Error("Failed to export PDF");
}
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -249,19 +252,6 @@ const PresentationHeader = ({
trackEvent(MixpanelEvent.Header_ReGenerate_Button_Clicked, { pathname }); trackEvent(MixpanelEvent.Header_ReGenerate_Button_Clicked, { pathname });
router.push(`/presentation?id=${presentation_id}&stream=true`); router.push(`/presentation?id=${presentation_id}&stream=true`);
}; };
const downloadLink = (path: string) => {
// if we have popup access give direct download if not redirect to the path
if (window.opener) {
window.open(path, '_blank');
} else {
const link = document.createElement('a');
link.href = path;
link.download = path.split('/').pop() || 'download';
document.body.appendChild(link);
link.click();
}
};
const ExportOptions = ({ mobile }: { mobile: boolean }) => ( const ExportOptions = ({ mobile }: { mobile: boolean }) => (
<div className={` rounded-[18px] max-md:mt-4 ${mobile ? "" : "bg-white"} p-5`}> <div className={` rounded-[18px] max-md:mt-4 ${mobile ? "" : "bg-white"} p-5`}>
<p className="text-sm font-medium text-[#19001F]">Export as</p> <p className="text-sm font-medium text-[#19001F]">Export as</p>

View file

@ -226,27 +226,4 @@ export class PresentationGenerationApi {
} }
} }
}
// EXPORT PRESENTATION
static async exportAsPPTX(presentationData: any) {
try {
const response = await fetch(
getApiUrl(`/api/v1/ppt/presentation/export/pptx`),
{
method: "POST",
headers: getHeader(),
body: JSON.stringify(presentationData),
cache: "no-cache",
}
);
return await ApiResponseHandler.handleResponse(response, "Failed to export as PowerPoint");
} catch (error) {
console.error("error in pptx export", error);
throw error;
}
}
}

View file

@ -1,114 +0,0 @@
import path from "path";
import fs from "fs";
import puppeteer from "puppeteer";
import { sanitizeFilename } from "@/app/(presentation-generator)/utils/others";
import { NextResponse, NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const { id, title } = await req.json();
if (!id) {
return NextResponse.json(
{ error: "Missing Presentation ID" },
{ status: 400 }
);
}
// Get the Next.js server URL from environment variable or construct from request
// NEXT_PUBLIC_URL is set by Electron app and includes protocol (e.g., "http://127.0.0.1:40001")
let nextjsUrl = process.env.NEXT_PUBLIC_URL;
if (!nextjsUrl) {
// In Docker environment, use localhost (goes through nginx on port 80)
// This ensures API calls from the page use window.location.origin = http://localhost
// which routes /api/v1/ through nginx to FastAPI correctly
nextjsUrl = 'http://localhost';
}
const browser = await puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
headless: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-web-security",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-features=TranslateUI",
"--disable-ipc-flooding-protection",
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720 });
page.setDefaultNavigationTimeout(300000);
page.setDefaultTimeout(300000);
await page.goto(`${nextjsUrl}/pdf-maker?id=${id}`, {
waitUntil: "networkidle0",
timeout: 300000,
});
await page.waitForFunction('() => document.readyState === "complete"');
try {
await page.waitForFunction(
`
() => {
const allElements = document.querySelectorAll('*');
let loadedElements = 0;
let totalElements = allElements.length;
for (let el of allElements) {
const style = window.getComputedStyle(el);
const isVisible = style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0';
if (isVisible && el.offsetWidth > 0 && el.offsetHeight > 0) {
loadedElements++;
}
}
return (loadedElements / totalElements) >= 0.99;
}
`,
{ timeout: 300000 }
);
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (error) {
console.log("Warning: Some content may not have loaded completely:", error);
}
const pdfBuffer = await page.pdf({
width: "1280px",
height: "720px",
printBackground: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
});
browser.close();
const sanitizedTitle = sanitizeFilename(title ?? "presentation");
const appDataDirectory = process.env.APP_DATA_DIRECTORY!;
if (!appDataDirectory) {
return NextResponse.json({
error: "App data directory not found",
status: 500,
});
}
const destinationPath = path.join(
appDataDirectory,
"exports",
`${sanitizedTitle}.pdf`
);
await fs.promises.mkdir(path.dirname(destinationPath), { recursive: true });
await fs.promises.writeFile(destinationPath, pdfBuffer);
return NextResponse.json({
success: true,
path: destinationPath,
});
}

View file

@ -1,82 +0,0 @@
import { ElementHandle } from "puppeteer";
export interface ElementAttributes {
tagName: string;
id?: string;
className?: string;
innerText?: string;
opacity?: number;
background?: {
color?: string;
opacity?: number;
};
border?: {
color?: string;
width?: number;
opacity?: number;
};
shadow?: {
offset?: [number, number];
color?: string;
opacity?: number;
radius?: number;
angle?: number;
spread?: number;
inset?: boolean;
},
font?: {
name?: string;
size?: number;
weight?: number;
color?: string;
italic?: boolean;
};
position?: {
left?: number;
top?: number;
width?: number;
height?: number;
};
margin?: {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
padding?: {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
zIndex?: number;
textAlign?: 'left' | 'center' | 'right' | 'justify';
lineHeight?: number;
borderRadius?: number[];
imageSrc?: string;
objectFit?: 'contain' | 'cover' | 'fill';
clip?: boolean;
overlay?: string;
shape?: 'rectangle' | 'circle';
connectorType?: string;
textWrap?: boolean;
should_screenshot?: boolean;
element?: ElementHandle<Element>;
filters?: {
invert?: number;
brightness?: number;
contrast?: number;
saturate?: number;
hueRotate?: number;
blur?: number;
grayscale?: number;
sepia?: number;
opacity?: number;
};
}
export interface SlideAttributesResult {
elements: ElementAttributes[];
backgroundColor?: string;
speakerNote?: string;
}

View file

@ -16,7 +16,11 @@ interface TextFrameProps {
// Electron IPC types // Electron IPC types
interface ElectronAPI { interface ElectronAPI {
fileDownloaded: (filePath: string) => Promise<any>; fileDownloaded: (filePath: string) => Promise<any>;
exportAsPDF: (id: string, title: string) => Promise<any>; exportPresentation: (
id: string,
title: string,
format: "pptx" | "pdf"
) => Promise<any>;
getUserConfig: () => Promise<any>; getUserConfig: () => Promise<any>;
setUserConfig: (userConfig: any) => Promise<any>; setUserConfig: (userConfig: any) => Promise<any>;
getCanChangeKeys: () => Promise<boolean>; getCanChangeKeys: () => Promise<boolean>;

View file

@ -1,364 +0,0 @@
export enum PptxBoxShapeEnum {
RECTANGLE = "rectangle",
CIRCLE = "circle"
}
export enum PptxObjectFitEnum {
CONTAIN = "contain",
COVER = "cover",
FILL = "fill"
}
export enum PptxAlignment {
CENTER = 2,
DISTRIBUTE = 5,
JUSTIFY = 4,
JUSTIFY_LOW = 7,
LEFT = 1,
RIGHT = 3,
THAI_DISTRIBUTE = 6,
MIXED = -2
}
export enum PptxShapeType {
ACTION_BUTTON_BACK_OR_PREVIOUS = 129,
ACTION_BUTTON_BEGINNING = 131,
ACTION_BUTTON_CUSTOM = 125,
ACTION_BUTTON_DOCUMENT = 134,
ACTION_BUTTON_END = 132,
ACTION_BUTTON_FORWARD_OR_NEXT = 130,
ACTION_BUTTON_HELP = 127,
ACTION_BUTTON_HOME = 126,
ACTION_BUTTON_INFORMATION = 128,
ACTION_BUTTON_MOVIE = 136,
ACTION_BUTTON_RETURN = 133,
ACTION_BUTTON_SOUND = 135,
ARC = 25,
BALLOON = 137,
BENT_ARROW = 41,
BENT_UP_ARROW = 44,
BEVEL = 15,
BLOCK_ARC = 20,
CAN = 13,
CHART_PLUS = 182,
CHART_STAR = 181,
CHART_X = 180,
CHEVRON = 52,
CHORD = 161,
CIRCULAR_ARROW = 60,
CLOUD = 179,
CLOUD_CALLOUT = 108,
CORNER = 162,
CORNER_TABS = 169,
CROSS = 11,
CUBE = 14,
CURVED_DOWN_ARROW = 48,
CURVED_DOWN_RIBBON = 100,
CURVED_LEFT_ARROW = 46,
CURVED_RIGHT_ARROW = 45,
CURVED_UP_ARROW = 47,
CURVED_UP_RIBBON = 99,
DECAGON = 144,
DIAGONAL_STRIPE = 141,
DIAMOND = 4,
DODECAGON = 146,
DONUT = 18,
DOUBLE_BRACE = 27,
DOUBLE_BRACKET = 26,
DOUBLE_WAVE = 104,
DOWN_ARROW = 36,
DOWN_ARROW_CALLOUT = 56,
DOWN_RIBBON = 98,
EXPLOSION1 = 89,
EXPLOSION2 = 90,
FLOWCHART_ALTERNATE_PROCESS = 62,
FLOWCHART_CARD = 75,
FLOWCHART_COLLATE = 79,
FLOWCHART_CONNECTOR = 73,
FLOWCHART_DATA = 64,
FLOWCHART_DECISION = 63,
FLOWCHART_DELAY = 84,
FLOWCHART_DIRECT_ACCESS_STORAGE = 87,
FLOWCHART_DISPLAY = 88,
FLOWCHART_DOCUMENT = 67,
FLOWCHART_EXTRACT = 81,
FLOWCHART_INTERNAL_STORAGE = 66,
FLOWCHART_MAGNETIC_DISK = 86,
FLOWCHART_MANUAL_INPUT = 71,
FLOWCHART_MANUAL_OPERATION = 72,
FLOWCHART_MERGE = 82,
FLOWCHART_MULTIDOCUMENT = 68,
FLOWCHART_OFFLINE_STORAGE = 139,
FLOWCHART_OFFPAGE_CONNECTOR = 74,
FLOWCHART_OR = 78,
FLOWCHART_PREDEFINED_PROCESS = 65,
FLOWCHART_PREPARATION = 70,
FLOWCHART_PROCESS = 61,
FLOWCHART_PUNCHED_TAPE = 76,
FLOWCHART_SEQUENTIAL_ACCESS_STORAGE = 85,
FLOWCHART_SORT = 80,
FLOWCHART_STORED_DATA = 83,
FLOWCHART_SUMMING_JUNCTION = 77,
FLOWCHART_TERMINATOR = 69,
FOLDED_CORNER = 16,
FRAME = 158,
FUNNEL = 174,
GEAR_6 = 172,
GEAR_9 = 173,
HALF_FRAME = 159,
HEART = 21,
HEPTAGON = 145,
HEXAGON = 10,
HORIZONTAL_SCROLL = 102,
ISOSCELES_TRIANGLE = 7,
LEFT_ARROW = 34,
LEFT_ARROW_CALLOUT = 54,
LEFT_BRACE = 31,
LEFT_BRACKET = 29,
LEFT_CIRCULAR_ARROW = 176,
LEFT_RIGHT_ARROW = 37,
LEFT_RIGHT_ARROW_CALLOUT = 57,
LEFT_RIGHT_CIRCULAR_ARROW = 177,
LEFT_RIGHT_RIBBON = 140,
LEFT_RIGHT_UP_ARROW = 40,
LEFT_UP_ARROW = 43,
LIGHTNING_BOLT = 22,
LINE_CALLOUT_1 = 109,
LINE_CALLOUT_1_ACCENT_BAR = 113,
LINE_CALLOUT_1_BORDER_AND_ACCENT_BAR = 121,
LINE_CALLOUT_1_NO_BORDER = 117,
LINE_CALLOUT_2 = 110,
LINE_CALLOUT_2_ACCENT_BAR = 114,
LINE_CALLOUT_2_BORDER_AND_ACCENT_BAR = 122,
LINE_CALLOUT_2_NO_BORDER = 118,
LINE_CALLOUT_3 = 111,
LINE_CALLOUT_3_ACCENT_BAR = 115,
LINE_CALLOUT_3_BORDER_AND_ACCENT_BAR = 123,
LINE_CALLOUT_3_NO_BORDER = 119,
LINE_CALLOUT_4 = 112,
LINE_CALLOUT_4_ACCENT_BAR = 116,
LINE_CALLOUT_4_BORDER_AND_ACCENT_BAR = 124,
LINE_CALLOUT_4_NO_BORDER = 120,
LINE_INVERSE = 183,
MATH_DIVIDE = 166,
MATH_EQUAL = 167,
MATH_MINUS = 164,
MATH_MULTIPLY = 165,
MATH_NOT_EQUAL = 168,
MATH_PLUS = 163,
MOON = 24,
NON_ISOSCELES_TRAPEZOID = 143,
NOTCHED_RIGHT_ARROW = 50,
NO_SYMBOL = 19,
OCTAGON = 6,
OVAL = 9,
OVAL_CALLOUT = 107,
PARALLELOGRAM = 2,
PENTAGON = 51,
PIE = 142,
PIE_WEDGE = 175,
PLAQUE = 28,
PLAQUE_TABS = 171,
QUAD_ARROW = 39,
QUAD_ARROW_CALLOUT = 59,
RECTANGLE = 1,
RECTANGULAR_CALLOUT = 105,
REGULAR_PENTAGON = 12,
RIGHT_ARROW = 33,
RIGHT_ARROW_CALLOUT = 53,
RIGHT_BRACE = 32,
RIGHT_BRACKET = 30,
RIGHT_TRIANGLE = 8,
ROUNDED_RECTANGLE = 5,
ROUNDED_RECTANGULAR_CALLOUT = 106,
ROUND_1_RECTANGLE = 151,
ROUND_2_DIAG_RECTANGLE = 153,
ROUND_2_SAME_RECTANGLE = 152,
SMILEY_FACE = 17,
SNIP_1_RECTANGLE = 155,
SNIP_2_DIAG_RECTANGLE = 157,
SNIP_2_SAME_RECTANGLE = 156,
SNIP_ROUND_RECTANGLE = 154,
SQUARE_TABS = 170,
STAR_10_POINT = 149,
STAR_12_POINT = 150,
STAR_16_POINT = 94,
STAR_24_POINT = 95,
STAR_32_POINT = 96,
STAR_4_POINT = 91,
STAR_5_POINT = 92,
STAR_6_POINT = 147,
STAR_7_POINT = 148,
STAR_8_POINT = 93,
STRIPED_RIGHT_ARROW = 49,
SUN = 23,
SWOOSH_ARROW = 178,
TEAR = 160,
TRAPEZOID = 3,
UP_ARROW = 35,
UP_ARROW_CALLOUT = 55,
UP_DOWN_ARROW = 38,
UP_DOWN_ARROW_CALLOUT = 58,
UP_RIBBON = 97,
U_TURN_ARROW = 42,
VERTICAL_SCROLL = 101,
WAVE = 103
}
export enum PptxConnectorType {
CURVE = 3,
ELBOW = 2,
STRAIGHT = 1,
MIXED = -2
}
export interface PptxSpacingModel {
top: number;
bottom: number;
left: number;
right: number;
}
export interface PptxPositionModel {
left: number;
top: number;
width: number;
height: number;
}
export interface PptxFontModel {
name: string;
size: number;
font_weight: number;
italic: boolean;
color: string;
}
export interface PptxFillModel {
color: string;
opacity: number;
}
export interface PptxStrokeModel {
color: string;
thickness: number;
opacity: number;
}
export interface PptxShadowModel {
radius: number;
offset: number;
color: string;
opacity: number;
angle: number;
}
export interface PptxTextRunModel {
text: string;
font?: PptxFontModel;
}
export interface PptxParagraphModel {
spacing?: PptxSpacingModel;
alignment?: PptxAlignment;
font?: PptxFontModel;
line_height?: number;
text?: string;
text_runs?: PptxTextRunModel[];
}
export interface PptxObjectFitModel {
fit?: PptxObjectFitEnum;
focus?: [number | null, number | null];
}
export interface PptxPictureModel {
is_network: boolean;
path: string;
}
export interface PptxShapeModel {
}
export interface PptxTextBoxModel extends PptxShapeModel {
shape_type: string;
margin?: PptxSpacingModel;
fill?: PptxFillModel;
position: PptxPositionModel;
text_wrap: boolean;
paragraphs: PptxParagraphModel[];
}
export interface PptxAutoShapeBoxModel extends PptxShapeModel {
shape_type: string;
type?: PptxShapeType;
margin?: PptxSpacingModel;
fill?: PptxFillModel;
stroke?: PptxStrokeModel;
shadow?: PptxShadowModel;
position: PptxPositionModel;
text_wrap: boolean;
border_radius?: number;
paragraphs?: PptxParagraphModel[];
}
export interface PptxPictureBoxModel extends PptxShapeModel {
shape_type: string;
position: PptxPositionModel;
margin?: PptxSpacingModel;
clip: boolean;
opacity?: number;
invert?: boolean;
border_radius?: number[];
shape?: PptxBoxShapeEnum;
object_fit?: PptxObjectFitModel;
picture: PptxPictureModel;
}
export interface PptxConnectorModel extends PptxShapeModel {
shape_type: string;
type?: PptxConnectorType;
position: PptxPositionModel;
thickness: number;
color: string;
opacity: number;
}
export interface PptxSlideModel {
background?: PptxFillModel;
shapes: (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[];
note?: string;
}
export interface PptxPresentationModel {
name?: string;
shapes?: PptxShapeModel[];
slides: PptxSlideModel[];
}
export const createPptxSpacingAll = (num: number): PptxSpacingModel => ({
top: num,
left: num,
bottom: num,
right: num
});
export const createPptxPositionForTextbox = (left: number, top: number, width: number): PptxPositionModel => ({
left,
top,
width,
height: 100
});
export const positionToPtList = (position: PptxPositionModel): number[] => {
return [position.left, position.top, position.width, position.height];
};
export const positionToPtXyxy = (position: PptxPositionModel): number[] => {
const left = position.left;
const top = position.top;
const width = position.width;
const height = position.height;
return [left, top, left + width, top + height];
};

View file

@ -19,7 +19,6 @@ export enum MixpanelEvent {
Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked', Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked',
Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call', Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call',
Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call', Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call',
Header_GetPptxModel_API_Call = 'Header Get PPTX Model API Call',
Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call', Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call',
Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked', Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked',
Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked', Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked',

View file

@ -1,255 +0,0 @@
import { ElementAttributes, SlideAttributesResult } from "@/types/element_attibutes";
import {
PptxSlideModel,
PptxTextBoxModel,
PptxAutoShapeBoxModel,
PptxPictureBoxModel,
PptxConnectorModel,
PptxPositionModel,
PptxFillModel,
PptxStrokeModel,
PptxShadowModel,
PptxFontModel,
PptxParagraphModel,
PptxPictureModel,
PptxObjectFitModel,
PptxBoxShapeEnum,
PptxObjectFitEnum,
PptxAlignment,
PptxShapeType,
PptxConnectorType
} from "@/types/pptx_models";
function convertTextAlignToPptxAlignment(textAlign?: string): PptxAlignment | undefined {
if (!textAlign) return undefined;
switch (textAlign.toLowerCase()) {
case 'left':
return PptxAlignment.LEFT;
case 'center':
return PptxAlignment.CENTER;
case 'right':
return PptxAlignment.RIGHT;
case 'justify':
return PptxAlignment.JUSTIFY;
default:
return PptxAlignment.LEFT;
}
}
function convertLineHeightToRelative(lineHeight?: number, fontSize?: number): number | undefined {
if (!lineHeight) return undefined;
let calculatedLineHeight = 1.2;
if (lineHeight < 10) {
calculatedLineHeight = lineHeight;
}
if (fontSize && fontSize > 0) {
calculatedLineHeight = Math.round((lineHeight / fontSize) * 100) / 100;
}
return calculatedLineHeight - 0.3
}
export function convertElementAttributesToPptxSlides(
slidesAttributes: SlideAttributesResult[]
): PptxSlideModel[] {
return slidesAttributes.map((slideAttributes) => {
const shapes = slideAttributes.elements.map(element => {
return convertElementToPptxShape(element);
}).filter(Boolean);
const slide: PptxSlideModel = {
shapes: shapes as (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[],
note: slideAttributes.speakerNote
};
if (slideAttributes.backgroundColor) {
slide.background = {
color: slideAttributes.backgroundColor,
opacity: 1.0
};
}
return slide;
});
}
function convertElementToPptxShape(
element: ElementAttributes
): PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel | null {
if (!element.position) {
return null;
}
if (element.tagName === 'img' || (element.className && typeof element.className === 'string' && element.className.includes('image')) || element.imageSrc) {
return convertToPictureBox(element);
}
if (element.innerText && element.innerText.trim().length > 0) {
// Use AutoShape model if there's background color and border radius
if (element.background?.color && element.borderRadius && element.borderRadius.some(radius => radius > 0)) {
return convertToAutoShapeBox(element);
}
return convertToTextBox(element);
}
if (element.tagName === 'hr') {
return convertToConnector(element);
}
return convertToAutoShapeBox(element);
}
function convertToTextBox(element: ElementAttributes): PptxTextBoxModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
top: Math.round(element.position?.top ?? 0),
width: Math.round(element.position?.width ?? 0),
height: Math.round(element.position?.height ?? 0)
};
const fill: PptxFillModel | undefined = element.background?.color ? {
color: element.background.color,
opacity: element.background.opacity ?? 1.0
} : undefined;
const font: PptxFontModel | undefined = element.font ? {
name: element.font.name ?? "Inter",
size: Math.round(element.font.size ?? 16),
font_weight: element.font.weight ?? 400,
italic: element.font.italic ?? false,
color: element.font.color ?? "000000"
} : undefined;
const paragraph: PptxParagraphModel = {
spacing: undefined,
alignment: convertTextAlignToPptxAlignment(element.textAlign),
font,
line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size),
text: element.innerText
};
return {
shape_type: "textbox",
margin: undefined,
fill,
position,
text_wrap: element.textWrap ?? true,
paragraphs: [paragraph]
};
}
function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
top: Math.round(element.position?.top ?? 0),
width: Math.round(element.position?.width ?? 0),
height: Math.round(element.position?.height ?? 0)
};
const fill: PptxFillModel | undefined = element.background?.color ? {
color: element.background.color,
opacity: element.background.opacity ?? 1.0
} : undefined;
const stroke: PptxStrokeModel | undefined = element.border?.color ? {
color: element.border.color,
thickness: element.border.width ?? 1,
opacity: element.border.opacity ?? 1.0
} : undefined;
const shadow: PptxShadowModel | undefined = element.shadow?.color ? {
radius: Math.round(element.shadow.radius ?? 4),
offset: Math.round(element.shadow.offset ? Math.sqrt(element.shadow.offset[0] ** 2 + element.shadow.offset[1] ** 2) : 0),
color: element.shadow.color,
opacity: element.shadow.opacity ?? 0.5,
angle: Math.round(element.shadow.angle ?? 0)
} : undefined;
const paragraphs: PptxParagraphModel[] | undefined = element.innerText ? [{
spacing: undefined,
alignment: convertTextAlignToPptxAlignment(element.textAlign),
font: element.font ? {
name: element.font.name ?? "Inter",
size: Math.round(element.font.size ?? 16),
font_weight: element.font.weight ?? 400,
italic: element.font.italic ?? false,
color: element.font.color ?? "000000"
} : undefined,
line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size),
text: element.innerText
}] : undefined;
const shapeType = element.borderRadius ? PptxShapeType.ROUNDED_RECTANGLE : PptxShapeType.RECTANGLE;
let borderRadius = undefined;
for (const eachCornerRadius of element.borderRadius ?? []) {
if (eachCornerRadius > 0) {
borderRadius = Math.max(borderRadius ?? 0, eachCornerRadius);
}
}
return {
shape_type: "autoshape",
type: shapeType,
margin: undefined,
fill,
stroke,
shadow,
position,
text_wrap: element.textWrap ?? true,
border_radius: borderRadius || undefined,
paragraphs
};
}
function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
top: Math.round(element.position?.top ?? 0),
width: Math.round(element.position?.width ?? 0),
height: Math.round(element.position?.height ?? 0)
};
const objectFit: PptxObjectFitModel = {
fit: element.objectFit ? (element.objectFit as PptxObjectFitEnum) : PptxObjectFitEnum.CONTAIN
};
const picture: PptxPictureModel = {
is_network: element.imageSrc ? element.imageSrc.startsWith('http') : false,
path: element.imageSrc || ''
};
return {
shape_type: "picture",
position,
margin: undefined,
clip: element.clip ?? true,
invert: element.filters?.invert === 1,
opacity: element.opacity,
border_radius: element.borderRadius ? element.borderRadius.map(r => Math.round(r)) : undefined,
shape: element.shape ? (element.shape as PptxBoxShapeEnum) : PptxBoxShapeEnum.RECTANGLE,
object_fit: objectFit,
picture
};
}
function convertToConnector(element: ElementAttributes): PptxConnectorModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
top: Math.round(element.position?.top ?? 0),
width: Math.round(element.position?.width ?? 0),
height: Math.round(element.position?.height ?? 0)
};
return {
shape_type: "connector",
type: PptxConnectorType.STRAIGHT,
position,
thickness: element.border?.width ?? 0.5,
color: element.border?.color || element.background?.color || '000000',
opacity: element.border?.opacity ?? 1.0
};
}

View file

@ -1,7 +1,7 @@
{ {
"name": "presenton", "name": "presenton",
"version": "1.0.0", "version": "1.0.0",
"presentationExportVersion": "v0.2.0", "presentationExportVersion": "v0.2.2",
"type": "module", "type": "module",
"description": "Open-source AI presentation generator", "description": "Open-source AI presentation generator",
"scripts": { "scripts": {

View file

@ -23,7 +23,6 @@ from models.presentation_outline_model import (
) )
from enums.tone import Tone from enums.tone import Tone
from enums.verbosity import Verbosity from enums.verbosity import Verbosity
from models.pptx_models import PptxPresentationModel
from models.presentation_structure_model import PresentationStructureModel from models.presentation_structure_model import PresentationStructureModel
from models.presentation_with_slides import ( from models.presentation_with_slides import (
PresentationWithSlides, PresentationWithSlides,
@ -46,14 +45,12 @@ from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
from services.database import get_async_session from services.database import get_async_session
from services.temp_file_service import TEMP_FILE_SERVICE
from services.concurrent_service import CONCURRENT_SERVICE from services.concurrent_service import CONCURRENT_SERVICE
from models.sql.presentation import PresentationModel from models.sql.presentation import PresentationModel
from services.pptx_presentation_creator import PptxPresentationCreator
from models.sql.async_presentation_generation_status import ( from models.sql.async_presentation_generation_status import (
AsyncPresentationGenerationTaskModel, AsyncPresentationGenerationTaskModel,
) )
from utils.asset_directory_utils import get_exports_directory, get_images_directory from utils.asset_directory_utils import get_images_directory
from utils.llm_calls.generate_presentation_structure import ( from utils.llm_calls.generate_presentation_structure import (
generate_presentation_structure, generate_presentation_structure,
) )
@ -501,56 +498,6 @@ async def update_presentation(
slides=response_slides, slides=response_slides,
fonts=fonts, fonts=fonts,
) )
@PRESENTATION_ROUTER.post("/export/pptx", response_model=str)
async def export_presentation_as_pptx(
pptx_model: Annotated[PptxPresentationModel, Body()],
):
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
await pptx_creator.create_ppt()
export_directory = get_exports_directory()
pptx_path = os.path.join(
export_directory, f"{pptx_model.name or uuid.uuid4()}.pptx"
)
pptx_creator.save(pptx_path)
return pptx_path
@PRESENTATION_ROUTER.post("/export", response_model=PresentationPathAndEditPath)
async def export_presentation_as_pptx_or_pdf(
id: Annotated[uuid.UUID, Body(description="Presentation ID to export")],
export_as: Annotated[
Literal["pptx", "pdf"], Body(description="Format to export the presentation as")
] = "pptx",
sql_session: AsyncSession = Depends(get_async_session),
):
"""
Export a presentation as PPTX or PDF.
This Api is used to export via the nextjs app i.e using the puppeteer to export the presentation.
"""
presentation = await sql_session.get(PresentationModel, id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation_and_path = await export_presentation(
id,
presentation.title or str(uuid.uuid4()),
export_as,
)
return PresentationPathAndEditPath(
**presentation_and_path.model_dump(),
edit_path=f"/presentation?id={id}",
)
async def check_if_api_request_is_valid( async def check_if_api_request_is_valid(
request: GeneratePresentationRequest, request: GeneratePresentationRequest,
sql_session: AsyncSession = Depends(get_async_session), sql_session: AsyncSession = Depends(get_async_session),

View file

@ -1,198 +0,0 @@
from enum import Enum
from typing import Annotated, List, Literal, Optional, Union
from annotated_types import Len
from pydantic import BaseModel, Discriminator, field_validator
from pptx.util import Pt
from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE, MSO_CONNECTOR_TYPE
class PptxBoxShapeEnum(Enum):
RECTANGLE = "rectangle"
CIRCLE = "circle"
class PptxObjectFitEnum(Enum):
CONTAIN = "contain"
COVER = "cover"
FILL = "fill"
class PptxSpacingModel(BaseModel):
top: int = 0
bottom: int = 0
left: int = 0
right: int = 0
@classmethod
def all(cls, num: int):
return PptxSpacingModel(top=num, left=num, bottom=num, right=num)
class PptxPositionModel(BaseModel):
left: int = 0
top: int = 0
width: int = 0
height: int = 0
@classmethod
def for_textbox(cls, left: int, top: int, width: int):
return cls(left=left, top=top, width=width, height=100)
def to_pt_list(self) -> List[int]:
return [Pt(self.left), Pt(self.top), Pt(self.width), Pt(self.height)]
def to_pt_xyxy(self) -> List[int]:
return [
Pt(self.left),
Pt(self.top),
Pt(self.left + self.width),
Pt(self.top + self.height),
]
class PptxFontModel(BaseModel):
name: str = "Inter"
size: int = 16
italic: bool = False
color: str = "000000"
font_weight: Optional[int] = 400
underline: Optional[bool] = None
strike: Optional[bool] = None
class PptxFillModel(BaseModel):
color: str
opacity: float = 1.0
class PptxStrokeModel(BaseModel):
color: str
thickness: float
opacity: float = 1.0
class PptxShadowModel(BaseModel):
radius: int
offset: int = 0
color: str = "000000"
opacity: float = 0.5
angle: int = 0
class PptxTextRunModel(BaseModel):
text: str
font: Optional[PptxFontModel] = None
class PptxParagraphModel(BaseModel):
spacing: Optional[PptxSpacingModel] = None
alignment: Optional[PP_ALIGN] = None
font: Optional[PptxFontModel] = None
line_height: Optional[float] = None
text: Optional[str] = None
text_runs: Optional[List[PptxTextRunModel]] = None
class PptxObjectFitModel(BaseModel):
fit: Optional[PptxObjectFitEnum] = None
focus: Optional[
Annotated[List[Optional[float]], Len(min_length=2, max_length=2)]
] = None
class PptxPictureModel(BaseModel):
is_network: bool
path: str
class PptxShapeModel(BaseModel):
shape_type: Literal["textbox", "autoshape", "picture", "connector"]
class PptxTextBoxModel(PptxShapeModel):
shape_type: Literal["textbox"] = "textbox"
margin: Optional[PptxSpacingModel] = None
fill: Optional[PptxFillModel] = None
position: PptxPositionModel
text_wrap: bool = True
paragraphs: List[PptxParagraphModel]
class PptxAutoShapeBoxModel(PptxShapeModel):
shape_type: Literal["autoshape"] = "autoshape"
type: MSO_AUTO_SHAPE_TYPE = MSO_AUTO_SHAPE_TYPE.RECTANGLE
margin: Optional[PptxSpacingModel] = None
fill: Optional[PptxFillModel] = None
stroke: Optional[PptxStrokeModel] = None
shadow: Optional[PptxShadowModel] = None
position: PptxPositionModel
text_wrap: bool = True
border_radius: Optional[int] = None
paragraphs: Optional[List[PptxParagraphModel]] = None
@field_validator('border_radius', mode='before')
@classmethod
def convert_border_radius_to_int(cls, v):
"""Convert float border_radius values to int."""
if v is None:
return None
if isinstance(v, float):
return int(round(v))
return v
class PptxPictureBoxModel(PptxShapeModel):
shape_type: Literal["picture"] = "picture"
position: PptxPositionModel
margin: Optional[PptxSpacingModel] = None
clip: bool = True
opacity: Optional[float] = None
invert: bool = False
border_radius: Optional[List[int]] = None
shape: Optional[PptxBoxShapeEnum] = None
object_fit: Optional[PptxObjectFitModel] = None
picture: PptxPictureModel
@field_validator('border_radius', mode='before')
@classmethod
def convert_border_radius_list_to_int(cls, v):
"""Convert float values in border_radius list to int."""
if v is None:
return None
if isinstance(v, list):
return [int(round(item)) if isinstance(item, float) else int(item) for item in v]
return v
class PptxConnectorModel(PptxShapeModel):
shape_type: Literal["connector"] = "connector"
type: MSO_CONNECTOR_TYPE = MSO_CONNECTOR_TYPE.STRAIGHT
position: PptxPositionModel
thickness: float = 0.5
color: str = "000000"
opacity: float = 1.0
# Define a discriminated union for shapes
PptxShapeUnion = Annotated[
Union[
PptxTextBoxModel,
PptxAutoShapeBoxModel,
PptxConnectorModel,
PptxPictureBoxModel,
],
Discriminator("shape_type"),
]
class PptxSlideModel(BaseModel):
background: Optional[PptxFillModel] = None
note: Optional[str] = None
shapes: List[PptxShapeUnion]
class PptxPresentationModel(BaseModel):
name: Optional[str] = None
shapes: Optional[List[PptxShapeModel]] = None
slides: List[PptxSlideModel]

View file

@ -18,6 +18,5 @@ Requires-Dist: nltk>=3.9.1
Requires-Dist: openai>=1.98.0 Requires-Dist: openai>=1.98.0
Requires-Dist: pathvalidate>=3.3.1 Requires-Dist: pathvalidate>=3.3.1
Requires-Dist: pdfplumber>=0.11.7 Requires-Dist: pdfplumber>=0.11.7
Requires-Dist: python-pptx>=1.0.2
Requires-Dist: sqlmodel>=0.0.24 Requires-Dist: sqlmodel>=0.0.24
Requires-Dist: llmai==0.1.9 Requires-Dist: llmai==0.1.9

View file

@ -49,7 +49,6 @@ models/image_prompt.py
models/json_path_guide.py models/json_path_guide.py
models/ollama_model_metadata.py models/ollama_model_metadata.py
models/ollama_model_status.py models/ollama_model_status.py
models/pptx_models.py
models/presentation_and_path.py models/presentation_and_path.py
models/presentation_from_template.py models/presentation_from_template.py
models/presentation_layout.py models/presentation_layout.py
@ -81,7 +80,6 @@ services/database.py
services/document_conversion_service.py services/document_conversion_service.py
services/documents_loader.py services/documents_loader.py
services/export_task_service.py services/export_task_service.py
services/html_to_text_runs_service.py
services/icon_finder_service.py services/icon_finder_service.py
services/image_generation_service.py services/image_generation_service.py
services/liteparse_service.py services/liteparse_service.py
@ -106,7 +104,6 @@ tests/test_liteparse_service.py
tests/test_mcp_server.py tests/test_mcp_server.py
tests/test_mem0_presentation_memory_service.py tests/test_mem0_presentation_memory_service.py
tests/test_openai_schema_support.py tests/test_openai_schema_support.py
tests/test_pptx_creator.py
tests/test_pptx_slides_processing.py tests/test_pptx_slides_processing.py
tests/test_presentation_generation_api.py tests/test_presentation_generation_api.py
tests/test_slide_to_html.py tests/test_slide_to_html.py
@ -126,7 +123,6 @@ utils/get_dynamic_models.py
utils/get_env.py utils/get_env.py
utils/get_layout_by_name.py utils/get_layout_by_name.py
utils/image_provider.py utils/image_provider.py
utils/image_utils.py
utils/llm_client_error_handler.py utils/llm_client_error_handler.py
utils/llm_config.py utils/llm_config.py
utils/llm_provider.py utils/llm_provider.py
@ -153,4 +149,4 @@ utils/llm_calls/generate_slide_content.py
utils/llm_calls/select_slide_type_on_edit.py utils/llm_calls/select_slide_type_on_edit.py
utils/oauth/__init__.py utils/oauth/__init__.py
utils/oauth/openai_codex.py utils/oauth/openai_codex.py
utils/oauth/pkce.py utils/oauth/pkce.py

View file

@ -13,6 +13,5 @@ nltk>=3.9.1
openai>=1.98.0 openai>=1.98.0
pathvalidate>=3.3.1 pathvalidate>=3.3.1
pdfplumber>=0.11.7 pdfplumber>=0.11.7
python-pptx>=1.0.2
sqlmodel>=0.0.24 sqlmodel>=0.0.24
llmai==0.1.9 llmai==0.1.9

View file

@ -23,7 +23,6 @@ dependencies = [
"openai>=1.98.0", "openai>=1.98.0",
"pathvalidate>=3.3.1", "pathvalidate>=3.3.1",
"pdfplumber>=0.11.7", "pdfplumber>=0.11.7",
"python-pptx>=1.0.2",
"sqlmodel>=0.0.24", "sqlmodel>=0.0.24",
"llmai==0.1.9", "llmai==0.1.9",
] ]

View file

@ -4,7 +4,7 @@ import os
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
from typing import Mapping from typing import Literal, Mapping
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import BaseModel from pydantic import BaseModel
@ -23,6 +23,10 @@ class PptxToHtmlDocument(BaseModel):
fonts_dir: str fonts_dir: str
class PresentationExportTaskResult(BaseModel):
path: str
class ExportTaskService: class ExportTaskService:
def __init__(self, timeout_seconds: int = 300): def __init__(self, timeout_seconds: int = 300):
self.timeout_seconds = timeout_seconds self.timeout_seconds = timeout_seconds
@ -147,29 +151,24 @@ class ExportTaskService:
detail="PPTX-to-HTML task completed without a valid output path", detail="PPTX-to-HTML task completed without a valid output path",
) )
async def convert_pptx_to_html( @staticmethod
self, pptx_path: str, get_fonts: bool = False def _create_task_paths() -> tuple[str, str, str]:
) -> PptxToHtmlDocument: temp_root = get_temp_directory_env() or os.path.join(
self._ensure_runtime_ready() tempfile.gettempdir(), "presenton"
if not os.path.isfile(pptx_path): )
raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}")
temp_root = get_temp_directory_env() or os.path.join(tempfile.gettempdir(), "presenton")
os.makedirs(temp_root, exist_ok=True) os.makedirs(temp_root, exist_ok=True)
temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root) temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root)
task_path = os.path.join(temp_dir, "export_task.json") task_path = os.path.join(temp_dir, "export_task.json")
response_path = os.path.join(temp_dir, "export_task.response.json") response_path = os.path.join(temp_dir, "export_task.response.json")
return temp_dir, task_path, response_path
async def _run_task(self, task_payload: dict, response_error_detail: str) -> dict:
self._ensure_runtime_ready()
temp_dir, task_path, response_path = self._create_task_paths()
try: try:
with open(task_path, "w", encoding="utf-8") as task_file: with open(task_path, "w", encoding="utf-8") as task_file:
json.dump( json.dump(task_payload, task_file)
{
"type": "pptx-to-html",
"pptx_path": pptx_path,
"get_fonts": get_fonts,
},
task_file,
)
result = await asyncio.to_thread( result = await asyncio.to_thread(
subprocess.run, subprocess.run,
@ -185,7 +184,7 @@ class ExportTaskService:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail=( detail=(
"PPTX-to-HTML export task failed. " "Export task failed. "
f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}" f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}"
), ),
) )
@ -193,34 +192,77 @@ class ExportTaskService:
if not os.path.isfile(response_path): if not os.path.isfile(response_path):
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="PPTX-to-HTML export task did not produce a response file", detail=response_error_detail,
) )
with open(response_path, "r", encoding="utf-8") as response_file: with open(response_path, "r", encoding="utf-8") as response_file:
response_data = json.load(response_file) return json.load(response_file)
except subprocess.TimeoutExpired as exc:
raise HTTPException(
status_code=500,
detail=f"Export task timed out after {self.timeout_seconds} seconds",
) from exc
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=500,
detail="Export task produced invalid JSON output",
) from exc
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to run export task: {exc}",
) from exc
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
async def export_from_url(
self,
url: str,
title: str,
export_as: Literal["pdf", "pptx"],
fastapi_url: str | None = None,
) -> PresentationExportTaskResult:
response_data = await self._run_task(
{
"type": "export",
"url": url,
"format": export_as,
"title": title,
"fastapiUrl": fastapi_url or None,
},
"Export task did not produce a response file",
)
return PresentationExportTaskResult(
path=self._resolve_output_path(response_data),
)
async def convert_pptx_to_html(
self, pptx_path: str, get_fonts: bool = False
) -> PptxToHtmlDocument:
if not os.path.isfile(pptx_path):
raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}")
try:
response_data = await self._run_task(
{
"type": "pptx-to-html",
"pptx_path": pptx_path,
"get_fonts": get_fonts,
},
"PPTX-to-HTML export task did not produce a response file",
)
output_path = self._resolve_output_path(response_data) output_path = self._resolve_output_path(response_data)
with open(output_path, "r", encoding="utf-8") as output_file: with open(output_path, "r", encoding="utf-8") as output_file:
output_data = json.load(output_file) output_data = json.load(output_file)
return PptxToHtmlDocument(**output_data) return PptxToHtmlDocument(**output_data)
except subprocess.TimeoutExpired as exc:
raise HTTPException(
status_code=500,
detail=f"PPTX-to-HTML export timed out after {self.timeout_seconds} seconds",
) from exc
except json.JSONDecodeError as exc: except json.JSONDecodeError as exc:
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="PPTX-to-HTML export produced invalid JSON output", detail="PPTX-to-HTML export produced invalid JSON output",
) from exc ) from exc
except OSError as exc:
raise HTTPException(
status_code=500,
detail=f"Failed to run PPTX-to-HTML export task: {exc}",
) from exc
finally:
shutil.rmtree(temp_dir, ignore_errors=True)
def sys_platform() -> str: def sys_platform() -> str:

View file

@ -1,65 +0,0 @@
from html.parser import HTMLParser
from typing import List, Optional
from models.pptx_models import PptxFontModel, PptxTextRunModel
class InlineHTMLToRunsParser(HTMLParser):
def __init__(self, base_font: PptxFontModel):
super().__init__(convert_charrefs=True)
self.base_font = base_font
self.tag_stack: List[str] = []
self.text_runs: List[PptxTextRunModel] = []
def _current_font(self) -> PptxFontModel:
font_json = self.base_font.model_dump()
is_bold = any(tag in ("strong", "b") for tag in self.tag_stack)
is_italic = any(tag in ("em", "i") for tag in self.tag_stack)
is_underline = any(tag == "u" for tag in self.tag_stack)
is_strike = any(tag in ("s", "strike", "del") for tag in self.tag_stack)
is_code = any(tag == "code" for tag in self.tag_stack)
if is_bold:
font_json["font_weight"] = 700
if is_italic:
font_json["italic"] = True
if is_underline:
font_json["underline"] = True
if is_strike:
font_json["strike"] = True
if is_code:
font_json["name"] = "Courier New"
return PptxFontModel(**font_json)
def handle_starttag(self, tag, attrs):
tag = tag.lower()
if tag == "br":
self.text_runs.append(PptxTextRunModel(text="\n"))
return
self.tag_stack.append(tag)
def handle_endtag(self, tag):
tag = tag.lower()
for i in range(len(self.tag_stack) - 1, -1, -1):
if self.tag_stack[i] == tag:
del self.tag_stack[i]
break
def handle_data(self, data):
if data == "":
return
self.text_runs.append(PptxTextRunModel(text=data, font=self._current_font()))
def parse_html_text_to_text_runs(
text: str, base_font: Optional[PptxFontModel] = None
) -> List[PptxTextRunModel]:
normalized_text = text.replace("\r\n", "\n").replace("\r", "\n")
normalized_text = normalized_text.replace("\n", "<br>")
parser = InlineHTMLToRunsParser(base_font if base_font else PptxFontModel())
parser.feed(normalized_text)
return parser.text_runs

View file

@ -1,632 +0,0 @@
import os
from typing import List, Optional
from lxml import etree
from services.html_to_text_runs_service import (
parse_html_text_to_text_runs as parse_inline_html_to_runs,
)
import tempfile
import zipfile
from pptx import Presentation
from pptx.shapes.autoshape import Shape
from pptx.slide import Slide
from pptx.text.text import _Paragraph, TextFrame, Font, _Run
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
from lxml.etree import fromstring, tostring
from PIL import Image
from pptx.oxml.xmlchemy import OxmlElement
from pptx.util import Pt
from pptx.dml.color import RGBColor
from models.pptx_models import (
PptxAutoShapeBoxModel,
PptxBoxShapeEnum,
PptxConnectorModel,
PptxFillModel,
PptxFontModel,
PptxParagraphModel,
PptxPictureBoxModel,
PptxPositionModel,
PptxPresentationModel,
PptxShadowModel,
PptxSlideModel,
PptxSpacingModel,
PptxStrokeModel,
PptxTextBoxModel,
PptxTextRunModel,
)
from utils.asset_directory_utils import get_images_directory, resolve_image_path_to_filesystem
from utils.download_helpers import download_files
from utils.get_env import get_app_data_directory_env
from utils.image_utils import (
clip_image,
create_circle_image,
fit_image,
invert_image,
round_image_corners,
set_image_opacity,
)
import uuid
BLANK_SLIDE_LAYOUT = 6
class PptxPresentationCreator:
def __init__(self, ppt_model: PptxPresentationModel, temp_dir: str):
self._temp_dir = temp_dir
self._ppt_model = ppt_model
self._slide_models = ppt_model.slides
self._ppt = Presentation()
self._ppt.slide_width = Pt(1280)
self._ppt.slide_height = Pt(720)
def get_sub_element(self, parent, tagname, **kwargs):
"""Helper method to create XML elements"""
element = OxmlElement(tagname)
element.attrib.update(kwargs)
parent.append(element)
return element
def fix_keynote_compatibility(self, pptx_path: str):
"""Patch pptx XML for stricter parsers like Keynote."""
PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main"
REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
NOTES_MASTER_REL_TYPE = (
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster"
)
def ensure_grp_sppr_xfrm(slide_path: str):
slide_tree = etree.parse(slide_path)
slide_root = slide_tree.getroot()
grp_sppr_elements = slide_root.findall(
f".//{{{PRESENTATION_NS}}}grpSpPr"
)
changed = False
for grp_sppr in grp_sppr_elements:
xfrm = grp_sppr.find(f"{{{DRAWING_NS}}}xfrm")
if xfrm is None:
xfrm = etree.SubElement(grp_sppr, f"{{{DRAWING_NS}}}xfrm")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}off", x="0", y="0")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}ext", cx="0", cy="0")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chOff", x="0", y="0")
etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chExt", cx="0", cy="0")
changed = True
if changed:
slide_tree.write(
slide_path,
xml_declaration=True,
encoding="UTF-8",
standalone="yes",
)
with tempfile.TemporaryDirectory() as temp_dir:
extract_dir = os.path.join(temp_dir, "pptx_contents")
os.makedirs(extract_dir, exist_ok=True)
with zipfile.ZipFile(pptx_path, "r") as existing_zip:
existing_zip.extractall(extract_dir)
ppt_dir = os.path.join(extract_dir, "ppt")
slides_dir = os.path.join(ppt_dir, "slides")
if os.path.isdir(slides_dir):
for file_name in os.listdir(slides_dir):
if file_name.endswith(".xml"):
ensure_grp_sppr_xfrm(os.path.join(slides_dir, file_name))
rels_path = os.path.join(ppt_dir, "_rels", "presentation.xml.rels")
presentation_path = os.path.join(ppt_dir, "presentation.xml")
if os.path.exists(rels_path) and os.path.exists(presentation_path):
rels_tree = etree.parse(rels_path)
rels_root = rels_tree.getroot()
rel_tag = f"{{{PACKAGE_REL_NS}}}Relationship"
notes_master_rel = None
existing_ids = set()
for rel in rels_root.findall(rel_tag):
rel_id = rel.get("Id")
if rel_id:
existing_ids.add(rel_id)
if rel.get("Type") == NOTES_MASTER_REL_TYPE:
notes_master_rel = rel
notes_masters_dir = os.path.join(ppt_dir, "notesMasters")
has_notes_master = (
os.path.isdir(notes_masters_dir)
and any(
name.endswith(".xml") for name in os.listdir(notes_masters_dir)
)
)
if has_notes_master and notes_master_rel is None:
next_id = 1
while f"rId{next_id}" in existing_ids:
next_id += 1
notes_master_rel = etree.SubElement(rels_root, rel_tag)
notes_master_rel.set("Id", f"rId{next_id}")
notes_master_rel.set("Type", NOTES_MASTER_REL_TYPE)
notes_master_rel.set(
"Target", "notesMasters/notesMaster1.xml"
)
rels_tree.write(
rels_path,
xml_declaration=True,
encoding="UTF-8",
standalone="yes",
)
if has_notes_master and notes_master_rel is not None:
presentation_tree = etree.parse(presentation_path)
presentation_root = presentation_tree.getroot()
notes_master_id_lst = presentation_root.find(
f"{{{PRESENTATION_NS}}}notesMasterIdLst"
)
if notes_master_id_lst is None:
notes_master_id_lst = etree.Element(
f"{{{PRESENTATION_NS}}}notesMasterIdLst"
)
sld_master_id_lst = presentation_root.find(
f"{{{PRESENTATION_NS}}}sldMasterIdLst"
)
if sld_master_id_lst is not None:
insert_index = list(presentation_root).index(
sld_master_id_lst
) + 1
presentation_root.insert(insert_index, notes_master_id_lst)
else:
presentation_root.insert(0, notes_master_id_lst)
if not notes_master_id_lst.findall(
f"{{{PRESENTATION_NS}}}notesMasterId"
):
notes_master_id = etree.SubElement(
notes_master_id_lst,
f"{{{PRESENTATION_NS}}}notesMasterId",
)
notes_master_id.set(
f"{{{REL_NS}}}id",
notes_master_rel.get("Id"),
)
presentation_tree.write(
presentation_path,
xml_declaration=True,
encoding="UTF-8",
standalone="yes",
)
with zipfile.ZipFile(pptx_path, "w", zipfile.ZIP_DEFLATED) as new_zip:
for root, _, files in os.walk(extract_dir):
for file_name in files:
full_path = os.path.join(root, file_name)
archive_name = os.path.relpath(full_path, extract_dir)
new_zip.write(full_path, archive_name)
async def fetch_network_assets(self):
image_urls = []
models_with_network_asset: List[PptxPictureBoxModel] = []
def _process_image_path(each_shape, image_path):
if not image_path.startswith("http"):
return
if "app_data/" in image_path:
relative_path = image_path.split("app_data/")[1]
app_data_dir = get_app_data_directory_env()
if app_data_dir:
each_shape.picture.path = os.path.join(app_data_dir, relative_path)
else:
each_shape.picture.path = os.path.join("/app_data", relative_path)
each_shape.picture.is_network = False
return
# Resolve HTTP URLs that contain absolute filesystem paths.
local_path = resolve_image_path_to_filesystem(image_path)
if local_path:
each_shape.picture.path = local_path
each_shape.picture.is_network = False
return
image_urls.append(image_path)
models_with_network_asset.append(each_shape)
if self._ppt_model.shapes:
for each_shape in self._ppt_model.shapes:
if isinstance(each_shape, PptxPictureBoxModel):
_process_image_path(each_shape, each_shape.picture.path)
for each_slide in self._slide_models:
for each_shape in each_slide.shapes:
if isinstance(each_shape, PptxPictureBoxModel):
_process_image_path(each_shape, each_shape.picture.path)
if image_urls:
image_paths = await download_files(image_urls, self._temp_dir)
for each_shape, each_image_path in zip(
models_with_network_asset, image_paths
):
if each_image_path:
each_shape.picture.path = each_image_path
each_shape.picture.is_network = False
async def create_ppt(self):
await self.fetch_network_assets()
for slide_model in self._slide_models:
# Adding global shapes to slide
if self._ppt_model.shapes:
slide_model.shapes.append(self._ppt_model.shapes)
self.add_and_populate_slide(slide_model)
def set_presentation_theme(self):
slide_master = self._ppt.slide_master
slide_master_part = slide_master.part
theme_part = slide_master_part.part_related_by(RT.THEME)
theme = fromstring(theme_part.blob)
theme_colors = self._theme.colors.theme_color_mapping
nsmap = {"a": "http://schemas.openxmlformats.org/drawingml/2006/main"}
for color_name, hex_value in theme_colors.items():
if color_name:
color_element = theme.xpath(
f"a:themeElements/a:clrScheme/a:{color_name}/a:srgbClr",
namespaces=nsmap,
)[0]
color_element.set("val", hex_value.encode("utf-8"))
theme_part._blob = tostring(theme)
def add_and_populate_slide(self, slide_model: PptxSlideModel):
slide = self._ppt.slides.add_slide(self._ppt.slide_layouts[BLANK_SLIDE_LAYOUT])
if slide_model.background:
self.apply_fill_to_shape(slide.background, slide_model.background)
if slide_model.note:
slide.notes_slide.notes_text_frame.text = slide_model.note
for shape_model in slide_model.shapes:
model_type = type(shape_model)
if model_type is PptxPictureBoxModel:
self.add_picture(slide, shape_model)
elif model_type is PptxAutoShapeBoxModel:
self.add_autoshape(slide, shape_model)
elif model_type is PptxTextBoxModel:
self.add_textbox(slide, shape_model)
elif model_type is PptxConnectorModel:
self.add_connector(slide, shape_model)
def add_connector(self, slide: Slide, connector_model: PptxConnectorModel):
if connector_model.thickness == 0:
return
connector_shape = slide.shapes.add_connector(
connector_model.type, *connector_model.position.to_pt_xyxy()
)
connector_shape.line.width = Pt(connector_model.thickness)
connector_shape.line.color.rgb = RGBColor.from_string(connector_model.color)
self.set_fill_opacity(connector_shape, connector_model.opacity)
def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel):
image_path = picture_model.picture.path
# Resolve /app_data/... to actual filesystem path.
if image_path.startswith("/app_data/"):
app_data_dir = get_app_data_directory_env()
if app_data_dir:
relative = image_path[len("/app_data/"):]
image_path = os.path.join(app_data_dir, relative)
if (
picture_model.clip
or picture_model.border_radius
or picture_model.invert
or picture_model.opacity
or picture_model.object_fit
or picture_model.shape
):
try:
image = Image.open(image_path)
except Exception:
print(f"Could not open image: {image_path}")
return
image = image.convert("RGBA")
# ? Applying border radius twice to support both clip and object fit
if picture_model.border_radius:
image = round_image_corners(image, picture_model.border_radius)
if picture_model.object_fit:
image = fit_image(
image,
picture_model.position.width,
picture_model.position.height,
picture_model.object_fit,
)
elif picture_model.clip:
image = clip_image(
image,
picture_model.position.width,
picture_model.position.height,
)
if picture_model.border_radius:
image = round_image_corners(image, picture_model.border_radius)
if picture_model.shape == PptxBoxShapeEnum.CIRCLE:
image = create_circle_image(image)
if picture_model.invert:
image = invert_image(image)
if picture_model.opacity:
image = set_image_opacity(image, picture_model.opacity)
image_path = os.path.join(self._temp_dir, f"{uuid.uuid4()}.png")
image.save(image_path)
margined_position = self.get_margined_position(
picture_model.position, picture_model.margin
)
slide.shapes.add_picture(image_path, *margined_position.to_pt_list())
def add_autoshape(self, slide: Slide, autoshape_box_model: PptxAutoShapeBoxModel):
position = autoshape_box_model.position
if autoshape_box_model.margin:
position = self.get_margined_position(position, autoshape_box_model.margin)
autoshape = slide.shapes.add_shape(
autoshape_box_model.type, *position.to_pt_list()
)
textbox = autoshape.text_frame
textbox.word_wrap = autoshape_box_model.text_wrap
self.apply_fill_to_shape(autoshape, autoshape_box_model.fill)
self.apply_margin_to_text_box(textbox, autoshape_box_model.margin)
self.apply_stroke_to_shape(autoshape, autoshape_box_model.stroke)
self.apply_shadow_to_shape(autoshape, autoshape_box_model.shadow)
self.apply_border_radius_to_shape(autoshape, autoshape_box_model.border_radius)
if autoshape_box_model.paragraphs:
self.add_paragraphs(textbox, autoshape_box_model.paragraphs)
def add_textbox(self, slide: Slide, textbox_model: PptxTextBoxModel):
position = textbox_model.position
textbox_shape = slide.shapes.add_textbox(*position.to_pt_list())
textbox_shape.width += Pt(2)
textbox = textbox_shape.text_frame
textbox.word_wrap = textbox_model.text_wrap
self.apply_fill_to_shape(textbox_shape, textbox_model.fill)
self.apply_margin_to_text_box(textbox, textbox_model.margin)
self.add_paragraphs(textbox, textbox_model.paragraphs)
def add_paragraphs(
self, textbox: TextFrame, paragraph_models: List[PptxParagraphModel]
):
for index, paragraph_model in enumerate(paragraph_models):
paragraph = textbox.add_paragraph() if index > 0 else textbox.paragraphs[0]
self.populate_paragraph(paragraph, paragraph_model)
def populate_paragraph(
self, paragraph: _Paragraph, paragraph_model: PptxParagraphModel
):
if paragraph_model.spacing:
self.apply_spacing_to_paragraph(paragraph, paragraph_model.spacing)
if paragraph_model.line_height:
paragraph.line_spacing = paragraph_model.line_height
if paragraph_model.alignment:
paragraph.alignment = paragraph_model.alignment
if paragraph_model.font:
self.apply_font_to_paragraph(paragraph, paragraph_model.font)
text_runs = []
if paragraph_model.text:
text_runs = self.parse_html_text_to_text_runs(
paragraph_model.font, paragraph_model.text
)
elif paragraph_model.text_runs:
text_runs = paragraph_model.text_runs
for text_run_model in text_runs:
text_run = paragraph.add_run()
self.populate_text_run(text_run, text_run_model)
def parse_html_text_to_text_runs(self, font: Optional[PptxFontModel], text: str):
return parse_inline_html_to_runs(text, font)
def populate_text_run(self, text_run: _Run, text_run_model: PptxTextRunModel):
text_run.text = text_run_model.text
if text_run_model.font:
self.apply_font(text_run.font, text_run_model.font)
def apply_border_radius_to_shape(self, shape: Shape, border_radius: Optional[int]):
if not border_radius:
return
try:
normalized_border_radius = Pt(border_radius) / min(
shape.width, shape.height
)
shape.adjustments[0] = normalized_border_radius
except Exception:
print("Could not apply border radius.")
def apply_fill_to_shape(self, shape: Shape, fill: Optional[PptxFillModel] = None):
if not fill:
shape.fill.background()
else:
shape.fill.solid()
shape.fill.fore_color.rgb = RGBColor.from_string(fill.color)
self.set_fill_opacity(shape.fill, fill.opacity)
def apply_stroke_to_shape(
self, shape: Shape, stroke: Optional[PptxStrokeModel] = None
):
if not stroke or stroke.thickness == 0:
shape.line.fill.background()
else:
shape.line.fill.solid()
shape.line.fill.fore_color.rgb = RGBColor.from_string(stroke.color)
shape.line.width = Pt(stroke.thickness)
self.set_fill_opacity(shape.line.fill, stroke.opacity)
def apply_shadow_to_shape(
self, shape: Shape, shadow: Optional[PptxShadowModel] = None
):
# Access the XML for the shape
sp_element = shape._element
sp_pr = sp_element.xpath("p:spPr")[0] # Shape properties XML element
nsmap = sp_pr.nsmap
# # Remove existing shadow effects if present
effect_list = sp_pr.find("a:effectLst", namespaces=nsmap)
if effect_list:
old_outer_shadow = effect_list.find("a:outerShdw")
if old_outer_shadow:
effect_list.remove(
old_outer_shadow, namespaces=nsmap
) # Remove the old shadow
old_inner_shadow = effect_list.find("a:innerShdw")
if old_inner_shadow:
effect_list.remove(
old_inner_shadow, namespaces=nsmap
) # Remove the old shadow
old_prst_shadow = effect_list.find("a:prstShdw")
if old_prst_shadow:
effect_list.remove(
old_prst_shadow, namespaces=nsmap
) # Remove the old shadow
if not effect_list:
effect_list = etree.SubElement(
sp_pr, f"{{{nsmap['a']}}}effectLst", nsmap=nsmap
)
if shadow is None:
# Apply shadow with zero values when shadow is None
outer_shadow = etree.SubElement(
effect_list,
f"{{{nsmap['a']}}}outerShdw",
{
"blurRad": "0",
"dist": "0",
"dir": "0",
},
nsmap=nsmap,
)
color_element = etree.SubElement(
outer_shadow,
f"{{{nsmap['a']}}}srgbClr",
{"val": "000000"},
nsmap=nsmap,
)
etree.SubElement(
color_element,
f"{{{nsmap['a']}}}alpha",
{"val": "0"},
nsmap=nsmap,
)
else:
# Apply the provided shadow
# dir expects 60000ths of a degree in OOXML
angle_dir = (
int(round((shadow.angle % 360) * 60000))
if shadow.angle is not None
else 0
)
outer_shadow = etree.SubElement(
effect_list,
f"{{{nsmap['a']}}}outerShdw",
{
"blurRad": f"{Pt(shadow.radius)}",
"dir": f"{angle_dir}",
"dist": f"{Pt(shadow.offset)}",
"rotWithShape": "0",
},
nsmap=nsmap,
)
color_element = etree.SubElement(
outer_shadow,
f"{{{nsmap['a']}}}srgbClr",
{"val": f"{shadow.color}"},
nsmap=nsmap,
)
etree.SubElement(
color_element,
f"{{{nsmap['a']}}}alpha",
{"val": f"{int(shadow.opacity * 100000)}"},
nsmap=nsmap,
)
def set_fill_opacity(self, fill, opacity):
if opacity is None or opacity >= 1.0:
return
alpha = int((opacity) * 100000)
try:
ts = fill._xPr.solidFill
sF = ts.get_or_change_to_srgbClr()
self.get_sub_element(sF, "a:alpha", val=str(alpha))
except Exception as e:
print(f"Could not set fill opacity: {e}")
def get_margined_position(
self, position: PptxPositionModel, margin: Optional[PptxSpacingModel]
) -> PptxPositionModel:
if not margin:
return position
left = position.left + margin.left
top = position.top + margin.top
width = max(position.width - margin.left - margin.right, 0)
height = max(position.height - margin.top - margin.bottom, 0)
return PptxPositionModel(left=left, top=top, width=width, height=height)
def apply_margin_to_text_box(
self, text_frame: TextFrame, margin: Optional[PptxSpacingModel]
) -> PptxPositionModel:
text_frame.margin_left = Pt(margin.left if margin else 0)
text_frame.margin_right = Pt(margin.right if margin else 0)
text_frame.margin_top = Pt(margin.top if margin else 0)
text_frame.margin_bottom = Pt(margin.bottom if margin else 0)
def apply_spacing_to_paragraph(
self, paragraph: _Paragraph, spacing: PptxSpacingModel
):
paragraph.space_before = Pt(spacing.top)
paragraph.space_after = Pt(spacing.bottom)
def apply_font_to_paragraph(self, paragraph: _Paragraph, font: PptxFontModel):
self.apply_font(paragraph.font, font)
def apply_font(self, font: Font, font_model: PptxFontModel):
font.name = font_model.name
font.color.rgb = RGBColor.from_string(font_model.color)
font.italic = font_model.italic
font.size = Pt(font_model.size)
font.bold = font_model.font_weight >= 600
if font_model.underline is not None:
font.underline = bool(font_model.underline)
if font_model.strike is not None:
self.apply_strike_to_font(font, font_model.strike)
def apply_strike_to_font(self, font: Font, strike: Optional[bool]):
try:
rPr = font._element
if strike is True:
rPr.set("strike", "sngStrike")
elif strike is False:
rPr.set("strike", "noStrike")
except Exception as e:
print(f"Could not apply strikethrough: {e}")
def save(self, path: str):
self._ppt.save(path)
self.fix_keynote_compatibility(path)

View file

@ -1,40 +0,0 @@
import asyncio
from models.pptx_models import (
PptxAutoShapeBoxModel,
PptxFillModel,
PptxPositionModel,
PptxPresentationModel,
PptxSlideModel,
)
from services.pptx_presentation_creator import PptxPresentationCreator
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
pptx_model = PptxPresentationModel(
slides=[
PptxSlideModel(
shapes=[
PptxAutoShapeBoxModel(
type=MSO_AUTO_SHAPE_TYPE.RECTANGLE,
position=PptxPositionModel(
left=20,
right=20,
width=100,
height=100,
),
fill=PptxFillModel(
color="000000",
opacity=0.5,
),
)
]
)
]
)
def test_pptx_creator():
temp_dir = "/tmp/presenton"
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
asyncio.run(pptx_creator.create_ppt())
pptx_creator.save("debug/test.pptx")

View file

@ -1,67 +1,47 @@
import json
import os import os
import aiohttp
from typing import Literal from typing import Literal
from urllib.parse import urlencode
import uuid import uuid
from fastapi import HTTPException
from pathvalidate import sanitize_filename from pathvalidate import sanitize_filename
from models.pptx_models import PptxPresentationModel
from models.presentation_and_path import PresentationAndPath from models.presentation_and_path import PresentationAndPath
from services.pptx_presentation_creator import PptxPresentationCreator from services.export_task_service import EXPORT_TASK_SERVICE
from services.temp_file_service import TEMP_FILE_SERVICE
from utils.asset_directory_utils import get_exports_directory
import uuid def _get_next_public_url() -> str:
return (os.getenv("NEXT_PUBLIC_URL") or "").strip() or "http://127.0.0.1"
def _get_next_public_fastapi_url() -> str | None:
value = (os.getenv("NEXT_PUBLIC_FAST_API") or "").strip()
return value or None
def _build_presentation_export_url(presentation_id: uuid.UUID) -> tuple[str, str | None]:
params = {"id": str(presentation_id)}
fastapi_url = _get_next_public_fastapi_url()
if fastapi_url:
params["fastapiUrl"] = fastapi_url
return (
f"{_get_next_public_url().rstrip('/')}/pdf-maker?{urlencode(params)}",
fastapi_url,
)
async def export_presentation( async def export_presentation(
presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"] presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"]
) -> PresentationAndPath: ) -> PresentationAndPath:
if export_as == "pptx": export_url, fastapi_url = _build_presentation_export_url(presentation_id)
export_result = await EXPORT_TASK_SERVICE.export_from_url(
url=export_url,
title=sanitize_filename(title or str(uuid.uuid4())),
export_as=export_as,
fastapi_url=fastapi_url,
)
# Get the converted PPTX model from the Next.js service return PresentationAndPath(
async with aiohttp.ClientSession() as session: presentation_id=presentation_id,
async with session.get( path=export_result.path,
f"http://localhost/api/presentation_to_pptx_model?id={presentation_id}" )
) as response:
if response.status != 200:
error_text = await response.text()
print(f"Failed to get PPTX model: {error_text}")
raise HTTPException(
status_code=500,
detail="Failed to convert presentation to PPTX model",
)
pptx_model_data = await response.json()
# Create PPTX file using the converted model
pptx_model = PptxPresentationModel(**pptx_model_data)
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
await pptx_creator.create_ppt()
export_directory = get_exports_directory()
pptx_path = os.path.join(
export_directory,
f"{sanitize_filename(title or str(uuid.uuid4()))}.pptx",
)
pptx_creator.save(pptx_path)
return PresentationAndPath(
presentation_id=presentation_id,
path=pptx_path,
)
else:
async with aiohttp.ClientSession() as session:
async with session.post(
"http://localhost/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"],
)

View file

@ -1,258 +0,0 @@
from typing import List
from PIL import Image, ImageDraw
from models.pptx_models import PptxObjectFitEnum, PptxObjectFitModel
def clip_image(
image: Image.Image,
width: int,
height: int,
focus_x: float = 50.0,
focus_y: float = 50.0,
) -> Image.Image:
img_width, img_height = image.size
img_aspect = img_width / img_height
box_aspect = width / height
if img_aspect > box_aspect:
new_height = height
new_width = int(new_height * img_aspect)
else:
new_width = width
new_height = int(new_width / img_aspect)
resized_image = image.resize((new_width, new_height), Image.LANCZOS)
# Calculate clipping position based on focus
# Convert focus percentages (0-100) to position in the resized image
focus_x = max(0.0, min(100.0, focus_x)) # Clamp to 0-100 range
focus_y = max(0.0, min(100.0, focus_y)) # Clamp to 0-100 range
# Calculate the center point based on focus
center_x = int((new_width - width) * (focus_x / 100.0))
center_y = int((new_height - height) * (focus_y / 100.0))
# Calculate clipping box
left = center_x
top = center_y
right = left + width
bottom = top + height
clipped_image = resized_image.crop((left, top, right, bottom))
return clipped_image
def round_image_corners(image: Image.Image, radii: List[int]) -> Image.Image:
if len(radii) != 4:
raise ValueError(
"Image Border Radius - radii must contain exactly 4 values for each corner"
)
w, h = image.size
# Clamp border radius to not exceed half the width or height
max_radius = min(w // 2, h // 2)
clamped_radii = [min(radius, max_radius) for radius in radii]
# Ensure the image has an alpha channel (RGBA)
if image.mode != "RGBA":
image = image.convert("RGBA")
# Create a mask for the rounded corners (start with fully transparent)
rounded_mask = Image.new("L", image.size, 0)
# Create a rectangular mask (fully opaque)
rectangular_mask = Image.new("L", image.size, 255)
# Process each corner
for i, radius in enumerate(clamped_radii):
if radius > 0: # Only process if radius is positive
# Create a circle for this radius
circle = Image.new("L", (radius * 2, radius * 2), 0)
draw = ImageDraw.Draw(circle)
draw.ellipse((0, 0, radius * 2 - 1, radius * 2 - 1), fill=255)
# Calculate position based on corner index
if i == 0: # top-left
rounded_mask.paste(circle.crop((0, 0, radius, radius)), (0, 0))
rectangular_mask.paste(0, (0, 0, radius, radius))
elif i == 1: # top-right
rounded_mask.paste(
circle.crop((radius, 0, radius * 2, radius)), (w - radius, 0)
)
rectangular_mask.paste(0, (w - radius, 0, w, radius))
elif i == 2: # bottom-right
rounded_mask.paste(
circle.crop((radius, radius, radius * 2, radius * 2)),
(w - radius, h - radius),
)
rectangular_mask.paste(0, (w - radius, h - radius, w, h))
else: # bottom-left
rounded_mask.paste(
circle.crop((0, radius, radius, radius * 2)), (0, h - radius)
)
rectangular_mask.paste(0, (0, h - radius, radius, h))
# Get the original alpha channel
original_alpha = image.getchannel("A")
# Combine the rectangular mask with the rounded corners
corner_mask = Image.composite(rounded_mask, rectangular_mask, rounded_mask)
# Combine the corner mask with the original alpha channel
final_alpha = Image.composite(
original_alpha, Image.new("L", image.size, 0), corner_mask
)
# Create a new image with the modified alpha channel
result = Image.new("RGBA", image.size)
result.paste(image.convert("RGB"), (0, 0))
result.putalpha(final_alpha)
return result
def invert_image(img: Image.Image) -> Image.Image:
# Get image data
data = img.getdata()
# Process each pixel
new_data = []
for item in data:
# Get current pixel values
r, g, b, a = item
# Invert RGB values while preserving transparency
if a != 0: # Skip fully transparent pixels
new_data.append((255 - r, 255 - g, 255 - b, a))
else:
new_data.append((0, 0, 0, 0))
# Create new image with modified data
new_img = Image.new("RGBA", img.size)
new_img.putdata(new_data)
return new_img
def create_circle_image(
image: Image.Image,
) -> Image.Image:
# Convert to RGBA if not already
img = image.convert("RGBA")
# Get the original image size
size = img.size
# Use the smaller dimension for the circle
circle_size = min(size)
# Create a transparent image of the same size as original
mask = Image.new("RGBA", size, color=(0, 0, 0, 0))
draw = ImageDraw.Draw(mask)
# Calculate center position
center_x = size[0] // 2
center_y = size[1] // 2
radius = circle_size // 2
# Create a circular mask
draw.ellipse(
(
center_x - radius,
center_y - radius,
center_x + radius,
center_y + radius,
),
fill=(255, 255, 255, 255),
)
# Apply the circular mask
result = Image.composite(img, mask, mask)
return result
def set_image_opacity(image: Image.Image, opacity: float) -> Image.Image:
# Clamp opacity to valid range
opacity = max(0.0, min(1.0, opacity))
# Convert to RGBA if not already
if image.mode != "RGBA":
image = image.convert("RGBA")
# Get the original alpha channel
original_alpha = image.getchannel("A")
# Create new alpha channel with adjusted opacity
new_alpha = original_alpha.point(lambda x: int(x * opacity))
# Create new image with modified alpha channel
result = Image.new("RGBA", image.size)
result.paste(image.convert("RGB"), (0, 0))
result.putalpha(new_alpha)
return result
def fit_image(
image: Image.Image, width: int, height: int, object_fit: PptxObjectFitModel
) -> Image.Image:
if not object_fit.fit:
return image
img_width, img_height = image.size
img_aspect = img_width / img_height
box_aspect = width / height
if object_fit.fit == PptxObjectFitEnum.CONTAIN:
# Scale image to fit within the box while maintaining aspect ratio
if img_aspect > box_aspect:
new_width = width
new_height = int(width / img_aspect)
else:
new_height = height
new_width = int(height * img_aspect)
resized_image = image.resize((new_width, new_height), Image.LANCZOS)
# Use focus point for positioning if available
focus_x = 50.0
focus_y = 50.0
if object_fit.focus and len(object_fit.focus) == 2:
focus_x, focus_y = object_fit.focus[0], object_fit.focus[1]
# Calculate paste position based on focus
paste_x = int((width - new_width) * (focus_x / 100.0))
paste_y = int((height - new_height) * (focus_y / 100.0))
result = Image.new("RGBA", (width, height), (0, 0, 0, 0))
result.paste(resized_image, (paste_x, paste_y))
return result
elif object_fit.fit == PptxObjectFitEnum.COVER:
# Scale image to cover the box while maintaining aspect ratio
if img_aspect > box_aspect:
new_height = height
new_width = int(height * img_aspect)
else:
new_width = width
new_height = int(width / img_aspect)
resized_image = image.resize((new_width, new_height), Image.LANCZOS)
# Use focus point for positioning if available
focus_x = 50.0
focus_y = 50.0
if object_fit.focus and len(object_fit.focus) == 2:
focus_x, focus_y = object_fit.focus[0], object_fit.focus[1]
# Calculate paste position based on focus
paste_x = int((new_width - width) * (focus_x / 100.0))
paste_y = int((new_height - height) * (focus_y / 100.0))
# Clip the image to the box size
return resized_image.crop((paste_x, paste_y, paste_x + width, paste_y + height))
elif object_fit.fit == PptxObjectFitEnum.FILL:
# Stretch image to fill the box exactly
return image.resize((width, height), Image.LANCZOS)
return image

View file

@ -1188,23 +1188,16 @@ wheels = [
[[package]] [[package]]
name = "llmai" name = "llmai"
version = "0.1.9" version = "0.1.9"
source = { url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "anthropic" }, { name = "anthropic" },
{ name = "boto3" }, { name = "boto3" },
{ name = "google-genai" }, { name = "google-genai" },
{ name = "openai" }, { name = "openai" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/f9/dd/dc7cb70fb5f9b33abf457b2bded61f27189232e769badc065ca0e2d1cda2/llmai-0.1.9.tar.gz", hash = "sha256:00ee4b987dc07a65425a1296df937d7640541630fd347ca758ea1ed496880e67", size = 46798, upload-time = "2026-04-23T07:34:49.975Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl", hash = "sha256:dcd94502516586bbd6394fe2c9c610941ff4c19eae0f1316825435f35134cfb4" }, { url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl", hash = "sha256:dcd94502516586bbd6394fe2c9c610941ff4c19eae0f1316825435f35134cfb4", size = 58968, upload-time = "2026-04-23T07:34:48.375Z" },
]
[package.metadata]
requires-dist = [
{ name = "anthropic", specifier = ">=0.79.0" },
{ name = "boto3", specifier = ">=1.42.89" },
{ name = "google-genai", specifier = ">=1.62.0" },
{ name = "openai", specifier = ">=2.18.0" },
] ]
[[package]] [[package]]
@ -1220,36 +1213,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
] ]
[[package]]
name = "lxml"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/5d/3bccad330292946f97962df9d5f2d3ae129cce6e212732a781e856b91e07/lxml-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cec05be8c876f92a5aa07b01d60bbb4d11cfbdd654cad0561c0d7b5c043a61b9", size = 8526232, upload-time = "2026-04-18T04:27:40.389Z" },
{ url = "https://files.pythonhosted.org/packages/a7/51/adc8826570a112f83bb4ddb3a2ab510bbc2ccd62c1b9fe1f34fae2d90b57/lxml-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c03e048b6ce8e77b09c734e931584894ecd58d08296804ca2d0b184c933ce50", size = 4595448, upload-time = "2026-04-18T04:27:44.208Z" },
{ url = "https://files.pythonhosted.org/packages/54/84/5a9ec07cbe1d2334a6465f863b949a520d2699a755738986dcd3b6b89e3f/lxml-6.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:942454ff253da14218f972b23dc72fa4edf6c943f37edd19cd697618b626fac5", size = 4923771, upload-time = "2026-04-18T04:32:17.402Z" },
{ url = "https://files.pythonhosted.org/packages/a7/23/851cfa33b6b38adb628e45ad51fb27105fa34b2b3ba9d1d4aa7a9428dfe0/lxml-6.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d036ee7b99d5148072ac7c9b847193decdfeac633db350363f7bce4fff108f0e", size = 5068101, upload-time = "2026-04-18T04:32:21.437Z" },
{ url = "https://files.pythonhosted.org/packages/b0/38/41bf99c2023c6b79916ba057d83e9db21d642f473cac210201222882d38b/lxml-6.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ae5d8d5427f3cc317e7950f2da7ad276df0cfa37b8de2f5658959e618ea8512", size = 5002573, upload-time = "2026-04-18T04:32:25.373Z" },
{ url = "https://files.pythonhosted.org/packages/c2/20/053aa10bdc39747e1e923ce2d45413075e84f70a136045bb09e5eaca41d3/lxml-6.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:363e47283bde87051b821826e71dde47f107e08614e1aa312ba0c5711e77738c", size = 5202816, upload-time = "2026-04-18T04:32:29.393Z" },
{ url = "https://files.pythonhosted.org/packages/9a/da/bc710fad8bf04b93baee752c192eaa2210cd3a84f969d0be7830fea55802/lxml-6.1.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f504d861d9f2a8f94020130adac88d66de93841707a23a86244263d1e54682f5", size = 5329999, upload-time = "2026-04-18T04:32:34.019Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/bf035dedbdf7fab49411aa52e4236f3445e98d38647d85419e6c0d2806b9/lxml-6.1.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:23a5dc68e08ed13331d61815c08f260f46b4a60fdd1640bbeb82cf89a9d90289", size = 4659643, upload-time = "2026-04-18T04:32:37.932Z" },
{ url = "https://files.pythonhosted.org/packages/5c/4f/22be31f33727a5e4c7b01b0a874503026e50329b259d3587e0b923cf964b/lxml-6.1.0-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f15401d8d3dbf239e23c818afc10c7207f7b95f9a307e092122b6f86dd43209a", size = 5265963, upload-time = "2026-04-18T04:32:41.881Z" },
{ url = "https://files.pythonhosted.org/packages/c8/2b/d44d0e5c79226017f4ab8c87a802ebe4f89f97e6585a8e4166dffcdd7b6e/lxml-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fcf3da95e93349e0647d48d4b36a12783105bcc74cb0c416952f9988410846a3", size = 5045444, upload-time = "2026-04-18T04:32:44.512Z" },
{ url = "https://files.pythonhosted.org/packages/d3/c3/3f034fec1594c331a6dbf9491238fdcc9d66f68cc529e109ec75b97197e1/lxml-6.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0d082495c5fcf426e425a6e28daaba1fcb6d8f854a4ff01effb1f1f381203eb9", size = 4712703, upload-time = "2026-04-18T04:32:47.16Z" },
{ url = "https://files.pythonhosted.org/packages/12/16/0b83fccc158218aca75a7aa33e97441df737950734246b9fffa39301603d/lxml-6.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e3c4f84b24a1fcba435157d111c4b755099c6ff00a3daee1ad281817de75ed11", size = 5252745, upload-time = "2026-04-18T04:32:50.427Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ee/12e6c1b39a77666c02eaa77f94a870aaf63c4ac3a497b2d52319448b01c6/lxml-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976a6b39b1b13e8c354ad8d3f261f3a4ac6609518af91bdb5094760a08f132c4", size = 5226822, upload-time = "2026-04-18T04:32:53.437Z" },
{ url = "https://files.pythonhosted.org/packages/34/20/c7852904858b4723af01d2fc14b5d38ff57cb92f01934a127ebd9a9e51aa/lxml-6.1.0-cp311-cp311-win32.whl", hash = "sha256:857efde87d365706590847b916baff69c0bc9252dc5af030e378c9800c0b10e3", size = 3594026, upload-time = "2026-04-18T04:27:31.903Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/d60c732b56da5085175c07c74b2df4e6d181b0c9a61e1691474f06ef4b39/lxml-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:183bfb45a493081943be7ea2b5adfc2b611e1cf377cefa8b8a8be404f45ef9a7", size = 4025114, upload-time = "2026-04-18T04:27:34.077Z" },
{ url = "https://files.pythonhosted.org/packages/c2/df/c84dcc175fd690823436d15b41cb920cd5ba5e14cd8bfb00949d5903b320/lxml-6.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:19f4164243fc206d12ed3d866e80e74f5bc3627966520da1a5f97e42c32a3f39", size = 3667742, upload-time = "2026-04-18T04:27:38.45Z" },
{ url = "https://files.pythonhosted.org/packages/f2/88/55143966481409b1740a3ac669e611055f49efd68087a5ce41582325db3e/lxml-6.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:546b66c0dd1bb8d9fa89d7123e5fa19a8aff3a1f2141eb22df96112afb17b842", size = 3930134, upload-time = "2026-04-18T04:32:35.008Z" },
{ url = "https://files.pythonhosted.org/packages/b5/97/28b985c2983938d3cb696dd5501423afb90a8c3e869ef5d3c62569282c0f/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfa1a34df366d9dc0d5eaf420f4cf2bb1e1bebe1066d1c2fc28c179f8a4004c", size = 4210749, upload-time = "2026-04-18T04:36:03.626Z" },
{ url = "https://files.pythonhosted.org/packages/29/67/dfab2b7d58214921935ccea7ce9b3df9b7d46f305d12f0f532ac7cf6b804/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db88156fcf544cdbf0d95588051515cfdfd4c876fc66444eb98bceb5d6db76de", size = 4318463, upload-time = "2026-04-18T04:36:06.309Z" },
{ url = "https://files.pythonhosted.org/packages/32/a2/4ac7eb32a4d997dd352c32c32399aae27b3f268d440e6f9cfa405b575d2f/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07f98f5496f96bf724b1e3c933c107f0cbf2745db18c03d2e13a291c3afd2635", size = 4251124, upload-time = "2026-04-18T04:36:09.056Z" },
{ url = "https://files.pythonhosted.org/packages/33/ef/d6abd850bb4822f9b720cfe36b547a558e694881010ff7d012191e8769c6/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4642e04449a1e164b5ff71ffd901ddb772dfabf5c9adf1b7be5dffe1212bc037", size = 4401758, upload-time = "2026-04-18T04:36:11.803Z" },
{ url = "https://files.pythonhosted.org/packages/40/44/3ee09a5b60cb44c4f2fbc1c9015cfd6ff5afc08f991cab295d3024dcbf2d/lxml-6.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace", size = 3508860, upload-time = "2026-04-18T04:32:48.619Z" },
]
[[package]] [[package]]
name = "mako" name = "mako"
version = "1.3.11" version = "1.3.11"
@ -1677,7 +1640,6 @@ dependencies = [
{ name = "openai" }, { name = "openai" },
{ name = "pathvalidate" }, { name = "pathvalidate" },
{ name = "pdfplumber" }, { name = "pdfplumber" },
{ name = "python-pptx" },
{ name = "sqlmodel" }, { name = "sqlmodel" },
] ]
@ -1693,13 +1655,12 @@ requires-dist = [
{ name = "fastembed-vectorstore", specifier = ">=0.5.2" }, { name = "fastembed-vectorstore", specifier = ">=0.5.2" },
{ name = "fastmcp", specifier = ">=2.11.0" }, { name = "fastmcp", specifier = ">=2.11.0" },
{ name = "google-genai", specifier = ">=1.28.0" }, { name = "google-genai", specifier = ">=1.28.0" },
{ name = "llmai", url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl" }, { name = "llmai", specifier = "==0.1.9" },
{ name = "mem0ai", extras = ["nlp"], specifier = ">=0.1.115" }, { name = "mem0ai", extras = ["nlp"], specifier = ">=0.1.115" },
{ name = "nltk", specifier = ">=3.9.1" }, { name = "nltk", specifier = ">=3.9.1" },
{ name = "openai", specifier = ">=1.98.0" }, { name = "openai", specifier = ">=1.98.0" },
{ name = "pathvalidate", specifier = ">=3.3.1" }, { name = "pathvalidate", specifier = ">=3.3.1" },
{ name = "pdfplumber", specifier = ">=0.11.7" }, { name = "pdfplumber", specifier = ">=0.11.7" },
{ name = "python-pptx", specifier = ">=1.0.2" },
{ name = "sqlmodel", specifier = ">=0.0.24" }, { name = "sqlmodel", specifier = ">=0.0.24" },
] ]
@ -2020,21 +1981,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" }, { url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
] ]
[[package]]
name = "python-pptx"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "lxml" },
{ name = "pillow" },
{ name = "typing-extensions" },
{ name = "xlsxwriter" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" },
]
[[package]] [[package]]
name = "pytz" name = "pytz"
version = "2026.1.post1" version = "2026.1.post1"
@ -2773,15 +2719,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
] ]
[[package]]
name = "xlsxwriter"
version = "3.2.9"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
]
[[package]] [[package]]
name = "yarl" name = "yarl"
version = "1.23.0" version = "1.23.0"

View file

@ -25,7 +25,6 @@ import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store"; import { RootState } from "@/store/store";
import { toast } from "sonner"; import { toast } from "sonner";
import { PptxPresentationModel } from "@/types/pptx_models";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo"; import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
import ToolTip from "@/components/ToolTip"; import ToolTip from "@/components/ToolTip";
@ -181,12 +180,6 @@ const PresentationHeader = ({
titleBlurIntentRef.current = "cancel"; titleBlurIntentRef.current = "cancel";
}; };
const get_presentation_pptx_model = async (id: string): Promise<PptxPresentationModel> => {
const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`);
const pptx_model = await response.json();
return pptx_model;
};
const handleExportPptx = async () => { const handleExportPptx = async () => {
if (isStreaming) return; if (isStreaming) return;
@ -201,26 +194,30 @@ const PresentationHeader = ({
setIsExporting(true); setIsExporting(true);
// Save the presentation data before exporting // Save the presentation data before exporting
await PresentationGenerationApi.updatePresentationContent(presentationData); await PresentationGenerationApi.updatePresentationContent(presentationData);
const pptx_model = await get_presentation_pptx_model(presentation_id);
if (!pptx_model) {
throw new Error("Failed to get presentation PPTX model");
}
const safePptxFileName = buildSafeExportFileName( const safePptxFileName = buildSafeExportFileName(
presentationData?.title, presentationData?.title,
"pptx" "pptx"
); );
const safePptxTitle = safePptxFileName.replace(/\.pptx$/i, ""); const safePptxTitle = safePptxFileName.replace(/\.pptx$/i, "");
const pptx_path = await PresentationGenerationApi.exportAsPPTX({ const response = await fetch("/api/export-presentation", {
...pptx_model, method: "POST",
name: safePptxTitle, body: JSON.stringify({
format: "pptx",
id: presentation_id,
title: safePptxTitle,
}),
}); });
if (pptx_path) {
// window.open(pptx_path, '_self'); if (!response.ok) {
downloadLink(pptx_path, safePptxFileName); throw new Error("Failed to export PPTX");
} else { }
const { path: pptxPath } = await response.json();
if (!pptxPath) {
throw new Error("No path returned from export"); throw new Error("No path returned from export");
} }
downloadLink(pptxPath, safePptxFileName);
} catch (error) { } catch (error) {
console.error("Export failed:", error); console.error("Export failed:", error);
toast.error("Having trouble exporting!", { toast.error("Having trouble exporting!", {
@ -251,17 +248,17 @@ const PresentationHeader = ({
"pdf" "pdf"
); );
const safePdfTitle = safePdfFileName.replace(/\.pdf$/i, ""); const safePdfTitle = safePdfFileName.replace(/\.pdf$/i, "");
const response = await fetch('/api/export-as-pdf', { const response = await fetch("/api/export-presentation", {
method: 'POST', method: "POST",
body: JSON.stringify({ body: JSON.stringify({
format: "pdf",
id: presentation_id, id: presentation_id,
title: safePdfTitle, title: safePdfTitle,
}) }),
}); });
if (response.ok) { if (response.ok) {
const { path: pdfPath } = await response.json(); const { path: pdfPath } = await response.json();
// window.open(pdfPath, '_blank');
downloadLink(pdfPath, safePdfFileName); downloadLink(pdfPath, safePdfFileName);
} else { } else {
throw new Error("Failed to export PDF"); throw new Error("Failed to export PDF");

View file

@ -226,27 +226,4 @@ export class PresentationGenerationApi {
} }
} }
}
// EXPORT PRESENTATION
static async exportAsPPTX(presentationData: any) {
try {
const response = await fetch(
getApiUrl(`/api/v1/ppt/presentation/export/pptx`),
{
method: "POST",
headers: getHeader(),
body: JSON.stringify(presentationData),
cache: "no-cache",
}
);
return await ApiResponseHandler.handleResponse(response, "Failed to export as PowerPoint");
} catch (error) {
console.error("error in pptx export", error);
throw error;
}
}
}

View file

@ -1,12 +1,18 @@
import { NextResponse, NextRequest } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { import {
BundledPresentationExportFormat,
bundledExportPackageAvailable, bundledExportPackageAvailable,
runBundledPdfExport, runBundledPresentationExport,
} from "@/lib/run-bundled-pdf-export"; } from "@/lib/run-bundled-presentation-export";
function isValidFormat(value: unknown): value is BundledPresentationExportFormat {
return value === "pdf" || value === "pptx";
}
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const { id, title } = await req.json(); const { format, id, title } = await req.json();
if (!id) { if (!id) {
return NextResponse.json( return NextResponse.json(
{ error: "Missing Presentation ID" }, { error: "Missing Presentation ID" },
@ -14,6 +20,13 @@ export async function POST(req: NextRequest) {
); );
} }
if (!isValidFormat(format)) {
return NextResponse.json(
{ error: "Invalid export format" },
{ status: 400 }
);
}
try { try {
if (!(await bundledExportPackageAvailable())) { if (!(await bundledExportPackageAvailable())) {
throw new Error( throw new Error(
@ -21,17 +34,19 @@ export async function POST(req: NextRequest) {
); );
} }
const { path: outPath } = await runBundledPdfExport({ const { path: outPath } = await runBundledPresentationExport({
format,
presentationId: id, presentationId: id,
title, title,
}); });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
path: outPath, path: outPath,
}); });
} catch (e) { } catch (e) {
const message = e instanceof Error ? e.message : String(e); const message = e instanceof Error ? e.message : String(e);
console.error("[export-as-pdf]", message); console.error(`[export-presentation:${format}]`, message);
return NextResponse.json( return NextResponse.json(
{ error: message, success: false }, { error: message, success: false },
{ status: 500 } { status: 500 }

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,90 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import puppeteer from "puppeteer"; import { validate as uuidValidate } from "uuid";
import { getSchemaByTemplateId, getSettingsByTemplateId } from "@/app/presentation-templates";
import { compileTemplateSchema } from "@/lib/compile-template-schema";
type CustomTemplateLayoutsResponse = {
layouts: Array<{
layout_code: string;
layout_id: string;
layout_name: string;
template: string;
}>;
template?: {
description?: string | null;
id: string;
name?: string | null;
} | null;
};
function getFastApiBaseUrl(): string {
return (
process.env.FAST_API_INTERNAL_URL?.trim() ||
process.env.NEXT_PUBLIC_FAST_API?.trim() ||
"http://127.0.0.1:8000"
);
}
function isCustomTemplateId(groupName: string): boolean {
return groupName.startsWith("custom-") || uuidValidate(groupName);
}
async function getCustomTemplateResponse(groupName: string) {
const templateId = groupName.startsWith("custom-")
? groupName.slice("custom-".length)
: groupName;
const response = await fetch(
`${getFastApiBaseUrl()}/api/v1/ppt/template/${templateId}/layouts`,
{
cache: "no-store",
}
);
if (!response.ok) {
throw new Error(`Failed to fetch template data. HTTP ${response.status}`);
}
const data = (await response.json()) as CustomTemplateLayoutsResponse;
return {
name: data.template?.name || groupName,
ordered: false,
slides: data.layouts
.map((layout) => {
const compiledLayout = compileTemplateSchema(layout.layout_code);
if (!compiledLayout) {
return null;
}
return {
description: compiledLayout.layoutDescription,
id: `custom-${templateId}:${compiledLayout.layoutId}`,
json_schema: compiledLayout.schemaJSON,
name: compiledLayout.layoutName,
};
})
.filter(
(
layout
): layout is {
description: string;
id: string;
json_schema: unknown;
name: string;
} => layout !== null
),
};
}
function getBuiltInTemplateResponse(groupName: string) {
const settings = getSettingsByTemplateId(groupName);
return {
name: groupName,
ordered: settings?.ordered ?? false,
slides: getSchemaByTemplateId(groupName),
};
}
export async function GET(request: Request) { export async function GET(request: Request) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
@ -9,78 +94,17 @@ export async function GET(request: Request) {
return NextResponse.json({ error: "Missing group name" }, { status: 400 }); return NextResponse.json({ error: "Missing group name" }, { status: 400 });
} }
const schemaPageUrl = `http://localhost/schema?group=${encodeURIComponent(
groupName
)}`;
let browser;
try { try {
browser = await puppeteer.launch({ const response = isCustomTemplateId(groupName)
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, ? await getCustomTemplateResponse(groupName)
headless: true, : getBuiltInTemplateResponse(groupName);
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--disable-web-security",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
"--disable-features=TranslateUI",
"--disable-ipc-flooding-protection",
],
});
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 720 });
page.setDefaultNavigationTimeout(300000);
page.setDefaultTimeout(300000);
await page.goto(schemaPageUrl, {
waitUntil: "networkidle0",
timeout: 300000,
});
await page.waitForSelector("[data-layouts]", { timeout: 300000 });
await page.waitForSelector("[data-settings]", { timeout: 300000 });
const { dataLayouts, dataGroupSettings } = await page.$eval(
"[data-layouts]",
(el) => ({
dataLayouts: el.getAttribute("data-layouts"),
dataGroupSettings: el.getAttribute("data-settings"),
})
);
let slides, groupSettings;
try {
slides = JSON.parse(dataLayouts || "[]");
} catch (e) {
slides = [];
}
try {
groupSettings = JSON.parse(dataGroupSettings || "null");
} catch (e) {
groupSettings = null;
}
const response = {
name: groupName,
ordered: groupSettings?.ordered ?? false,
slides: slides.map((slide: any) => ({
id: slide.id,
name: slide.name,
description: slide.description,
json_schema: slide.json_schema,
})),
};
return NextResponse.json(response); return NextResponse.json(response);
} catch (err) { } catch (error) {
console.error("[api/template]", error);
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch or parse client page" }, { error: "Failed to fetch template data" },
{ status: 500 } { status: 500 }
); );
} finally {
if (browser) await browser.close();
} }
} }

View file

@ -0,0 +1,402 @@
import { parse } from "@babel/parser";
import * as t from "@babel/types";
import * as z from "zod";
export type CompiledTemplateSchema = {
layoutDescription: string;
layoutId: string;
layoutName: string;
schemaJSON: unknown;
};
type ExtractedDeclaration = {
init: t.Expression;
initSource: string;
name: string;
order: number;
};
const DANGEROUS_MEMBER_NAMES = new Set([
"__defineGetter__",
"__defineSetter__",
"__lookupGetter__",
"__lookupSetter__",
"__proto__",
"apply",
"bind",
"call",
"constructor",
"eval",
"prototype",
]);
function normalizeHardcodedBackendUrlsInCode(layoutCode: string): string {
return layoutCode.replace(
/https?:\/\/(?:127\.0\.0\.1|localhost|0\.0\.0\.0):(?:8000|5000)(?=\/(?:app_data|static)\/)/g,
""
);
}
function unwrapExpression(node: t.Expression): t.Expression {
if (
t.isParenthesizedExpression(node) ||
t.isTSAsExpression(node) ||
t.isTSTypeAssertion(node) ||
t.isTSNonNullExpression(node)
) {
return unwrapExpression(node.expression as t.Expression);
}
return node;
}
function getRootIdentifier(node: t.Expression): string | null {
const expression = unwrapExpression(node);
if (t.isIdentifier(expression)) {
return expression.name;
}
if (t.isMemberExpression(expression)) {
return getRootIdentifier(expression.object as t.Expression);
}
if (t.isCallExpression(expression)) {
return getRootIdentifier(expression.callee as t.Expression);
}
return null;
}
function getStaticStringValue(node: t.Expression | null | undefined): string | null {
if (!node) {
return null;
}
const expression = unwrapExpression(node);
if (t.isStringLiteral(expression)) {
return expression.value;
}
if (t.isTemplateLiteral(expression) && expression.expressions.length === 0) {
return expression.quasis
.map((quasi) => quasi.value.cooked ?? quasi.value.raw ?? "")
.join("");
}
return null;
}
function extractTopLevelDeclarations(source: string): Map<string, ExtractedDeclaration> {
const program = parse(source, {
plugins: ["jsx", "typescript"],
sourceType: "module",
}).program;
const declarations = new Map<string, ExtractedDeclaration>();
let order = 0;
for (const statement of program.body) {
const declaration = t.isExportNamedDeclaration(statement)
? statement.declaration
: statement;
if (!declaration || !t.isVariableDeclaration(declaration)) {
continue;
}
for (const declarator of declaration.declarations) {
if (!t.isIdentifier(declarator.id) || !declarator.init) {
continue;
}
declarations.set(declarator.id.name, {
init: unwrapExpression(declarator.init as t.Expression),
initSource: source.slice(declarator.init.start ?? 0, declarator.init.end ?? 0),
name: declarator.id.name,
order: order++,
});
}
}
return declarations;
}
function readStringDeclaration(
declarations: Map<string, ExtractedDeclaration>,
name: string
): string | null {
return getStaticStringValue(declarations.get(name)?.init);
}
function isAllowedIdentifier(
declarations: Map<string, ExtractedDeclaration>,
name: string
): boolean {
return name === "z" || name === "undefined" || declarations.has(name);
}
function assertSafeMemberName(property: t.Identifier): void {
if (DANGEROUS_MEMBER_NAMES.has(property.name)) {
throw new Error(`Unsupported member access: ${property.name}`);
}
}
function collectDependenciesForDeclaration(
declarations: Map<string, ExtractedDeclaration>,
currentDeclaration: string,
expression: t.Expression
): Set<string> {
const dependencies = new Set<string>();
const addDependency = (name: string) => {
if (name !== "z" && name !== "undefined" && name !== currentDeclaration) {
dependencies.add(name);
}
};
const validateMemberExpression = (node: t.MemberExpression) => {
if (node.computed || !t.isIdentifier(node.property)) {
throw new Error("Computed member access is not supported in template schemas");
}
assertSafeMemberName(node.property);
const rootIdentifier = getRootIdentifier(node);
if (!rootIdentifier || !isAllowedIdentifier(declarations, rootIdentifier)) {
throw new Error(`Unsupported member access root: ${rootIdentifier ?? node.type}`);
}
validateExpression(node.object as t.Expression);
};
const validateCallExpression = (node: t.CallExpression) => {
const callee = unwrapExpression(node.callee as t.Expression);
if (t.isIdentifier(callee)) {
throw new Error(`Unsupported direct function call: ${callee.name}`);
}
if (t.isMemberExpression(callee)) {
validateMemberExpression(callee);
} else if (t.isCallExpression(callee)) {
validateCallExpression(callee);
} else {
throw new Error(`Unsupported callee type: ${callee.type}`);
}
for (const argument of node.arguments) {
if (t.isSpreadElement(argument)) {
validateExpression(argument.argument);
continue;
}
if (!t.isExpression(argument)) {
throw new Error("Unsupported call argument");
}
validateExpression(argument);
}
};
const validateObjectProperty = (node: t.ObjectProperty) => {
if (node.computed) {
if (!t.isExpression(node.key)) {
throw new Error("Unsupported computed object key");
}
validateExpression(node.key);
} else if (
!t.isIdentifier(node.key) &&
!t.isStringLiteral(node.key) &&
!t.isNumericLiteral(node.key)
) {
throw new Error(`Unsupported object key type: ${node.key.type}`);
}
if (!t.isExpression(node.value)) {
throw new Error("Unsupported object property value");
}
validateExpression(node.value);
};
const validateExpression = (node: t.Expression) => {
const expressionNode = unwrapExpression(node);
if (
t.isStringLiteral(expressionNode) ||
t.isNumericLiteral(expressionNode) ||
t.isBooleanLiteral(expressionNode) ||
t.isNullLiteral(expressionNode) ||
t.isBigIntLiteral(expressionNode) ||
t.isRegExpLiteral(expressionNode)
) {
return;
}
if (t.isIdentifier(expressionNode)) {
if (!isAllowedIdentifier(declarations, expressionNode.name)) {
throw new Error(`Unsupported identifier: ${expressionNode.name}`);
}
addDependency(expressionNode.name);
return;
}
if (t.isTemplateLiteral(expressionNode)) {
if (expressionNode.expressions.length > 0) {
throw new Error("Dynamic template literals are not supported in template schemas");
}
return;
}
if (t.isArrayExpression(expressionNode)) {
for (const element of expressionNode.elements) {
if (!element) {
continue;
}
if (t.isSpreadElement(element)) {
validateExpression(element.argument);
continue;
}
validateExpression(element);
}
return;
}
if (t.isObjectExpression(expressionNode)) {
for (const property of expressionNode.properties) {
if (t.isSpreadElement(property)) {
validateExpression(property.argument);
continue;
}
if (!t.isObjectProperty(property)) {
throw new Error(`Unsupported object property type: ${property.type}`);
}
validateObjectProperty(property);
}
return;
}
if (t.isMemberExpression(expressionNode)) {
validateMemberExpression(expressionNode);
return;
}
if (t.isCallExpression(expressionNode)) {
validateCallExpression(expressionNode);
return;
}
if (t.isUnaryExpression(expressionNode)) {
if (!["!", "+", "-", "void"].includes(expressionNode.operator)) {
throw new Error(`Unsupported unary operator: ${expressionNode.operator}`);
}
validateExpression(expressionNode.argument);
return;
}
throw new Error(`Unsupported expression type: ${expressionNode.type}`);
};
validateExpression(expression);
return dependencies;
}
function buildSchemaRuntimeSource(
declarations: Map<string, ExtractedDeclaration>
): string {
const requiredDeclarations = new Set<string>();
const visiting = new Set<string>();
const visitDeclaration = (name: string) => {
if (requiredDeclarations.has(name)) {
return;
}
if (visiting.has(name)) {
throw new Error(`Circular schema declaration detected: ${name}`);
}
const declaration = declarations.get(name);
if (!declaration) {
throw new Error(`Missing declaration: ${name}`);
}
visiting.add(name);
const dependencies = collectDependenciesForDeclaration(
declarations,
name,
declaration.init
);
for (const dependency of dependencies) {
visitDeclaration(dependency);
}
visiting.delete(name);
requiredDeclarations.add(name);
};
visitDeclaration("Schema");
return Array.from(declarations.values())
.filter((declaration) => requiredDeclarations.has(declaration.name))
.sort((left, right) => left.order - right.order)
.map(
(declaration) =>
`const ${declaration.name} = ${declaration.initSource};`
)
.join("\n");
}
function isZodSchema(value: unknown): value is z.ZodTypeAny {
return (
typeof value === "object" &&
value !== null &&
typeof (value as z.ZodTypeAny).safeParse === "function"
);
}
export function compileTemplateSchema(
layoutCode: string
): CompiledTemplateSchema | null {
try {
const normalizedLayoutCode =
normalizeHardcodedBackendUrlsInCode(layoutCode);
const declarations = extractTopLevelDeclarations(normalizedLayoutCode);
if (!declarations.has("Schema")) {
return null;
}
const schemaRuntimeSource = buildSchemaRuntimeSource(declarations);
const factory = new Function(
"_z",
`"use strict"; const z = _z; ${schemaRuntimeSource}\nreturn Schema;`
);
const schema = factory(z);
if (!isZodSchema(schema)) {
return null;
}
return {
layoutDescription:
readStringDeclaration(declarations, "layoutDescription") ?? "",
layoutId: readStringDeclaration(declarations, "layoutId") ?? "custom-layout",
layoutName:
readStringDeclaration(declarations, "layoutName") ?? "Custom Layout",
schemaJSON: z.toJSONSchema(schema),
};
} catch (error) {
console.error("Failed to compile template schema", error);
return null;
}
}

View file

@ -57,7 +57,9 @@ export async function bundledExportPackageAvailable(): Promise<boolean> {
} }
} }
export type BundledPdfExportResult = { path: string }; export type BundledPresentationExportFormat = "pdf" | "pptx";
export type BundledPresentationExportResult = { path: string };
function normalizeExportOutputPath(params: { function normalizeExportOutputPath(params: {
pathValue?: string; pathValue?: string;
@ -110,11 +112,12 @@ function normalizeExportOutputPath(params: {
* Runs the bundled export entrypoint (`presentation-export/index.js`) with * Runs the bundled export entrypoint (`presentation-export/index.js`) with
* `BUILT_PYTHON_MODULE_PATH` pointing at the PyInstaller converter binary. * `BUILT_PYTHON_MODULE_PATH` pointing at the PyInstaller converter binary.
*/ */
export async function runBundledPdfExport(params: { export async function runBundledPresentationExport(params: {
presentationId: string; presentationId: string;
title: string | undefined; title: string | undefined;
}): Promise<BundledPdfExportResult> { format: BundledPresentationExportFormat;
const { presentationId, title } = params; }): Promise<BundledPresentationExportResult> {
const { presentationId, title, format } = params;
const exportRoot = getExportPackageRoot(); const exportRoot = getExportPackageRoot();
const entrypoint = await resolveExportEntrypoint(exportRoot); const entrypoint = await resolveExportEntrypoint(exportRoot);
const converter = bundledConverterPath(exportRoot); const converter = bundledConverterPath(exportRoot);
@ -140,7 +143,7 @@ export async function runBundledPdfExport(params: {
const exportTask = { const exportTask = {
type: "export", type: "export",
url: pptUrl, url: pptUrl,
format: "pdf", format,
title: sanitizeFilename(title ?? "presentation"), title: sanitizeFilename(title ?? "presentation"),
fastapiUrl: fastapiUrl || undefined, fastapiUrl: fastapiUrl || undefined,
}; };

File diff suppressed because it is too large Load diff

View file

@ -10,8 +10,10 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"@babel/parser": "^7.28.4",
"@babel/standalone": "^7.28.2", "@babel/standalone": "^7.28.2",
"@babel/traverse": "^7.29.0", "@babel/traverse": "^7.29.0",
"@babel/types": "^7.28.4",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@ -53,7 +55,6 @@
"next": "^14.2.14", "next": "^14.2.14",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prismjs": "^1.30.0", "prismjs": "^1.30.0",
"puppeteer": "^24.13.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@ -73,7 +74,6 @@
"@types/canvas-confetti": "^1.9.0", "@types/canvas-confetti": "^1.9.0",
"@types/node": "^20", "@types/node": "^20",
"@types/prismjs": "^1.26.5", "@types/prismjs": "^1.26.5",
"@types/puppeteer": "^5.4.7",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",

View file

@ -1,82 +0,0 @@
import { ElementHandle } from "puppeteer";
export interface ElementAttributes {
tagName: string;
id?: string;
className?: string;
innerText?: string;
opacity?: number;
background?: {
color?: string;
opacity?: number;
};
border?: {
color?: string;
width?: number;
opacity?: number;
};
shadow?: {
offset?: [number, number];
color?: string;
opacity?: number;
radius?: number;
angle?: number;
spread?: number;
inset?: boolean;
},
font?: {
name?: string;
size?: number;
weight?: number;
color?: string;
italic?: boolean;
};
position?: {
left?: number;
top?: number;
width?: number;
height?: number;
};
margin?: {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
padding?: {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
zIndex?: number;
textAlign?: 'left' | 'center' | 'right' | 'justify';
lineHeight?: number;
borderRadius?: number[];
imageSrc?: string;
objectFit?: 'contain' | 'cover' | 'fill';
clip?: boolean;
overlay?: string;
shape?: 'rectangle' | 'circle';
connectorType?: string;
textWrap?: boolean;
should_screenshot?: boolean;
element?: ElementHandle<Element>;
filters?: {
invert?: number;
brightness?: number;
contrast?: number;
saturate?: number;
hueRotate?: number;
blur?: number;
grayscale?: number;
sepia?: number;
opacity?: number;
};
}
export interface SlideAttributesResult {
elements: ElementAttributes[];
backgroundColor?: string;
speakerNote?: string;
}

View file

@ -1,364 +0,0 @@
export enum PptxBoxShapeEnum {
RECTANGLE = "rectangle",
CIRCLE = "circle"
}
export enum PptxObjectFitEnum {
CONTAIN = "contain",
COVER = "cover",
FILL = "fill"
}
export enum PptxAlignment {
CENTER = 2,
DISTRIBUTE = 5,
JUSTIFY = 4,
JUSTIFY_LOW = 7,
LEFT = 1,
RIGHT = 3,
THAI_DISTRIBUTE = 6,
MIXED = -2
}
export enum PptxShapeType {
ACTION_BUTTON_BACK_OR_PREVIOUS = 129,
ACTION_BUTTON_BEGINNING = 131,
ACTION_BUTTON_CUSTOM = 125,
ACTION_BUTTON_DOCUMENT = 134,
ACTION_BUTTON_END = 132,
ACTION_BUTTON_FORWARD_OR_NEXT = 130,
ACTION_BUTTON_HELP = 127,
ACTION_BUTTON_HOME = 126,
ACTION_BUTTON_INFORMATION = 128,
ACTION_BUTTON_MOVIE = 136,
ACTION_BUTTON_RETURN = 133,
ACTION_BUTTON_SOUND = 135,
ARC = 25,
BALLOON = 137,
BENT_ARROW = 41,
BENT_UP_ARROW = 44,
BEVEL = 15,
BLOCK_ARC = 20,
CAN = 13,
CHART_PLUS = 182,
CHART_STAR = 181,
CHART_X = 180,
CHEVRON = 52,
CHORD = 161,
CIRCULAR_ARROW = 60,
CLOUD = 179,
CLOUD_CALLOUT = 108,
CORNER = 162,
CORNER_TABS = 169,
CROSS = 11,
CUBE = 14,
CURVED_DOWN_ARROW = 48,
CURVED_DOWN_RIBBON = 100,
CURVED_LEFT_ARROW = 46,
CURVED_RIGHT_ARROW = 45,
CURVED_UP_ARROW = 47,
CURVED_UP_RIBBON = 99,
DECAGON = 144,
DIAGONAL_STRIPE = 141,
DIAMOND = 4,
DODECAGON = 146,
DONUT = 18,
DOUBLE_BRACE = 27,
DOUBLE_BRACKET = 26,
DOUBLE_WAVE = 104,
DOWN_ARROW = 36,
DOWN_ARROW_CALLOUT = 56,
DOWN_RIBBON = 98,
EXPLOSION1 = 89,
EXPLOSION2 = 90,
FLOWCHART_ALTERNATE_PROCESS = 62,
FLOWCHART_CARD = 75,
FLOWCHART_COLLATE = 79,
FLOWCHART_CONNECTOR = 73,
FLOWCHART_DATA = 64,
FLOWCHART_DECISION = 63,
FLOWCHART_DELAY = 84,
FLOWCHART_DIRECT_ACCESS_STORAGE = 87,
FLOWCHART_DISPLAY = 88,
FLOWCHART_DOCUMENT = 67,
FLOWCHART_EXTRACT = 81,
FLOWCHART_INTERNAL_STORAGE = 66,
FLOWCHART_MAGNETIC_DISK = 86,
FLOWCHART_MANUAL_INPUT = 71,
FLOWCHART_MANUAL_OPERATION = 72,
FLOWCHART_MERGE = 82,
FLOWCHART_MULTIDOCUMENT = 68,
FLOWCHART_OFFLINE_STORAGE = 139,
FLOWCHART_OFFPAGE_CONNECTOR = 74,
FLOWCHART_OR = 78,
FLOWCHART_PREDEFINED_PROCESS = 65,
FLOWCHART_PREPARATION = 70,
FLOWCHART_PROCESS = 61,
FLOWCHART_PUNCHED_TAPE = 76,
FLOWCHART_SEQUENTIAL_ACCESS_STORAGE = 85,
FLOWCHART_SORT = 80,
FLOWCHART_STORED_DATA = 83,
FLOWCHART_SUMMING_JUNCTION = 77,
FLOWCHART_TERMINATOR = 69,
FOLDED_CORNER = 16,
FRAME = 158,
FUNNEL = 174,
GEAR_6 = 172,
GEAR_9 = 173,
HALF_FRAME = 159,
HEART = 21,
HEPTAGON = 145,
HEXAGON = 10,
HORIZONTAL_SCROLL = 102,
ISOSCELES_TRIANGLE = 7,
LEFT_ARROW = 34,
LEFT_ARROW_CALLOUT = 54,
LEFT_BRACE = 31,
LEFT_BRACKET = 29,
LEFT_CIRCULAR_ARROW = 176,
LEFT_RIGHT_ARROW = 37,
LEFT_RIGHT_ARROW_CALLOUT = 57,
LEFT_RIGHT_CIRCULAR_ARROW = 177,
LEFT_RIGHT_RIBBON = 140,
LEFT_RIGHT_UP_ARROW = 40,
LEFT_UP_ARROW = 43,
LIGHTNING_BOLT = 22,
LINE_CALLOUT_1 = 109,
LINE_CALLOUT_1_ACCENT_BAR = 113,
LINE_CALLOUT_1_BORDER_AND_ACCENT_BAR = 121,
LINE_CALLOUT_1_NO_BORDER = 117,
LINE_CALLOUT_2 = 110,
LINE_CALLOUT_2_ACCENT_BAR = 114,
LINE_CALLOUT_2_BORDER_AND_ACCENT_BAR = 122,
LINE_CALLOUT_2_NO_BORDER = 118,
LINE_CALLOUT_3 = 111,
LINE_CALLOUT_3_ACCENT_BAR = 115,
LINE_CALLOUT_3_BORDER_AND_ACCENT_BAR = 123,
LINE_CALLOUT_3_NO_BORDER = 119,
LINE_CALLOUT_4 = 112,
LINE_CALLOUT_4_ACCENT_BAR = 116,
LINE_CALLOUT_4_BORDER_AND_ACCENT_BAR = 124,
LINE_CALLOUT_4_NO_BORDER = 120,
LINE_INVERSE = 183,
MATH_DIVIDE = 166,
MATH_EQUAL = 167,
MATH_MINUS = 164,
MATH_MULTIPLY = 165,
MATH_NOT_EQUAL = 168,
MATH_PLUS = 163,
MOON = 24,
NON_ISOSCELES_TRAPEZOID = 143,
NOTCHED_RIGHT_ARROW = 50,
NO_SYMBOL = 19,
OCTAGON = 6,
OVAL = 9,
OVAL_CALLOUT = 107,
PARALLELOGRAM = 2,
PENTAGON = 51,
PIE = 142,
PIE_WEDGE = 175,
PLAQUE = 28,
PLAQUE_TABS = 171,
QUAD_ARROW = 39,
QUAD_ARROW_CALLOUT = 59,
RECTANGLE = 1,
RECTANGULAR_CALLOUT = 105,
REGULAR_PENTAGON = 12,
RIGHT_ARROW = 33,
RIGHT_ARROW_CALLOUT = 53,
RIGHT_BRACE = 32,
RIGHT_BRACKET = 30,
RIGHT_TRIANGLE = 8,
ROUNDED_RECTANGLE = 5,
ROUNDED_RECTANGULAR_CALLOUT = 106,
ROUND_1_RECTANGLE = 151,
ROUND_2_DIAG_RECTANGLE = 153,
ROUND_2_SAME_RECTANGLE = 152,
SMILEY_FACE = 17,
SNIP_1_RECTANGLE = 155,
SNIP_2_DIAG_RECTANGLE = 157,
SNIP_2_SAME_RECTANGLE = 156,
SNIP_ROUND_RECTANGLE = 154,
SQUARE_TABS = 170,
STAR_10_POINT = 149,
STAR_12_POINT = 150,
STAR_16_POINT = 94,
STAR_24_POINT = 95,
STAR_32_POINT = 96,
STAR_4_POINT = 91,
STAR_5_POINT = 92,
STAR_6_POINT = 147,
STAR_7_POINT = 148,
STAR_8_POINT = 93,
STRIPED_RIGHT_ARROW = 49,
SUN = 23,
SWOOSH_ARROW = 178,
TEAR = 160,
TRAPEZOID = 3,
UP_ARROW = 35,
UP_ARROW_CALLOUT = 55,
UP_DOWN_ARROW = 38,
UP_DOWN_ARROW_CALLOUT = 58,
UP_RIBBON = 97,
U_TURN_ARROW = 42,
VERTICAL_SCROLL = 101,
WAVE = 103
}
export enum PptxConnectorType {
CURVE = 3,
ELBOW = 2,
STRAIGHT = 1,
MIXED = -2
}
export interface PptxSpacingModel {
top: number;
bottom: number;
left: number;
right: number;
}
export interface PptxPositionModel {
left: number;
top: number;
width: number;
height: number;
}
export interface PptxFontModel {
name: string;
size: number;
font_weight: number;
italic: boolean;
color: string;
}
export interface PptxFillModel {
color: string;
opacity: number;
}
export interface PptxStrokeModel {
color: string;
thickness: number;
opacity: number;
}
export interface PptxShadowModel {
radius: number;
offset: number;
color: string;
opacity: number;
angle: number;
}
export interface PptxTextRunModel {
text: string;
font?: PptxFontModel;
}
export interface PptxParagraphModel {
spacing?: PptxSpacingModel;
alignment?: PptxAlignment;
font?: PptxFontModel;
line_height?: number;
text?: string;
text_runs?: PptxTextRunModel[];
}
export interface PptxObjectFitModel {
fit?: PptxObjectFitEnum;
focus?: [number | null, number | null];
}
export interface PptxPictureModel {
is_network: boolean;
path: string;
}
export interface PptxShapeModel {
}
export interface PptxTextBoxModel extends PptxShapeModel {
shape_type: string;
margin?: PptxSpacingModel;
fill?: PptxFillModel;
position: PptxPositionModel;
text_wrap: boolean;
paragraphs: PptxParagraphModel[];
}
export interface PptxAutoShapeBoxModel extends PptxShapeModel {
shape_type: string;
type?: PptxShapeType;
margin?: PptxSpacingModel;
fill?: PptxFillModel;
stroke?: PptxStrokeModel;
shadow?: PptxShadowModel;
position: PptxPositionModel;
text_wrap: boolean;
border_radius?: number;
paragraphs?: PptxParagraphModel[];
}
export interface PptxPictureBoxModel extends PptxShapeModel {
shape_type: string;
position: PptxPositionModel;
margin?: PptxSpacingModel;
clip: boolean;
opacity?: number;
invert?: boolean;
border_radius?: number[];
shape?: PptxBoxShapeEnum;
object_fit?: PptxObjectFitModel;
picture: PptxPictureModel;
}
export interface PptxConnectorModel extends PptxShapeModel {
shape_type: string;
type?: PptxConnectorType;
position: PptxPositionModel;
thickness: number;
color: string;
opacity: number;
}
export interface PptxSlideModel {
background?: PptxFillModel;
shapes: (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[];
note?: string;
}
export interface PptxPresentationModel {
name?: string;
shapes?: PptxShapeModel[];
slides: PptxSlideModel[];
}
export const createPptxSpacingAll = (num: number): PptxSpacingModel => ({
top: num,
left: num,
bottom: num,
right: num
});
export const createPptxPositionForTextbox = (left: number, top: number, width: number): PptxPositionModel => ({
left,
top,
width,
height: 100
});
export const positionToPtList = (position: PptxPositionModel): number[] => {
return [position.left, position.top, position.width, position.height];
};
export const positionToPtXyxy = (position: PptxPositionModel): number[] => {
const left = position.left;
const top = position.top;
const width = position.width;
const height = position.height;
return [left, top, left + width, top + height];
};

View file

@ -47,7 +47,6 @@ export enum MixpanelEvent {
Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked', Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked',
Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call', Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call',
Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call', Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call',
Header_GetPptxModel_API_Call = 'Header Get PPTX Model API Call',
Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call', Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call',
Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked', Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked',
Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked', Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked',

View file

@ -1,255 +0,0 @@
import { ElementAttributes, SlideAttributesResult } from "@/types/element_attibutes";
import {
PptxSlideModel,
PptxTextBoxModel,
PptxAutoShapeBoxModel,
PptxPictureBoxModel,
PptxConnectorModel,
PptxPositionModel,
PptxFillModel,
PptxStrokeModel,
PptxShadowModel,
PptxFontModel,
PptxParagraphModel,
PptxPictureModel,
PptxObjectFitModel,
PptxBoxShapeEnum,
PptxObjectFitEnum,
PptxAlignment,
PptxShapeType,
PptxConnectorType
} from "@/types/pptx_models";
function convertTextAlignToPptxAlignment(textAlign?: string): PptxAlignment | undefined {
if (!textAlign) return undefined;
switch (textAlign.toLowerCase()) {
case 'left':
return PptxAlignment.LEFT;
case 'center':
return PptxAlignment.CENTER;
case 'right':
return PptxAlignment.RIGHT;
case 'justify':
return PptxAlignment.JUSTIFY;
default:
return PptxAlignment.LEFT;
}
}
function convertLineHeightToRelative(lineHeight?: number, fontSize?: number): number | undefined {
if (!lineHeight) return undefined;
let calculatedLineHeight = 1.2;
if (lineHeight < 10) {
calculatedLineHeight = lineHeight;
}
if (fontSize && fontSize > 0) {
calculatedLineHeight = Math.round((lineHeight / fontSize) * 100) / 100;
}
return calculatedLineHeight - 0.3
}
export function convertElementAttributesToPptxSlides(
slidesAttributes: SlideAttributesResult[]
): PptxSlideModel[] {
return slidesAttributes.map((slideAttributes) => {
const shapes = slideAttributes.elements.map(element => {
return convertElementToPptxShape(element);
}).filter(Boolean);
const slide: PptxSlideModel = {
shapes: shapes as (PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel)[],
note: slideAttributes.speakerNote
};
if (slideAttributes.backgroundColor) {
slide.background = {
color: slideAttributes.backgroundColor,
opacity: 1.0
};
}
return slide;
});
}
function convertElementToPptxShape(
element: ElementAttributes
): PptxTextBoxModel | PptxAutoShapeBoxModel | PptxConnectorModel | PptxPictureBoxModel | null {
if (!element.position) {
return null;
}
if (element.tagName === 'img' || (element.className && typeof element.className === 'string' && element.className.includes('image')) || element.imageSrc) {
return convertToPictureBox(element);
}
if (element.innerText && element.innerText.trim().length > 0) {
// Use AutoShape model if there's background color and border radius
if (element.background?.color && element.borderRadius && element.borderRadius.some(radius => radius > 0)) {
return convertToAutoShapeBox(element);
}
return convertToTextBox(element);
}
if (element.tagName === 'hr') {
return convertToConnector(element);
}
return convertToAutoShapeBox(element);
}
function convertToTextBox(element: ElementAttributes): PptxTextBoxModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
top: Math.round(element.position?.top ?? 0),
width: Math.round(element.position?.width ?? 0),
height: Math.round(element.position?.height ?? 0)
};
const fill: PptxFillModel | undefined = element.background?.color ? {
color: element.background.color,
opacity: element.background.opacity ?? 1.0
} : undefined;
const font: PptxFontModel | undefined = element.font ? {
name: element.font.name ?? "Inter",
size: Math.round(element.font.size ?? 16),
font_weight: element.font.weight ?? 400,
italic: element.font.italic ?? false,
color: element.font.color ?? "000000"
} : undefined;
const paragraph: PptxParagraphModel = {
spacing: undefined,
alignment: convertTextAlignToPptxAlignment(element.textAlign),
font,
line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size),
text: element.innerText
};
return {
shape_type: "textbox",
margin: undefined,
fill,
position,
text_wrap: element.textWrap ?? true,
paragraphs: [paragraph]
};
}
function convertToAutoShapeBox(element: ElementAttributes): PptxAutoShapeBoxModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
top: Math.round(element.position?.top ?? 0),
width: Math.round(element.position?.width ?? 0),
height: Math.round(element.position?.height ?? 0)
};
const fill: PptxFillModel | undefined = element.background?.color ? {
color: element.background.color,
opacity: element.background.opacity ?? 1.0
} : undefined;
const stroke: PptxStrokeModel | undefined = element.border?.color ? {
color: element.border.color,
thickness: element.border.width ?? 1,
opacity: element.border.opacity ?? 1.0
} : undefined;
const shadow: PptxShadowModel | undefined = element.shadow?.color ? {
radius: Math.round(element.shadow.radius ?? 4),
offset: Math.round(element.shadow.offset ? Math.sqrt(element.shadow.offset[0] ** 2 + element.shadow.offset[1] ** 2) : 0),
color: element.shadow.color,
opacity: element.shadow.opacity ?? 0.5,
angle: Math.round(element.shadow.angle ?? 0)
} : undefined;
const paragraphs: PptxParagraphModel[] | undefined = element.innerText ? [{
spacing: undefined,
alignment: convertTextAlignToPptxAlignment(element.textAlign),
font: element.font ? {
name: element.font.name ?? "Inter",
size: Math.round(element.font.size ?? 16),
font_weight: element.font.weight ?? 400,
italic: element.font.italic ?? false,
color: element.font.color ?? "000000"
} : undefined,
line_height: convertLineHeightToRelative(element.lineHeight, element.font?.size),
text: element.innerText
}] : undefined;
const shapeType = element.borderRadius ? PptxShapeType.ROUNDED_RECTANGLE : PptxShapeType.RECTANGLE;
let borderRadius = undefined;
for (const eachCornerRadius of element.borderRadius ?? []) {
if (eachCornerRadius > 0) {
borderRadius = Math.max(borderRadius ?? 0, eachCornerRadius);
}
}
return {
shape_type: "autoshape",
type: shapeType,
margin: undefined,
fill,
stroke,
shadow,
position,
text_wrap: element.textWrap ?? true,
border_radius: borderRadius || undefined,
paragraphs
};
}
function convertToPictureBox(element: ElementAttributes): PptxPictureBoxModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
top: Math.round(element.position?.top ?? 0),
width: Math.round(element.position?.width ?? 0),
height: Math.round(element.position?.height ?? 0)
};
const objectFit: PptxObjectFitModel = {
fit: element.objectFit ? (element.objectFit as PptxObjectFitEnum) : PptxObjectFitEnum.CONTAIN
};
const picture: PptxPictureModel = {
is_network: element.imageSrc ? element.imageSrc.startsWith('http') : false,
path: element.imageSrc || ''
};
return {
shape_type: "picture",
position,
margin: undefined,
clip: element.clip ?? true,
invert: element.filters?.invert === 1,
opacity: element.opacity,
border_radius: element.borderRadius ? element.borderRadius.map(r => Math.round(r)) : undefined,
shape: element.shape ? (element.shape as PptxBoxShapeEnum) : PptxBoxShapeEnum.RECTANGLE,
object_fit: objectFit,
picture
};
}
function convertToConnector(element: ElementAttributes): PptxConnectorModel {
const position: PptxPositionModel = {
left: Math.round(element.position?.left ?? 0),
top: Math.round(element.position?.top ?? 0),
width: Math.round(element.position?.width ?? 0),
height: Math.round(element.position?.height ?? 0)
};
return {
shape_type: "connector",
type: PptxConnectorType.STRAIGHT,
position,
thickness: element.border?.width ?? 0.5,
color: element.border?.color || element.background?.color || '000000',
opacity: element.border?.opacity ?? 1.0
};
}