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

View file

@ -20,7 +20,7 @@ export function setupExportHandlers() {
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 {
const params = new URLSearchParams({ id });
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.unlink(sourcePath);
}
}
}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -9,11 +9,10 @@ contextBridge.exposeInMainWorld('env', {
});
contextBridge.exposeInMainWorld('electron', {
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" | "png") =>
ipcRenderer.invoke("export-presentation", id, title, format),
contextBridge.exposeInMainWorld('electron', {
fileDownloaded: (filePath: string) => ipcRenderer.invoke("file-downloaded", filePath),
exportPresentation: (id: string, title: string, format: "pptx" | "pdf") =>
ipcRenderer.invoke("export-presentation", id, title, format),
getUserConfig: () => ipcRenderer.invoke("get-user-config"),
setUserConfig: (userConfig: UserConfig) => ipcRenderer.invoke("set-user-config", userConfig),
getCanChangeKeys: () => ipcRenderer.invoke("get-can-change-keys"),
@ -28,11 +27,10 @@ contextBridge.exposeInMainWorld('electron', {
writeNextjsLog: (logData: string) => ipcRenderer.invoke("write-nextjs-log", logData),
clearNextjsLogs: () => ipcRenderer.invoke("clear-nextjs-logs"),
// API handlers
hasRequiredKey: () => ipcRenderer.invoke("api:has-required-key"),
telemetryStatus: () => ipcRenderer.invoke("api:telemetry-status"),
getTemplates: () => ipcRenderer.invoke("api:templates"),
getPresentationPptxModel: (presentationId: string) => ipcRenderer.invoke("presentation-to-pptx-model", presentationId),
onStartupStatus: (callback: (payload: { name: string; status: string }) => void) =>
ipcRenderer.on("startup:status", (_event, payload) => callback(payload)),
getStartupStatus: () => ipcRenderer.invoke("startup:get-status"),
});
hasRequiredKey: () => ipcRenderer.invoke("api:has-required-key"),
telemetryStatus: () => ipcRenderer.invoke("api:telemetry-status"),
getTemplates: () => ipcRenderer.invoke("api:templates"),
onStartupStatus: (callback: (payload: { name: string; status: string }) => void) =>
ipcRenderer.on("startup:status", (_event, payload) => callback(payload)),
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.verbosity import Verbosity
from models.pptx_models import PptxPresentationModel
from models.presentation_structure_model import PresentationStructureModel
from models.presentation_with_slides import (
PresentationWithSlides,
@ -40,14 +39,12 @@ from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
from services.database import get_async_session
from services.temp_file_service import TEMP_FILE_SERVICE
from services.concurrent_service import CONCURRENT_SERVICE
from models.sql.presentation import PresentationModel
from services.pptx_presentation_creator import PptxPresentationCreator
from models.sql.async_presentation_generation_status import (
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 (
generate_presentation_structure,
)
@ -489,56 +486,6 @@ async def update_presentation(
slides=response_slides,
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(
request: GeneratePresentationRequest,
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 subprocess
import tempfile
from typing import Mapping
from typing import Literal, Mapping
from fastapi import HTTPException
from pydantic import BaseModel
@ -27,6 +27,10 @@ class PptxToHtmlDocument(BaseModel):
fonts_dir: str
class PresentationExportTaskResult(BaseModel):
path: str
class ExportTaskService:
def __init__(self, timeout_seconds: int = 300):
self.timeout_seconds = timeout_seconds
@ -154,29 +158,24 @@ class ExportTaskService:
detail="PPTX-to-HTML task completed without a valid output path",
)
async def convert_pptx_to_html(
self, pptx_path: str, get_fonts: bool = False
) -> PptxToHtmlDocument:
self._ensure_runtime_ready()
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")
@staticmethod
def _create_task_paths() -> tuple[str, str, str]:
temp_root = get_temp_directory_env() or os.path.join(
tempfile.gettempdir(), "presenton"
)
os.makedirs(temp_root, exist_ok=True)
temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root)
task_path = os.path.join(temp_dir, "export_task.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:
with open(task_path, "w", encoding="utf-8") as task_file:
json.dump(
{
"type": "pptx-to-html",
"pptx_path": pptx_path,
"get_fonts": get_fonts,
},
task_file,
)
json.dump(task_payload, task_file)
result = await asyncio.to_thread(
subprocess.run,
@ -192,7 +191,7 @@ class ExportTaskService:
raise HTTPException(
status_code=500,
detail=(
"PPTX-to-HTML export task failed. "
"Export task failed. "
f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}"
),
)
@ -200,34 +199,77 @@ class ExportTaskService:
if not os.path.isfile(response_path):
raise HTTPException(
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:
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)
with open(output_path, "r", encoding="utf-8") as output_file:
output_data = json.load(output_file)
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:
raise HTTPException(
status_code=500,
detail="PPTX-to-HTML export produced invalid JSON output",
) 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:

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 aiohttp
from typing import Literal
from urllib.parse import urlencode
import uuid
from fastapi import HTTPException
from pathvalidate import sanitize_filename
from models.pptx_models import PptxPresentationModel
from models.presentation_and_path import PresentationAndPath
from services.pptx_presentation_creator import PptxPresentationCreator
from services.temp_file_service import TEMP_FILE_SERVICE
from utils.asset_directory_utils import get_exports_directory
import uuid
from services.export_task_service import EXPORT_TASK_SERVICE
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(
presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"]
) -> 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
async with aiohttp.ClientSession() as session:
async with session.get(
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"],
)
return PresentationAndPath(
presentation_id=presentation_id,
path=export_result.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 { toast } from "sonner";
import { PptxPresentationModel } from "@/types/pptx_models";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
import ToolTip from "@/components/ToolTip";
@ -42,6 +41,39 @@ import { Theme } from "../../services/api/types";
import MarkdownRenderer from "@/components/MarkDownRender";
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 = ({
presentation_id,
isPresentationSaving,
@ -138,15 +170,13 @@ const PresentationHeader = ({
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 exportViaIpc = async (format: "pptx" | "pdf"): Promise<boolean> => {
if (typeof window === 'undefined') return false;
if (!(window as any).electron?.exportPresentation) return false;
const exportViaIpc = async (
format: "pptx" | "pdf",
title: string
): Promise<void> => {
if (typeof window === "undefined" || !(window as any).electron?.exportPresentation) {
throw new Error("Electron export bridge is unavailable");
}
trackEvent(
format === "pptx"
? MixpanelEvent.Header_ExportAsPPTX_API_Call
@ -154,13 +184,12 @@ const PresentationHeader = ({
);
const result = await (window as any).electron.exportPresentation(
presentation_id,
presentationData?.title || 'presentation',
title,
format
);
if (!result?.success) {
throw new Error(result?.message || 'Export failed');
throw new Error(result?.message || "Export failed");
}
return true;
};
const handleExportPptx = async () => {
@ -172,25 +201,13 @@ const PresentationHeader = ({
// Save the presentation data before exporting
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
await PresentationGenerationApi.updatePresentationContent(presentationData);
if (await exportViaIpc("pptx")) {
toast.success("PPTX exported successfully!");
return;
}
trackEvent(MixpanelEvent.Header_GetPptxModel_API_Call);
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");
}
const safePptxFileName = buildSafeExportFileName(
presentationData?.title,
"pptx"
);
const safePptxTitle = safePptxFileName.replace(/\.pptx$/i, "");
await exportViaIpc("pptx", safePptxTitle);
toast.success("PPTX exported successfully!");
} catch (error) {
console.error("Export failed:", error);
toast.error("Having trouble exporting!", {
@ -211,27 +228,13 @@ const PresentationHeader = ({
// Save the presentation data before exporting
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
await PresentationGenerationApi.updatePresentationContent(presentationData);
trackEvent(MixpanelEvent.Header_ExportAsPDF_API_Call);
if (await exportViaIpc("pdf")) {
toast.success("PDF exported successfully!");
return;
}
const response = await fetch('/api/export-as-pdf', {
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");
}
const safePdfFileName = buildSafeExportFileName(
presentationData?.title,
"pdf"
);
const safePdfTitle = safePdfFileName.replace(/\.pdf$/i, "");
await exportViaIpc("pdf", safePdfTitle);
toast.success("PDF exported successfully!");
} catch (err) {
console.error(err);
@ -249,19 +252,6 @@ const PresentationHeader = ({
trackEvent(MixpanelEvent.Header_ReGenerate_Button_Clicked, { pathname });
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 }) => (
<div className={` rounded-[18px] max-md:mt-4 ${mobile ? "" : "bg-white"} p-5`}>
<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
interface ElectronAPI {
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>;
setUserConfig: (userConfig: any) => Promise<any>;
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_UpdatePresentationContent_API_Call = 'Header Update Presentation Content 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',
Slide_Add_New_Slide_Button_Clicked = 'Slide Add New 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",
"version": "1.0.0",
"presentationExportVersion": "v0.2.0",
"presentationExportVersion": "v0.2.2",
"type": "module",
"description": "Open-source AI presentation generator",
"scripts": {

View file

@ -23,7 +23,6 @@ from models.presentation_outline_model import (
)
from enums.tone import Tone
from enums.verbosity import Verbosity
from models.pptx_models import PptxPresentationModel
from models.presentation_structure_model import PresentationStructureModel
from models.presentation_with_slides import (
PresentationWithSlides,
@ -46,14 +45,12 @@ from models.sql.presentation_layout_code import PresentationLayoutCodeModel
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
from services.database import get_async_session
from services.temp_file_service import TEMP_FILE_SERVICE
from services.concurrent_service import CONCURRENT_SERVICE
from models.sql.presentation import PresentationModel
from services.pptx_presentation_creator import PptxPresentationCreator
from models.sql.async_presentation_generation_status import (
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 (
generate_presentation_structure,
)
@ -501,56 +498,6 @@ async def update_presentation(
slides=response_slides,
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(
request: GeneratePresentationRequest,
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: pathvalidate>=3.3.1
Requires-Dist: pdfplumber>=0.11.7
Requires-Dist: python-pptx>=1.0.2
Requires-Dist: sqlmodel>=0.0.24
Requires-Dist: llmai==0.1.9

View file

@ -49,7 +49,6 @@ models/image_prompt.py
models/json_path_guide.py
models/ollama_model_metadata.py
models/ollama_model_status.py
models/pptx_models.py
models/presentation_and_path.py
models/presentation_from_template.py
models/presentation_layout.py
@ -81,7 +80,6 @@ services/database.py
services/document_conversion_service.py
services/documents_loader.py
services/export_task_service.py
services/html_to_text_runs_service.py
services/icon_finder_service.py
services/image_generation_service.py
services/liteparse_service.py
@ -106,7 +104,6 @@ tests/test_liteparse_service.py
tests/test_mcp_server.py
tests/test_mem0_presentation_memory_service.py
tests/test_openai_schema_support.py
tests/test_pptx_creator.py
tests/test_pptx_slides_processing.py
tests/test_presentation_generation_api.py
tests/test_slide_to_html.py
@ -126,7 +123,6 @@ utils/get_dynamic_models.py
utils/get_env.py
utils/get_layout_by_name.py
utils/image_provider.py
utils/image_utils.py
utils/llm_client_error_handler.py
utils/llm_config.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/oauth/__init__.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
pathvalidate>=3.3.1
pdfplumber>=0.11.7
python-pptx>=1.0.2
sqlmodel>=0.0.24
llmai==0.1.9

View file

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

View file

@ -4,7 +4,7 @@ import os
import shutil
import subprocess
import tempfile
from typing import Mapping
from typing import Literal, Mapping
from fastapi import HTTPException
from pydantic import BaseModel
@ -23,6 +23,10 @@ class PptxToHtmlDocument(BaseModel):
fonts_dir: str
class PresentationExportTaskResult(BaseModel):
path: str
class ExportTaskService:
def __init__(self, timeout_seconds: int = 300):
self.timeout_seconds = timeout_seconds
@ -147,29 +151,24 @@ class ExportTaskService:
detail="PPTX-to-HTML task completed without a valid output path",
)
async def convert_pptx_to_html(
self, pptx_path: str, get_fonts: bool = False
) -> PptxToHtmlDocument:
self._ensure_runtime_ready()
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")
@staticmethod
def _create_task_paths() -> tuple[str, str, str]:
temp_root = get_temp_directory_env() or os.path.join(
tempfile.gettempdir(), "presenton"
)
os.makedirs(temp_root, exist_ok=True)
temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root)
task_path = os.path.join(temp_dir, "export_task.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:
with open(task_path, "w", encoding="utf-8") as task_file:
json.dump(
{
"type": "pptx-to-html",
"pptx_path": pptx_path,
"get_fonts": get_fonts,
},
task_file,
)
json.dump(task_payload, task_file)
result = await asyncio.to_thread(
subprocess.run,
@ -185,7 +184,7 @@ class ExportTaskService:
raise HTTPException(
status_code=500,
detail=(
"PPTX-to-HTML export task failed. "
"Export task failed. "
f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}"
),
)
@ -193,34 +192,77 @@ class ExportTaskService:
if not os.path.isfile(response_path):
raise HTTPException(
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:
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)
with open(output_path, "r", encoding="utf-8") as output_file:
output_data = json.load(output_file)
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:
raise HTTPException(
status_code=500,
detail="PPTX-to-HTML export produced invalid JSON output",
) 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:

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 aiohttp
from typing import Literal
from urllib.parse import urlencode
import uuid
from fastapi import HTTPException
from pathvalidate import sanitize_filename
from models.pptx_models import PptxPresentationModel
from models.presentation_and_path import PresentationAndPath
from services.pptx_presentation_creator import PptxPresentationCreator
from services.temp_file_service import TEMP_FILE_SERVICE
from utils.asset_directory_utils import get_exports_directory
import uuid
from services.export_task_service import EXPORT_TASK_SERVICE
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(
presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"]
) -> 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
async with aiohttp.ClientSession() as session:
async with session.get(
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"],
)
return PresentationAndPath(
presentation_id=presentation_id,
path=export_result.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]]
name = "llmai"
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 = [
{ name = "anthropic" },
{ name = "boto3" },
{ name = "google-genai" },
{ 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 = [
{ url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl", hash = "sha256:dcd94502516586bbd6394fe2c9c610941ff4c19eae0f1316825435f35134cfb4" },
]
[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" },
{ 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]]
@ -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" },
]
[[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]]
name = "mako"
version = "1.3.11"
@ -1677,7 +1640,6 @@ dependencies = [
{ name = "openai" },
{ name = "pathvalidate" },
{ name = "pdfplumber" },
{ name = "python-pptx" },
{ name = "sqlmodel" },
]
@ -1693,13 +1655,12 @@ requires-dist = [
{ name = "fastembed-vectorstore", specifier = ">=0.5.2" },
{ name = "fastmcp", specifier = ">=2.11.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 = "nltk", specifier = ">=3.9.1" },
{ name = "openai", specifier = ">=1.98.0" },
{ name = "pathvalidate", specifier = ">=3.3.1" },
{ name = "pdfplumber", specifier = ">=0.11.7" },
{ name = "python-pptx", specifier = ">=1.0.2" },
{ 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" },
]
[[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]]
name = "pytz"
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" },
]
[[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]]
name = "yarl"
version = "1.23.0"

View file

@ -25,7 +25,6 @@ import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { toast } from "sonner";
import { PptxPresentationModel } from "@/types/pptx_models";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
import ToolTip from "@/components/ToolTip";
@ -181,12 +180,6 @@ const PresentationHeader = ({
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 () => {
if (isStreaming) return;
@ -201,26 +194,30 @@ const PresentationHeader = ({
setIsExporting(true);
// Save the presentation data before exporting
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(
presentationData?.title,
"pptx"
);
const safePptxTitle = safePptxFileName.replace(/\.pptx$/i, "");
const pptx_path = await PresentationGenerationApi.exportAsPPTX({
...pptx_model,
name: safePptxTitle,
const response = await fetch("/api/export-presentation", {
method: "POST",
body: JSON.stringify({
format: "pptx",
id: presentation_id,
title: safePptxTitle,
}),
});
if (pptx_path) {
// window.open(pptx_path, '_self');
downloadLink(pptx_path, safePptxFileName);
} else {
if (!response.ok) {
throw new Error("Failed to export PPTX");
}
const { path: pptxPath } = await response.json();
if (!pptxPath) {
throw new Error("No path returned from export");
}
downloadLink(pptxPath, safePptxFileName);
} catch (error) {
console.error("Export failed:", error);
toast.error("Having trouble exporting!", {
@ -251,17 +248,17 @@ const PresentationHeader = ({
"pdf"
);
const safePdfTitle = safePdfFileName.replace(/\.pdf$/i, "");
const response = await fetch('/api/export-as-pdf', {
method: 'POST',
const response = await fetch("/api/export-presentation", {
method: "POST",
body: JSON.stringify({
format: "pdf",
id: presentation_id,
title: safePdfTitle,
})
}),
});
if (response.ok) {
const { path: pdfPath } = await response.json();
// window.open(pdfPath, '_blank');
downloadLink(pdfPath, safePdfFileName);
} else {
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 {
BundledPresentationExportFormat,
bundledExportPackageAvailable,
runBundledPdfExport,
} from "@/lib/run-bundled-pdf-export";
runBundledPresentationExport,
} from "@/lib/run-bundled-presentation-export";
function isValidFormat(value: unknown): value is BundledPresentationExportFormat {
return value === "pdf" || value === "pptx";
}
export async function POST(req: NextRequest) {
const { id, title } = await req.json();
const { format, id, title } = await req.json();
if (!id) {
return NextResponse.json(
{ 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 {
if (!(await bundledExportPackageAvailable())) {
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,
title,
});
return NextResponse.json({
success: true,
path: outPath,
});
} catch (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(
{ error: message, success: false },
{ status: 500 }

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,90 @@
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) {
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 });
}
const schemaPageUrl = `http://localhost/schema?group=${encodeURIComponent(
groupName
)}`;
let browser;
try {
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(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,
})),
};
const response = isCustomTemplateId(groupName)
? await getCustomTemplateResponse(groupName)
: getBuiltInTemplateResponse(groupName);
return NextResponse.json(response);
} catch (err) {
} catch (error) {
console.error("[api/template]", error);
return NextResponse.json(
{ error: "Failed to fetch or parse client page" },
{ error: "Failed to fetch template data" },
{ 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: {
pathValue?: string;
@ -110,11 +112,12 @@ function normalizeExportOutputPath(params: {
* Runs the bundled export entrypoint (`presentation-export/index.js`) with
* `BUILT_PYTHON_MODULE_PATH` pointing at the PyInstaller converter binary.
*/
export async function runBundledPdfExport(params: {
export async function runBundledPresentationExport(params: {
presentationId: string;
title: string | undefined;
}): Promise<BundledPdfExportResult> {
const { presentationId, title } = params;
format: BundledPresentationExportFormat;
}): Promise<BundledPresentationExportResult> {
const { presentationId, title, format } = params;
const exportRoot = getExportPackageRoot();
const entrypoint = await resolveExportEntrypoint(exportRoot);
const converter = bundledConverterPath(exportRoot);
@ -140,7 +143,7 @@ export async function runBundledPdfExport(params: {
const exportTask = {
type: "export",
url: pptUrl,
format: "pdf",
format,
title: sanitizeFilename(title ?? "presentation"),
fastapiUrl: fastapiUrl || undefined,
};

File diff suppressed because it is too large Load diff

View file

@ -10,8 +10,10 @@
"lint": "next lint"
},
"dependencies": {
"@babel/parser": "^7.28.4",
"@babel/standalone": "^7.28.2",
"@babel/traverse": "^7.29.0",
"@babel/types": "^7.28.4",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@ -53,7 +55,6 @@
"next": "^14.2.14",
"next-themes": "^0.4.6",
"prismjs": "^1.30.0",
"puppeteer": "^24.13.0",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
@ -73,7 +74,6 @@
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20",
"@types/prismjs": "^1.26.5",
"@types/puppeteer": "^5.4.7",
"@types/react": "^18",
"@types/react-dom": "^18",
"@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_UpdatePresentationContent_API_Call = 'Header Update Presentation Content 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',
Slide_Add_New_Slide_Button_Clicked = 'Slide Add New 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
};
}