refactor: cleans old unused export files from both docker and electron and uses package for export
This commit is contained in:
parent
9272907a30
commit
11904c6cb0
49 changed files with 874 additions and 9669 deletions
|
|
@ -31,8 +31,7 @@ FROM node:20-bookworm-slim AS nextjs-builder
|
||||||
|
|
||||||
WORKDIR /app/servers/nextjs
|
WORKDIR /app/servers/nextjs
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1 \
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
PUPPETEER_SKIP_DOWNLOAD=true
|
|
||||||
|
|
||||||
COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./
|
COPY servers/nextjs/package.json servers/nextjs/package-lock.json ./
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
|
@ -71,14 +70,12 @@ FROM python:3.11-slim-trixie AS runtime
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ARG INSTALL_CHROMIUM=true
|
|
||||||
ARG INSTALL_TESSERACT=true
|
ARG INSTALL_TESSERACT=true
|
||||||
ARG INSTALL_LIBREOFFICE=true
|
ARG INSTALL_LIBREOFFICE=true
|
||||||
|
|
||||||
# LiteParse uses Node + @llamaindex/liteparse (same runner as Electron); OCR uses Tesseract.
|
# LiteParse uses Node + @llamaindex/liteparse (same runner as Electron); OCR uses Tesseract.
|
||||||
ENV APP_DATA_DIRECTORY=/app_data \
|
ENV APP_DATA_DIRECTORY=/app_data \
|
||||||
TEMP_DIRECTORY=/tmp/presenton \
|
TEMP_DIRECTORY=/tmp/presenton \
|
||||||
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \
|
|
||||||
EXPORT_PACKAGE_ROOT=/app/presentation-export \
|
EXPORT_PACKAGE_ROOT=/app/presentation-export \
|
||||||
EXPORT_RUNTIME_DIR=/app/presentation-export \
|
EXPORT_RUNTIME_DIR=/app/presentation-export \
|
||||||
BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \
|
BUILT_PYTHON_MODULE_PATH=/app/presentation-export/py/convert-linux-x64 \
|
||||||
|
|
@ -90,7 +87,6 @@ ENV APP_DATA_DIRECTORY=/app_data \
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
packages="ca-certificates curl nginx fontconfig imagemagick zstd"; \
|
packages="ca-certificates curl nginx fontconfig imagemagick zstd"; \
|
||||||
if [ "$INSTALL_LIBREOFFICE" = "true" ]; then packages="$packages libreoffice"; fi; \
|
if [ "$INSTALL_LIBREOFFICE" = "true" ]; then packages="$packages libreoffice"; fi; \
|
||||||
if [ "$INSTALL_CHROMIUM" = "true" ]; then packages="$packages chromium"; fi; \
|
|
||||||
if [ "$INSTALL_TESSERACT" = "true" ]; then packages="$packages tesseract-ocr tesseract-ocr-eng"; fi; \
|
if [ "$INSTALL_TESSERACT" = "true" ]; then packages="$packages tesseract-ocr tesseract-ocr-eng"; fi; \
|
||||||
apt-get update; \
|
apt-get update; \
|
||||||
apt-get install -y --no-install-recommends $packages; \
|
apt-get install -y --no-install-recommends $packages; \
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export function setupExportHandlers() {
|
||||||
return { success };
|
return { success };
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("export-presentation", async (_, id: string, title: string, exportAs: "pptx" | "pdf" | "png") => {
|
ipcMain.handle("export-presentation", async (_, id: string, title: string, exportAs: "pptx" | "pdf") => {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ id });
|
const params = new URLSearchParams({ id });
|
||||||
if (process.env.NEXT_PUBLIC_FAST_API) {
|
if (process.env.NEXT_PUBLIC_FAST_API) {
|
||||||
|
|
@ -258,4 +258,4 @@ async function moveFile(sourcePath: string, destinationPath: string) {
|
||||||
await fs.promises.copyFile(sourcePath, destinationPath);
|
await fs.promises.copyFile(sourcePath, destinationPath);
|
||||||
await fs.promises.unlink(sourcePath);
|
await fs.promises.unlink(sourcePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,18 @@ import { setupSlideMetadataHandlers } from "./slide_metadata";
|
||||||
import { setupReadFile } from "./read_file";
|
import { setupReadFile } from "./read_file";
|
||||||
import { setupFooterHandlers } from "./footer_handlers";
|
import { setupFooterHandlers } from "./footer_handlers";
|
||||||
import { setupThemeHandlers } from "./theme_handlers";
|
import { setupThemeHandlers } from "./theme_handlers";
|
||||||
import { setupUploadImage } from "./upload_image";
|
import { setupUploadImage } from "./upload_image";
|
||||||
import { setupLogHandler } from "./log_handler";
|
import { setupLogHandler } from "./log_handler";
|
||||||
import { setupApiHandlers } from "./api_handlers";
|
import { setupApiHandlers } from "./api_handlers";
|
||||||
import { setupPresentationToPptxModelHandlers } from "./presentation_to_pptx_model_handlers";
|
|
||||||
|
export function setupIpcHandlers() {
|
||||||
export function setupIpcHandlers() {
|
|
||||||
setupExportHandlers();
|
setupExportHandlers();
|
||||||
setupUserConfigHandlers();
|
setupUserConfigHandlers();
|
||||||
setupSlideMetadataHandlers();
|
setupSlideMetadataHandlers();
|
||||||
setupReadFile();
|
setupReadFile();
|
||||||
setupFooterHandlers();
|
setupFooterHandlers();
|
||||||
setupThemeHandlers();
|
setupThemeHandlers();
|
||||||
setupUploadImage();
|
setupUploadImage();
|
||||||
setupLogHandler();
|
setupLogHandler();
|
||||||
setupApiHandlers();
|
setupApiHandlers();
|
||||||
setupPresentationToPptxModelHandlers();
|
}
|
||||||
}
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -9,11 +9,10 @@ contextBridge.exposeInMainWorld('env', {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electron', {
|
contextBridge.exposeInMainWorld('electron', {
|
||||||
fileDownloaded: (filePath: string) => ipcRenderer.invoke("file-downloaded", filePath),
|
fileDownloaded: (filePath: string) => ipcRenderer.invoke("file-downloaded", filePath),
|
||||||
exportAsPDF: (id: string, title: string) => ipcRenderer.invoke("export-as-pdf", id, title),
|
exportPresentation: (id: string, title: string, format: "pptx" | "pdf") =>
|
||||||
exportPresentation: (id: string, title: string, format: "pptx" | "pdf" | "png") =>
|
ipcRenderer.invoke("export-presentation", id, title, format),
|
||||||
ipcRenderer.invoke("export-presentation", id, title, format),
|
|
||||||
getUserConfig: () => ipcRenderer.invoke("get-user-config"),
|
getUserConfig: () => ipcRenderer.invoke("get-user-config"),
|
||||||
setUserConfig: (userConfig: UserConfig) => ipcRenderer.invoke("set-user-config", userConfig),
|
setUserConfig: (userConfig: UserConfig) => ipcRenderer.invoke("set-user-config", userConfig),
|
||||||
getCanChangeKeys: () => ipcRenderer.invoke("get-can-change-keys"),
|
getCanChangeKeys: () => ipcRenderer.invoke("get-can-change-keys"),
|
||||||
|
|
@ -28,11 +27,10 @@ contextBridge.exposeInMainWorld('electron', {
|
||||||
writeNextjsLog: (logData: string) => ipcRenderer.invoke("write-nextjs-log", logData),
|
writeNextjsLog: (logData: string) => ipcRenderer.invoke("write-nextjs-log", logData),
|
||||||
clearNextjsLogs: () => ipcRenderer.invoke("clear-nextjs-logs"),
|
clearNextjsLogs: () => ipcRenderer.invoke("clear-nextjs-logs"),
|
||||||
// API handlers
|
// API handlers
|
||||||
hasRequiredKey: () => ipcRenderer.invoke("api:has-required-key"),
|
hasRequiredKey: () => ipcRenderer.invoke("api:has-required-key"),
|
||||||
telemetryStatus: () => ipcRenderer.invoke("api:telemetry-status"),
|
telemetryStatus: () => ipcRenderer.invoke("api:telemetry-status"),
|
||||||
getTemplates: () => ipcRenderer.invoke("api:templates"),
|
getTemplates: () => ipcRenderer.invoke("api:templates"),
|
||||||
getPresentationPptxModel: (presentationId: string) => ipcRenderer.invoke("presentation-to-pptx-model", presentationId),
|
onStartupStatus: (callback: (payload: { name: string; status: string }) => void) =>
|
||||||
onStartupStatus: (callback: (payload: { name: string; status: string }) => void) =>
|
ipcRenderer.on("startup:status", (_event, payload) => callback(payload)),
|
||||||
ipcRenderer.on("startup:status", (_event, payload) => callback(payload)),
|
getStartupStatus: () => ipcRenderer.invoke("startup:get-status"),
|
||||||
getStartupStatus: () => ipcRenderer.invoke("startup:get-status"),
|
});
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ from models.presentation_outline_model import (
|
||||||
)
|
)
|
||||||
from enums.tone import Tone
|
from enums.tone import Tone
|
||||||
from enums.verbosity import Verbosity
|
from enums.verbosity import Verbosity
|
||||||
from models.pptx_models import PptxPresentationModel
|
|
||||||
from models.presentation_structure_model import PresentationStructureModel
|
from models.presentation_structure_model import PresentationStructureModel
|
||||||
from models.presentation_with_slides import (
|
from models.presentation_with_slides import (
|
||||||
PresentationWithSlides,
|
PresentationWithSlides,
|
||||||
|
|
@ -40,14 +39,12 @@ from models.sql.presentation_layout_code import PresentationLayoutCodeModel
|
||||||
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
|
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
|
||||||
|
|
||||||
from services.database import get_async_session
|
from services.database import get_async_session
|
||||||
from services.temp_file_service import TEMP_FILE_SERVICE
|
|
||||||
from services.concurrent_service import CONCURRENT_SERVICE
|
from services.concurrent_service import CONCURRENT_SERVICE
|
||||||
from models.sql.presentation import PresentationModel
|
from models.sql.presentation import PresentationModel
|
||||||
from services.pptx_presentation_creator import PptxPresentationCreator
|
|
||||||
from models.sql.async_presentation_generation_status import (
|
from models.sql.async_presentation_generation_status import (
|
||||||
AsyncPresentationGenerationTaskModel,
|
AsyncPresentationGenerationTaskModel,
|
||||||
)
|
)
|
||||||
from utils.asset_directory_utils import get_exports_directory, get_images_directory
|
from utils.asset_directory_utils import get_images_directory
|
||||||
from utils.llm_calls.generate_presentation_structure import (
|
from utils.llm_calls.generate_presentation_structure import (
|
||||||
generate_presentation_structure,
|
generate_presentation_structure,
|
||||||
)
|
)
|
||||||
|
|
@ -489,56 +486,6 @@ async def update_presentation(
|
||||||
slides=response_slides,
|
slides=response_slides,
|
||||||
fonts=fonts,
|
fonts=fonts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@PRESENTATION_ROUTER.post("/export/pptx", response_model=str)
|
|
||||||
async def export_presentation_as_pptx(
|
|
||||||
pptx_model: Annotated[PptxPresentationModel, Body()],
|
|
||||||
):
|
|
||||||
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
|
|
||||||
|
|
||||||
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
|
|
||||||
await pptx_creator.create_ppt()
|
|
||||||
|
|
||||||
export_directory = get_exports_directory()
|
|
||||||
pptx_path = os.path.join(
|
|
||||||
export_directory, f"{pptx_model.name or uuid.uuid4()}.pptx"
|
|
||||||
)
|
|
||||||
pptx_creator.save(pptx_path)
|
|
||||||
|
|
||||||
return pptx_path
|
|
||||||
|
|
||||||
|
|
||||||
@PRESENTATION_ROUTER.post("/export", response_model=PresentationPathAndEditPath)
|
|
||||||
async def export_presentation_as_pptx_or_pdf(
|
|
||||||
id: Annotated[uuid.UUID, Body(description="Presentation ID to export")],
|
|
||||||
export_as: Annotated[
|
|
||||||
Literal["pptx", "pdf"], Body(description="Format to export the presentation as")
|
|
||||||
] = "pptx",
|
|
||||||
sql_session: AsyncSession = Depends(get_async_session),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Export a presentation as PPTX or PDF.
|
|
||||||
This Api is used to export via the nextjs app i.e using the puppeteer to export the presentation.
|
|
||||||
|
|
||||||
"""
|
|
||||||
presentation = await sql_session.get(PresentationModel, id)
|
|
||||||
|
|
||||||
if not presentation:
|
|
||||||
raise HTTPException(status_code=404, detail="Presentation not found")
|
|
||||||
|
|
||||||
presentation_and_path = await export_presentation(
|
|
||||||
id,
|
|
||||||
presentation.title or str(uuid.uuid4()),
|
|
||||||
export_as,
|
|
||||||
)
|
|
||||||
|
|
||||||
return PresentationPathAndEditPath(
|
|
||||||
**presentation_and_path.model_dump(),
|
|
||||||
edit_path=f"/presentation?id={id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_if_api_request_is_valid(
|
async def check_if_api_request_is_valid(
|
||||||
request: GeneratePresentationRequest,
|
request: GeneratePresentationRequest,
|
||||||
sql_session: AsyncSession = Depends(get_async_session),
|
sql_session: AsyncSession = Depends(get_async_session),
|
||||||
|
|
|
||||||
|
|
@ -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]
|
|
||||||
|
|
@ -4,7 +4,7 @@ import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Mapping
|
from typing import Literal, Mapping
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -27,6 +27,10 @@ class PptxToHtmlDocument(BaseModel):
|
||||||
fonts_dir: str
|
fonts_dir: str
|
||||||
|
|
||||||
|
|
||||||
|
class PresentationExportTaskResult(BaseModel):
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
class ExportTaskService:
|
class ExportTaskService:
|
||||||
def __init__(self, timeout_seconds: int = 300):
|
def __init__(self, timeout_seconds: int = 300):
|
||||||
self.timeout_seconds = timeout_seconds
|
self.timeout_seconds = timeout_seconds
|
||||||
|
|
@ -154,29 +158,24 @@ class ExportTaskService:
|
||||||
detail="PPTX-to-HTML task completed without a valid output path",
|
detail="PPTX-to-HTML task completed without a valid output path",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def convert_pptx_to_html(
|
@staticmethod
|
||||||
self, pptx_path: str, get_fonts: bool = False
|
def _create_task_paths() -> tuple[str, str, str]:
|
||||||
) -> PptxToHtmlDocument:
|
temp_root = get_temp_directory_env() or os.path.join(
|
||||||
self._ensure_runtime_ready()
|
tempfile.gettempdir(), "presenton"
|
||||||
if not os.path.isfile(pptx_path):
|
)
|
||||||
raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}")
|
|
||||||
|
|
||||||
temp_root = get_temp_directory_env() or os.path.join(tempfile.gettempdir(), "presenton")
|
|
||||||
os.makedirs(temp_root, exist_ok=True)
|
os.makedirs(temp_root, exist_ok=True)
|
||||||
temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root)
|
temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root)
|
||||||
task_path = os.path.join(temp_dir, "export_task.json")
|
task_path = os.path.join(temp_dir, "export_task.json")
|
||||||
response_path = os.path.join(temp_dir, "export_task.response.json")
|
response_path = os.path.join(temp_dir, "export_task.response.json")
|
||||||
|
return temp_dir, task_path, response_path
|
||||||
|
|
||||||
|
async def _run_task(self, task_payload: dict, response_error_detail: str) -> dict:
|
||||||
|
self._ensure_runtime_ready()
|
||||||
|
temp_dir, task_path, response_path = self._create_task_paths()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(task_path, "w", encoding="utf-8") as task_file:
|
with open(task_path, "w", encoding="utf-8") as task_file:
|
||||||
json.dump(
|
json.dump(task_payload, task_file)
|
||||||
{
|
|
||||||
"type": "pptx-to-html",
|
|
||||||
"pptx_path": pptx_path,
|
|
||||||
"get_fonts": get_fonts,
|
|
||||||
},
|
|
||||||
task_file,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
subprocess.run,
|
subprocess.run,
|
||||||
|
|
@ -192,7 +191,7 @@ class ExportTaskService:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=(
|
detail=(
|
||||||
"PPTX-to-HTML export task failed. "
|
"Export task failed. "
|
||||||
f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}"
|
f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -200,34 +199,77 @@ class ExportTaskService:
|
||||||
if not os.path.isfile(response_path):
|
if not os.path.isfile(response_path):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail="PPTX-to-HTML export task did not produce a response file",
|
detail=response_error_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
with open(response_path, "r", encoding="utf-8") as response_file:
|
with open(response_path, "r", encoding="utf-8") as response_file:
|
||||||
response_data = json.load(response_file)
|
return json.load(response_file)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Export task timed out after {self.timeout_seconds} seconds",
|
||||||
|
) from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Export task produced invalid JSON output",
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to run export task: {exc}",
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
async def export_from_url(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
title: str,
|
||||||
|
export_as: Literal["pdf", "pptx"],
|
||||||
|
fastapi_url: str | None = None,
|
||||||
|
) -> PresentationExportTaskResult:
|
||||||
|
response_data = await self._run_task(
|
||||||
|
{
|
||||||
|
"type": "export",
|
||||||
|
"url": url,
|
||||||
|
"format": export_as,
|
||||||
|
"title": title,
|
||||||
|
"fastapiUrl": fastapi_url or None,
|
||||||
|
},
|
||||||
|
"Export task did not produce a response file",
|
||||||
|
)
|
||||||
|
|
||||||
|
return PresentationExportTaskResult(
|
||||||
|
path=self._resolve_output_path(response_data),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def convert_pptx_to_html(
|
||||||
|
self, pptx_path: str, get_fonts: bool = False
|
||||||
|
) -> PptxToHtmlDocument:
|
||||||
|
if not os.path.isfile(pptx_path):
|
||||||
|
raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = await self._run_task(
|
||||||
|
{
|
||||||
|
"type": "pptx-to-html",
|
||||||
|
"pptx_path": pptx_path,
|
||||||
|
"get_fonts": get_fonts,
|
||||||
|
},
|
||||||
|
"PPTX-to-HTML export task did not produce a response file",
|
||||||
|
)
|
||||||
|
|
||||||
output_path = self._resolve_output_path(response_data)
|
output_path = self._resolve_output_path(response_data)
|
||||||
with open(output_path, "r", encoding="utf-8") as output_file:
|
with open(output_path, "r", encoding="utf-8") as output_file:
|
||||||
output_data = json.load(output_file)
|
output_data = json.load(output_file)
|
||||||
|
|
||||||
return PptxToHtmlDocument(**output_data)
|
return PptxToHtmlDocument(**output_data)
|
||||||
except subprocess.TimeoutExpired as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"PPTX-to-HTML export timed out after {self.timeout_seconds} seconds",
|
|
||||||
) from exc
|
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail="PPTX-to-HTML export produced invalid JSON output",
|
detail="PPTX-to-HTML export produced invalid JSON output",
|
||||||
) from exc
|
) from exc
|
||||||
except OSError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to run PPTX-to-HTML export task: {exc}",
|
|
||||||
) from exc
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
def sys_platform() -> str:
|
def sys_platform() -> str:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -1,67 +1,47 @@
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import aiohttp
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
from urllib.parse import urlencode
|
||||||
import uuid
|
import uuid
|
||||||
from fastapi import HTTPException
|
|
||||||
from pathvalidate import sanitize_filename
|
from pathvalidate import sanitize_filename
|
||||||
|
|
||||||
from models.pptx_models import PptxPresentationModel
|
|
||||||
from models.presentation_and_path import PresentationAndPath
|
from models.presentation_and_path import PresentationAndPath
|
||||||
from services.pptx_presentation_creator import PptxPresentationCreator
|
from services.export_task_service import EXPORT_TASK_SERVICE
|
||||||
from services.temp_file_service import TEMP_FILE_SERVICE
|
|
||||||
from utils.asset_directory_utils import get_exports_directory
|
|
||||||
import uuid
|
def _get_next_public_url() -> str:
|
||||||
|
return (os.getenv("NEXT_PUBLIC_URL") or "").strip() or "http://127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_next_public_fastapi_url() -> str | None:
|
||||||
|
value = (os.getenv("NEXT_PUBLIC_FAST_API") or "").strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_presentation_export_url(presentation_id: uuid.UUID) -> tuple[str, str | None]:
|
||||||
|
params = {"id": str(presentation_id)}
|
||||||
|
fastapi_url = _get_next_public_fastapi_url()
|
||||||
|
if fastapi_url:
|
||||||
|
params["fastapiUrl"] = fastapi_url
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{_get_next_public_url().rstrip('/')}/pdf-maker?{urlencode(params)}",
|
||||||
|
fastapi_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def export_presentation(
|
async def export_presentation(
|
||||||
presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"]
|
presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"]
|
||||||
) -> PresentationAndPath:
|
) -> PresentationAndPath:
|
||||||
if export_as == "pptx":
|
export_url, fastapi_url = _build_presentation_export_url(presentation_id)
|
||||||
|
export_result = await EXPORT_TASK_SERVICE.export_from_url(
|
||||||
|
url=export_url,
|
||||||
|
title=sanitize_filename(title or str(uuid.uuid4())),
|
||||||
|
export_as=export_as,
|
||||||
|
fastapi_url=fastapi_url,
|
||||||
|
)
|
||||||
|
|
||||||
# Get the converted PPTX model from the Next.js service
|
return PresentationAndPath(
|
||||||
async with aiohttp.ClientSession() as session:
|
presentation_id=presentation_id,
|
||||||
async with session.get(
|
path=export_result.path,
|
||||||
f"http://localhost/api/presentation_to_pptx_model?id={presentation_id}"
|
)
|
||||||
) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
error_text = await response.text()
|
|
||||||
print(f"Failed to get PPTX model: {error_text}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to convert presentation to PPTX model",
|
|
||||||
)
|
|
||||||
pptx_model_data = await response.json()
|
|
||||||
|
|
||||||
# Create PPTX file using the converted model
|
|
||||||
pptx_model = PptxPresentationModel(**pptx_model_data)
|
|
||||||
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
|
|
||||||
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
|
|
||||||
await pptx_creator.create_ppt()
|
|
||||||
|
|
||||||
export_directory = get_exports_directory()
|
|
||||||
pptx_path = os.path.join(
|
|
||||||
export_directory,
|
|
||||||
f"{sanitize_filename(title or str(uuid.uuid4()))}.pptx",
|
|
||||||
)
|
|
||||||
pptx_creator.save(pptx_path)
|
|
||||||
|
|
||||||
return PresentationAndPath(
|
|
||||||
presentation_id=presentation_id,
|
|
||||||
path=pptx_path,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(
|
|
||||||
"http://localhost/api/export-as-pdf",
|
|
||||||
json={
|
|
||||||
"id": str(presentation_id),
|
|
||||||
"title": sanitize_filename(title or str(uuid.uuid4())),
|
|
||||||
},
|
|
||||||
) as response:
|
|
||||||
response_json = await response.json()
|
|
||||||
|
|
||||||
return PresentationAndPath(
|
|
||||||
presentation_id=presentation_id,
|
|
||||||
path=response_json["path"],
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -25,7 +25,6 @@ import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
import { RootState } from "@/store/store";
|
import { RootState } from "@/store/store";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { PptxPresentationModel } from "@/types/pptx_models";
|
|
||||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||||
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
|
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
|
||||||
import ToolTip from "@/components/ToolTip";
|
import ToolTip from "@/components/ToolTip";
|
||||||
|
|
@ -42,6 +41,39 @@ import { Theme } from "../../services/api/types";
|
||||||
import MarkdownRenderer from "@/components/MarkDownRender";
|
import MarkdownRenderer from "@/components/MarkDownRender";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const MAX_EXPORT_TITLE_LENGTH = 40;
|
||||||
|
|
||||||
|
const buildSafeExportFileName = (
|
||||||
|
rawTitle: string | null | undefined,
|
||||||
|
extension: "pdf" | "pptx"
|
||||||
|
) => {
|
||||||
|
const normalizedTitle = (rawTitle || "presentation").trim();
|
||||||
|
const titleWithoutExtension = normalizedTitle.replace(
|
||||||
|
/\.(pdf|pptx)$/i,
|
||||||
|
""
|
||||||
|
);
|
||||||
|
|
||||||
|
let safeBase = titleWithoutExtension
|
||||||
|
.replace(/[^a-zA-Z0-9\s_-]+/g, "-")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/[-_]{2,}/g, "-")
|
||||||
|
.replace(/^[-_]+|[-_]+$/g, "");
|
||||||
|
|
||||||
|
if (!safeBase) {
|
||||||
|
safeBase = "presentation";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (safeBase.length > MAX_EXPORT_TITLE_LENGTH) {
|
||||||
|
safeBase = safeBase.slice(0, MAX_EXPORT_TITLE_LENGTH).replace(/[-_]+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!safeBase) {
|
||||||
|
safeBase = "presentation";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${safeBase}.${extension}`;
|
||||||
|
};
|
||||||
|
|
||||||
const PresentationHeader = ({
|
const PresentationHeader = ({
|
||||||
presentation_id,
|
presentation_id,
|
||||||
isPresentationSaving,
|
isPresentationSaving,
|
||||||
|
|
@ -138,15 +170,13 @@ const PresentationHeader = ({
|
||||||
titleBlurIntentRef.current = "cancel";
|
titleBlurIntentRef.current = "cancel";
|
||||||
};
|
};
|
||||||
|
|
||||||
const get_presentation_pptx_model = async (id: string): Promise<PptxPresentationModel> => {
|
const exportViaIpc = async (
|
||||||
const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`);
|
format: "pptx" | "pdf",
|
||||||
const pptx_model = await response.json();
|
title: string
|
||||||
return pptx_model;
|
): Promise<void> => {
|
||||||
};
|
if (typeof window === "undefined" || !(window as any).electron?.exportPresentation) {
|
||||||
|
throw new Error("Electron export bridge is unavailable");
|
||||||
const exportViaIpc = async (format: "pptx" | "pdf"): Promise<boolean> => {
|
}
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
if (!(window as any).electron?.exportPresentation) return false;
|
|
||||||
trackEvent(
|
trackEvent(
|
||||||
format === "pptx"
|
format === "pptx"
|
||||||
? MixpanelEvent.Header_ExportAsPPTX_API_Call
|
? MixpanelEvent.Header_ExportAsPPTX_API_Call
|
||||||
|
|
@ -154,13 +184,12 @@ const PresentationHeader = ({
|
||||||
);
|
);
|
||||||
const result = await (window as any).electron.exportPresentation(
|
const result = await (window as any).electron.exportPresentation(
|
||||||
presentation_id,
|
presentation_id,
|
||||||
presentationData?.title || 'presentation',
|
title,
|
||||||
format
|
format
|
||||||
);
|
);
|
||||||
if (!result?.success) {
|
if (!result?.success) {
|
||||||
throw new Error(result?.message || 'Export failed');
|
throw new Error(result?.message || "Export failed");
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportPptx = async () => {
|
const handleExportPptx = async () => {
|
||||||
|
|
@ -172,25 +201,13 @@ const PresentationHeader = ({
|
||||||
// Save the presentation data before exporting
|
// Save the presentation data before exporting
|
||||||
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
|
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
|
||||||
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
||||||
|
const safePptxFileName = buildSafeExportFileName(
|
||||||
if (await exportViaIpc("pptx")) {
|
presentationData?.title,
|
||||||
toast.success("PPTX exported successfully!");
|
"pptx"
|
||||||
return;
|
);
|
||||||
}
|
const safePptxTitle = safePptxFileName.replace(/\.pptx$/i, "");
|
||||||
|
await exportViaIpc("pptx", safePptxTitle);
|
||||||
trackEvent(MixpanelEvent.Header_GetPptxModel_API_Call);
|
toast.success("PPTX exported successfully!");
|
||||||
const pptx_model = await get_presentation_pptx_model(presentation_id);
|
|
||||||
if (!pptx_model) {
|
|
||||||
throw new Error("Failed to get presentation PPTX model");
|
|
||||||
}
|
|
||||||
trackEvent(MixpanelEvent.Header_ExportAsPPTX_API_Call);
|
|
||||||
const pptx_path = await PresentationGenerationApi.exportAsPPTX(pptx_model);
|
|
||||||
if (pptx_path) {
|
|
||||||
// window.open(pptx_path, '_self');
|
|
||||||
downloadLink(pptx_path);
|
|
||||||
} else {
|
|
||||||
throw new Error("No path returned from export");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Export failed:", error);
|
console.error("Export failed:", error);
|
||||||
toast.error("Having trouble exporting!", {
|
toast.error("Having trouble exporting!", {
|
||||||
|
|
@ -211,27 +228,13 @@ const PresentationHeader = ({
|
||||||
// Save the presentation data before exporting
|
// Save the presentation data before exporting
|
||||||
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
|
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
|
||||||
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
||||||
|
const safePdfFileName = buildSafeExportFileName(
|
||||||
trackEvent(MixpanelEvent.Header_ExportAsPDF_API_Call);
|
presentationData?.title,
|
||||||
if (await exportViaIpc("pdf")) {
|
"pdf"
|
||||||
toast.success("PDF exported successfully!");
|
);
|
||||||
return;
|
const safePdfTitle = safePdfFileName.replace(/\.pdf$/i, "");
|
||||||
}
|
await exportViaIpc("pdf", safePdfTitle);
|
||||||
const response = await fetch('/api/export-as-pdf', {
|
toast.success("PDF exported successfully!");
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: presentation_id,
|
|
||||||
title: presentationData?.title,
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const { path: pdfPath } = await response.json();
|
|
||||||
// window.open(pdfPath, '_blank');
|
|
||||||
downloadLink(pdfPath);
|
|
||||||
} else {
|
|
||||||
throw new Error("Failed to export PDF");
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
@ -249,19 +252,6 @@ const PresentationHeader = ({
|
||||||
trackEvent(MixpanelEvent.Header_ReGenerate_Button_Clicked, { pathname });
|
trackEvent(MixpanelEvent.Header_ReGenerate_Button_Clicked, { pathname });
|
||||||
router.push(`/presentation?id=${presentation_id}&stream=true`);
|
router.push(`/presentation?id=${presentation_id}&stream=true`);
|
||||||
};
|
};
|
||||||
const downloadLink = (path: string) => {
|
|
||||||
// if we have popup access give direct download if not redirect to the path
|
|
||||||
if (window.opener) {
|
|
||||||
window.open(path, '_blank');
|
|
||||||
} else {
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = path;
|
|
||||||
link.download = path.split('/').pop() || 'download';
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const ExportOptions = ({ mobile }: { mobile: boolean }) => (
|
const ExportOptions = ({ mobile }: { mobile: boolean }) => (
|
||||||
<div className={` rounded-[18px] max-md:mt-4 ${mobile ? "" : "bg-white"} p-5`}>
|
<div className={` rounded-[18px] max-md:mt-4 ${mobile ? "" : "bg-white"} p-5`}>
|
||||||
<p className="text-sm font-medium text-[#19001F]">Export as</p>
|
<p className="text-sm font-medium text-[#19001F]">Export as</p>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
|
||||||
}
|
|
||||||
6
electron/servers/nextjs/types/global.d.ts
vendored
6
electron/servers/nextjs/types/global.d.ts
vendored
|
|
@ -16,7 +16,11 @@ interface TextFrameProps {
|
||||||
// Electron IPC types
|
// Electron IPC types
|
||||||
interface ElectronAPI {
|
interface ElectronAPI {
|
||||||
fileDownloaded: (filePath: string) => Promise<any>;
|
fileDownloaded: (filePath: string) => Promise<any>;
|
||||||
exportAsPDF: (id: string, title: string) => Promise<any>;
|
exportPresentation: (
|
||||||
|
id: string,
|
||||||
|
title: string,
|
||||||
|
format: "pptx" | "pdf"
|
||||||
|
) => Promise<any>;
|
||||||
getUserConfig: () => Promise<any>;
|
getUserConfig: () => Promise<any>;
|
||||||
setUserConfig: (userConfig: any) => Promise<any>;
|
setUserConfig: (userConfig: any) => Promise<any>;
|
||||||
getCanChangeKeys: () => Promise<boolean>;
|
getCanChangeKeys: () => Promise<boolean>;
|
||||||
|
|
|
||||||
|
|
@ -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];
|
|
||||||
};
|
|
||||||
|
|
@ -19,7 +19,6 @@ export enum MixpanelEvent {
|
||||||
Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked',
|
Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked',
|
||||||
Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call',
|
Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call',
|
||||||
Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call',
|
Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call',
|
||||||
Header_GetPptxModel_API_Call = 'Header Get PPTX Model API Call',
|
|
||||||
Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call',
|
Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call',
|
||||||
Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked',
|
Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked',
|
||||||
Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked',
|
Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked',
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "presenton",
|
"name": "presenton",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"presentationExportVersion": "v0.2.0",
|
"presentationExportVersion": "v0.2.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Open-source AI presentation generator",
|
"description": "Open-source AI presentation generator",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ from models.presentation_outline_model import (
|
||||||
)
|
)
|
||||||
from enums.tone import Tone
|
from enums.tone import Tone
|
||||||
from enums.verbosity import Verbosity
|
from enums.verbosity import Verbosity
|
||||||
from models.pptx_models import PptxPresentationModel
|
|
||||||
from models.presentation_structure_model import PresentationStructureModel
|
from models.presentation_structure_model import PresentationStructureModel
|
||||||
from models.presentation_with_slides import (
|
from models.presentation_with_slides import (
|
||||||
PresentationWithSlides,
|
PresentationWithSlides,
|
||||||
|
|
@ -46,14 +45,12 @@ from models.sql.presentation_layout_code import PresentationLayoutCodeModel
|
||||||
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
|
from models.sse_response import SSECompleteResponse, SSEErrorResponse, SSEResponse
|
||||||
|
|
||||||
from services.database import get_async_session
|
from services.database import get_async_session
|
||||||
from services.temp_file_service import TEMP_FILE_SERVICE
|
|
||||||
from services.concurrent_service import CONCURRENT_SERVICE
|
from services.concurrent_service import CONCURRENT_SERVICE
|
||||||
from models.sql.presentation import PresentationModel
|
from models.sql.presentation import PresentationModel
|
||||||
from services.pptx_presentation_creator import PptxPresentationCreator
|
|
||||||
from models.sql.async_presentation_generation_status import (
|
from models.sql.async_presentation_generation_status import (
|
||||||
AsyncPresentationGenerationTaskModel,
|
AsyncPresentationGenerationTaskModel,
|
||||||
)
|
)
|
||||||
from utils.asset_directory_utils import get_exports_directory, get_images_directory
|
from utils.asset_directory_utils import get_images_directory
|
||||||
from utils.llm_calls.generate_presentation_structure import (
|
from utils.llm_calls.generate_presentation_structure import (
|
||||||
generate_presentation_structure,
|
generate_presentation_structure,
|
||||||
)
|
)
|
||||||
|
|
@ -501,56 +498,6 @@ async def update_presentation(
|
||||||
slides=response_slides,
|
slides=response_slides,
|
||||||
fonts=fonts,
|
fonts=fonts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@PRESENTATION_ROUTER.post("/export/pptx", response_model=str)
|
|
||||||
async def export_presentation_as_pptx(
|
|
||||||
pptx_model: Annotated[PptxPresentationModel, Body()],
|
|
||||||
):
|
|
||||||
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
|
|
||||||
|
|
||||||
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
|
|
||||||
await pptx_creator.create_ppt()
|
|
||||||
|
|
||||||
export_directory = get_exports_directory()
|
|
||||||
pptx_path = os.path.join(
|
|
||||||
export_directory, f"{pptx_model.name or uuid.uuid4()}.pptx"
|
|
||||||
)
|
|
||||||
pptx_creator.save(pptx_path)
|
|
||||||
|
|
||||||
return pptx_path
|
|
||||||
|
|
||||||
|
|
||||||
@PRESENTATION_ROUTER.post("/export", response_model=PresentationPathAndEditPath)
|
|
||||||
async def export_presentation_as_pptx_or_pdf(
|
|
||||||
id: Annotated[uuid.UUID, Body(description="Presentation ID to export")],
|
|
||||||
export_as: Annotated[
|
|
||||||
Literal["pptx", "pdf"], Body(description="Format to export the presentation as")
|
|
||||||
] = "pptx",
|
|
||||||
sql_session: AsyncSession = Depends(get_async_session),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Export a presentation as PPTX or PDF.
|
|
||||||
This Api is used to export via the nextjs app i.e using the puppeteer to export the presentation.
|
|
||||||
|
|
||||||
"""
|
|
||||||
presentation = await sql_session.get(PresentationModel, id)
|
|
||||||
|
|
||||||
if not presentation:
|
|
||||||
raise HTTPException(status_code=404, detail="Presentation not found")
|
|
||||||
|
|
||||||
presentation_and_path = await export_presentation(
|
|
||||||
id,
|
|
||||||
presentation.title or str(uuid.uuid4()),
|
|
||||||
export_as,
|
|
||||||
)
|
|
||||||
|
|
||||||
return PresentationPathAndEditPath(
|
|
||||||
**presentation_and_path.model_dump(),
|
|
||||||
edit_path=f"/presentation?id={id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def check_if_api_request_is_valid(
|
async def check_if_api_request_is_valid(
|
||||||
request: GeneratePresentationRequest,
|
request: GeneratePresentationRequest,
|
||||||
sql_session: AsyncSession = Depends(get_async_session),
|
sql_session: AsyncSession = Depends(get_async_session),
|
||||||
|
|
|
||||||
|
|
@ -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]
|
|
||||||
|
|
@ -18,6 +18,5 @@ Requires-Dist: nltk>=3.9.1
|
||||||
Requires-Dist: openai>=1.98.0
|
Requires-Dist: openai>=1.98.0
|
||||||
Requires-Dist: pathvalidate>=3.3.1
|
Requires-Dist: pathvalidate>=3.3.1
|
||||||
Requires-Dist: pdfplumber>=0.11.7
|
Requires-Dist: pdfplumber>=0.11.7
|
||||||
Requires-Dist: python-pptx>=1.0.2
|
|
||||||
Requires-Dist: sqlmodel>=0.0.24
|
Requires-Dist: sqlmodel>=0.0.24
|
||||||
Requires-Dist: llmai==0.1.9
|
Requires-Dist: llmai==0.1.9
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@ models/image_prompt.py
|
||||||
models/json_path_guide.py
|
models/json_path_guide.py
|
||||||
models/ollama_model_metadata.py
|
models/ollama_model_metadata.py
|
||||||
models/ollama_model_status.py
|
models/ollama_model_status.py
|
||||||
models/pptx_models.py
|
|
||||||
models/presentation_and_path.py
|
models/presentation_and_path.py
|
||||||
models/presentation_from_template.py
|
models/presentation_from_template.py
|
||||||
models/presentation_layout.py
|
models/presentation_layout.py
|
||||||
|
|
@ -81,7 +80,6 @@ services/database.py
|
||||||
services/document_conversion_service.py
|
services/document_conversion_service.py
|
||||||
services/documents_loader.py
|
services/documents_loader.py
|
||||||
services/export_task_service.py
|
services/export_task_service.py
|
||||||
services/html_to_text_runs_service.py
|
|
||||||
services/icon_finder_service.py
|
services/icon_finder_service.py
|
||||||
services/image_generation_service.py
|
services/image_generation_service.py
|
||||||
services/liteparse_service.py
|
services/liteparse_service.py
|
||||||
|
|
@ -106,7 +104,6 @@ tests/test_liteparse_service.py
|
||||||
tests/test_mcp_server.py
|
tests/test_mcp_server.py
|
||||||
tests/test_mem0_presentation_memory_service.py
|
tests/test_mem0_presentation_memory_service.py
|
||||||
tests/test_openai_schema_support.py
|
tests/test_openai_schema_support.py
|
||||||
tests/test_pptx_creator.py
|
|
||||||
tests/test_pptx_slides_processing.py
|
tests/test_pptx_slides_processing.py
|
||||||
tests/test_presentation_generation_api.py
|
tests/test_presentation_generation_api.py
|
||||||
tests/test_slide_to_html.py
|
tests/test_slide_to_html.py
|
||||||
|
|
@ -126,7 +123,6 @@ utils/get_dynamic_models.py
|
||||||
utils/get_env.py
|
utils/get_env.py
|
||||||
utils/get_layout_by_name.py
|
utils/get_layout_by_name.py
|
||||||
utils/image_provider.py
|
utils/image_provider.py
|
||||||
utils/image_utils.py
|
|
||||||
utils/llm_client_error_handler.py
|
utils/llm_client_error_handler.py
|
||||||
utils/llm_config.py
|
utils/llm_config.py
|
||||||
utils/llm_provider.py
|
utils/llm_provider.py
|
||||||
|
|
@ -153,4 +149,4 @@ utils/llm_calls/generate_slide_content.py
|
||||||
utils/llm_calls/select_slide_type_on_edit.py
|
utils/llm_calls/select_slide_type_on_edit.py
|
||||||
utils/oauth/__init__.py
|
utils/oauth/__init__.py
|
||||||
utils/oauth/openai_codex.py
|
utils/oauth/openai_codex.py
|
||||||
utils/oauth/pkce.py
|
utils/oauth/pkce.py
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,5 @@ nltk>=3.9.1
|
||||||
openai>=1.98.0
|
openai>=1.98.0
|
||||||
pathvalidate>=3.3.1
|
pathvalidate>=3.3.1
|
||||||
pdfplumber>=0.11.7
|
pdfplumber>=0.11.7
|
||||||
python-pptx>=1.0.2
|
|
||||||
sqlmodel>=0.0.24
|
sqlmodel>=0.0.24
|
||||||
llmai==0.1.9
|
llmai==0.1.9
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ dependencies = [
|
||||||
"openai>=1.98.0",
|
"openai>=1.98.0",
|
||||||
"pathvalidate>=3.3.1",
|
"pathvalidate>=3.3.1",
|
||||||
"pdfplumber>=0.11.7",
|
"pdfplumber>=0.11.7",
|
||||||
"python-pptx>=1.0.2",
|
|
||||||
"sqlmodel>=0.0.24",
|
"sqlmodel>=0.0.24",
|
||||||
"llmai==0.1.9",
|
"llmai==0.1.9",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Mapping
|
from typing import Literal, Mapping
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
@ -23,6 +23,10 @@ class PptxToHtmlDocument(BaseModel):
|
||||||
fonts_dir: str
|
fonts_dir: str
|
||||||
|
|
||||||
|
|
||||||
|
class PresentationExportTaskResult(BaseModel):
|
||||||
|
path: str
|
||||||
|
|
||||||
|
|
||||||
class ExportTaskService:
|
class ExportTaskService:
|
||||||
def __init__(self, timeout_seconds: int = 300):
|
def __init__(self, timeout_seconds: int = 300):
|
||||||
self.timeout_seconds = timeout_seconds
|
self.timeout_seconds = timeout_seconds
|
||||||
|
|
@ -147,29 +151,24 @@ class ExportTaskService:
|
||||||
detail="PPTX-to-HTML task completed without a valid output path",
|
detail="PPTX-to-HTML task completed without a valid output path",
|
||||||
)
|
)
|
||||||
|
|
||||||
async def convert_pptx_to_html(
|
@staticmethod
|
||||||
self, pptx_path: str, get_fonts: bool = False
|
def _create_task_paths() -> tuple[str, str, str]:
|
||||||
) -> PptxToHtmlDocument:
|
temp_root = get_temp_directory_env() or os.path.join(
|
||||||
self._ensure_runtime_ready()
|
tempfile.gettempdir(), "presenton"
|
||||||
if not os.path.isfile(pptx_path):
|
)
|
||||||
raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}")
|
|
||||||
|
|
||||||
temp_root = get_temp_directory_env() or os.path.join(tempfile.gettempdir(), "presenton")
|
|
||||||
os.makedirs(temp_root, exist_ok=True)
|
os.makedirs(temp_root, exist_ok=True)
|
||||||
temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root)
|
temp_dir = tempfile.mkdtemp(prefix="export-task-", dir=temp_root)
|
||||||
task_path = os.path.join(temp_dir, "export_task.json")
|
task_path = os.path.join(temp_dir, "export_task.json")
|
||||||
response_path = os.path.join(temp_dir, "export_task.response.json")
|
response_path = os.path.join(temp_dir, "export_task.response.json")
|
||||||
|
return temp_dir, task_path, response_path
|
||||||
|
|
||||||
|
async def _run_task(self, task_payload: dict, response_error_detail: str) -> dict:
|
||||||
|
self._ensure_runtime_ready()
|
||||||
|
temp_dir, task_path, response_path = self._create_task_paths()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(task_path, "w", encoding="utf-8") as task_file:
|
with open(task_path, "w", encoding="utf-8") as task_file:
|
||||||
json.dump(
|
json.dump(task_payload, task_file)
|
||||||
{
|
|
||||||
"type": "pptx-to-html",
|
|
||||||
"pptx_path": pptx_path,
|
|
||||||
"get_fonts": get_fonts,
|
|
||||||
},
|
|
||||||
task_file,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await asyncio.to_thread(
|
result = await asyncio.to_thread(
|
||||||
subprocess.run,
|
subprocess.run,
|
||||||
|
|
@ -185,7 +184,7 @@ class ExportTaskService:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail=(
|
detail=(
|
||||||
"PPTX-to-HTML export task failed. "
|
"Export task failed. "
|
||||||
f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}"
|
f"stderr={_snippet(result.stderr)} stdout={_snippet(result.stdout)}"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -193,34 +192,77 @@ class ExportTaskService:
|
||||||
if not os.path.isfile(response_path):
|
if not os.path.isfile(response_path):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail="PPTX-to-HTML export task did not produce a response file",
|
detail=response_error_detail,
|
||||||
)
|
)
|
||||||
|
|
||||||
with open(response_path, "r", encoding="utf-8") as response_file:
|
with open(response_path, "r", encoding="utf-8") as response_file:
|
||||||
response_data = json.load(response_file)
|
return json.load(response_file)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Export task timed out after {self.timeout_seconds} seconds",
|
||||||
|
) from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail="Export task produced invalid JSON output",
|
||||||
|
) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to run export task: {exc}",
|
||||||
|
) from exc
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(temp_dir, ignore_errors=True)
|
||||||
|
|
||||||
|
async def export_from_url(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
title: str,
|
||||||
|
export_as: Literal["pdf", "pptx"],
|
||||||
|
fastapi_url: str | None = None,
|
||||||
|
) -> PresentationExportTaskResult:
|
||||||
|
response_data = await self._run_task(
|
||||||
|
{
|
||||||
|
"type": "export",
|
||||||
|
"url": url,
|
||||||
|
"format": export_as,
|
||||||
|
"title": title,
|
||||||
|
"fastapiUrl": fastapi_url or None,
|
||||||
|
},
|
||||||
|
"Export task did not produce a response file",
|
||||||
|
)
|
||||||
|
|
||||||
|
return PresentationExportTaskResult(
|
||||||
|
path=self._resolve_output_path(response_data),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def convert_pptx_to_html(
|
||||||
|
self, pptx_path: str, get_fonts: bool = False
|
||||||
|
) -> PptxToHtmlDocument:
|
||||||
|
if not os.path.isfile(pptx_path):
|
||||||
|
raise HTTPException(status_code=400, detail=f"PPTX not found: {pptx_path}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
response_data = await self._run_task(
|
||||||
|
{
|
||||||
|
"type": "pptx-to-html",
|
||||||
|
"pptx_path": pptx_path,
|
||||||
|
"get_fonts": get_fonts,
|
||||||
|
},
|
||||||
|
"PPTX-to-HTML export task did not produce a response file",
|
||||||
|
)
|
||||||
|
|
||||||
output_path = self._resolve_output_path(response_data)
|
output_path = self._resolve_output_path(response_data)
|
||||||
with open(output_path, "r", encoding="utf-8") as output_file:
|
with open(output_path, "r", encoding="utf-8") as output_file:
|
||||||
output_data = json.load(output_file)
|
output_data = json.load(output_file)
|
||||||
|
|
||||||
return PptxToHtmlDocument(**output_data)
|
return PptxToHtmlDocument(**output_data)
|
||||||
except subprocess.TimeoutExpired as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"PPTX-to-HTML export timed out after {self.timeout_seconds} seconds",
|
|
||||||
) from exc
|
|
||||||
except json.JSONDecodeError as exc:
|
except json.JSONDecodeError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=500,
|
status_code=500,
|
||||||
detail="PPTX-to-HTML export produced invalid JSON output",
|
detail="PPTX-to-HTML export produced invalid JSON output",
|
||||||
) from exc
|
) from exc
|
||||||
except OSError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to run PPTX-to-HTML export task: {exc}",
|
|
||||||
) from exc
|
|
||||||
finally:
|
|
||||||
shutil.rmtree(temp_dir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
def sys_platform() -> str:
|
def sys_platform() -> str:
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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)
|
|
||||||
|
|
@ -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")
|
|
||||||
|
|
@ -1,67 +1,47 @@
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import aiohttp
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
from urllib.parse import urlencode
|
||||||
import uuid
|
import uuid
|
||||||
from fastapi import HTTPException
|
|
||||||
from pathvalidate import sanitize_filename
|
from pathvalidate import sanitize_filename
|
||||||
|
|
||||||
from models.pptx_models import PptxPresentationModel
|
|
||||||
from models.presentation_and_path import PresentationAndPath
|
from models.presentation_and_path import PresentationAndPath
|
||||||
from services.pptx_presentation_creator import PptxPresentationCreator
|
from services.export_task_service import EXPORT_TASK_SERVICE
|
||||||
from services.temp_file_service import TEMP_FILE_SERVICE
|
|
||||||
from utils.asset_directory_utils import get_exports_directory
|
|
||||||
import uuid
|
def _get_next_public_url() -> str:
|
||||||
|
return (os.getenv("NEXT_PUBLIC_URL") or "").strip() or "http://127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_next_public_fastapi_url() -> str | None:
|
||||||
|
value = (os.getenv("NEXT_PUBLIC_FAST_API") or "").strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_presentation_export_url(presentation_id: uuid.UUID) -> tuple[str, str | None]:
|
||||||
|
params = {"id": str(presentation_id)}
|
||||||
|
fastapi_url = _get_next_public_fastapi_url()
|
||||||
|
if fastapi_url:
|
||||||
|
params["fastapiUrl"] = fastapi_url
|
||||||
|
|
||||||
|
return (
|
||||||
|
f"{_get_next_public_url().rstrip('/')}/pdf-maker?{urlencode(params)}",
|
||||||
|
fastapi_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def export_presentation(
|
async def export_presentation(
|
||||||
presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"]
|
presentation_id: uuid.UUID, title: str, export_as: Literal["pptx", "pdf"]
|
||||||
) -> PresentationAndPath:
|
) -> PresentationAndPath:
|
||||||
if export_as == "pptx":
|
export_url, fastapi_url = _build_presentation_export_url(presentation_id)
|
||||||
|
export_result = await EXPORT_TASK_SERVICE.export_from_url(
|
||||||
|
url=export_url,
|
||||||
|
title=sanitize_filename(title or str(uuid.uuid4())),
|
||||||
|
export_as=export_as,
|
||||||
|
fastapi_url=fastapi_url,
|
||||||
|
)
|
||||||
|
|
||||||
# Get the converted PPTX model from the Next.js service
|
return PresentationAndPath(
|
||||||
async with aiohttp.ClientSession() as session:
|
presentation_id=presentation_id,
|
||||||
async with session.get(
|
path=export_result.path,
|
||||||
f"http://localhost/api/presentation_to_pptx_model?id={presentation_id}"
|
)
|
||||||
) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
error_text = await response.text()
|
|
||||||
print(f"Failed to get PPTX model: {error_text}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Failed to convert presentation to PPTX model",
|
|
||||||
)
|
|
||||||
pptx_model_data = await response.json()
|
|
||||||
|
|
||||||
# Create PPTX file using the converted model
|
|
||||||
pptx_model = PptxPresentationModel(**pptx_model_data)
|
|
||||||
temp_dir = TEMP_FILE_SERVICE.create_temp_dir()
|
|
||||||
pptx_creator = PptxPresentationCreator(pptx_model, temp_dir)
|
|
||||||
await pptx_creator.create_ppt()
|
|
||||||
|
|
||||||
export_directory = get_exports_directory()
|
|
||||||
pptx_path = os.path.join(
|
|
||||||
export_directory,
|
|
||||||
f"{sanitize_filename(title or str(uuid.uuid4()))}.pptx",
|
|
||||||
)
|
|
||||||
pptx_creator.save(pptx_path)
|
|
||||||
|
|
||||||
return PresentationAndPath(
|
|
||||||
presentation_id=presentation_id,
|
|
||||||
path=pptx_path,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
async with session.post(
|
|
||||||
"http://localhost/api/export-as-pdf",
|
|
||||||
json={
|
|
||||||
"id": str(presentation_id),
|
|
||||||
"title": sanitize_filename(title or str(uuid.uuid4())),
|
|
||||||
},
|
|
||||||
) as response:
|
|
||||||
response_json = await response.json()
|
|
||||||
|
|
||||||
return PresentationAndPath(
|
|
||||||
presentation_id=presentation_id,
|
|
||||||
path=response_json["path"],
|
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
71
servers/fastapi/uv.lock
generated
71
servers/fastapi/uv.lock
generated
|
|
@ -1188,23 +1188,16 @@ wheels = [
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "llmai"
|
name = "llmai"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
source = { url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
{ name = "google-genai" },
|
{ name = "google-genai" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
]
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/dd/dc7cb70fb5f9b33abf457b2bded61f27189232e769badc065ca0e2d1cda2/llmai-0.1.9.tar.gz", hash = "sha256:00ee4b987dc07a65425a1296df937d7640541630fd347ca758ea1ed496880e67", size = 46798, upload-time = "2026-04-23T07:34:49.975Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl", hash = "sha256:dcd94502516586bbd6394fe2c9c610941ff4c19eae0f1316825435f35134cfb4" },
|
{ url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl", hash = "sha256:dcd94502516586bbd6394fe2c9c610941ff4c19eae0f1316825435f35134cfb4", size = 58968, upload-time = "2026-04-23T07:34:48.375Z" },
|
||||||
]
|
|
||||||
|
|
||||||
[package.metadata]
|
|
||||||
requires-dist = [
|
|
||||||
{ name = "anthropic", specifier = ">=0.79.0" },
|
|
||||||
{ name = "boto3", specifier = ">=1.42.89" },
|
|
||||||
{ name = "google-genai", specifier = ">=1.62.0" },
|
|
||||||
{ name = "openai", specifier = ">=2.18.0" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1220,36 +1213,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "lxml"
|
|
||||||
version = "6.1.0"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/5d/3bccad330292946f97962df9d5f2d3ae129cce6e212732a781e856b91e07/lxml-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cec05be8c876f92a5aa07b01d60bbb4d11cfbdd654cad0561c0d7b5c043a61b9", size = 8526232, upload-time = "2026-04-18T04:27:40.389Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/51/adc8826570a112f83bb4ddb3a2ab510bbc2ccd62c1b9fe1f34fae2d90b57/lxml-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c03e048b6ce8e77b09c734e931584894ecd58d08296804ca2d0b184c933ce50", size = 4595448, upload-time = "2026-04-18T04:27:44.208Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/84/5a9ec07cbe1d2334a6465f863b949a520d2699a755738986dcd3b6b89e3f/lxml-6.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:942454ff253da14218f972b23dc72fa4edf6c943f37edd19cd697618b626fac5", size = 4923771, upload-time = "2026-04-18T04:32:17.402Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/23/851cfa33b6b38adb628e45ad51fb27105fa34b2b3ba9d1d4aa7a9428dfe0/lxml-6.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d036ee7b99d5148072ac7c9b847193decdfeac633db350363f7bce4fff108f0e", size = 5068101, upload-time = "2026-04-18T04:32:21.437Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/38/41bf99c2023c6b79916ba057d83e9db21d642f473cac210201222882d38b/lxml-6.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ae5d8d5427f3cc317e7950f2da7ad276df0cfa37b8de2f5658959e618ea8512", size = 5002573, upload-time = "2026-04-18T04:32:25.373Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/20/053aa10bdc39747e1e923ce2d45413075e84f70a136045bb09e5eaca41d3/lxml-6.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:363e47283bde87051b821826e71dde47f107e08614e1aa312ba0c5711e77738c", size = 5202816, upload-time = "2026-04-18T04:32:29.393Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/da/bc710fad8bf04b93baee752c192eaa2210cd3a84f969d0be7830fea55802/lxml-6.1.0-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:f504d861d9f2a8f94020130adac88d66de93841707a23a86244263d1e54682f5", size = 5329999, upload-time = "2026-04-18T04:32:34.019Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/cb/bf035dedbdf7fab49411aa52e4236f3445e98d38647d85419e6c0d2806b9/lxml-6.1.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:23a5dc68e08ed13331d61815c08f260f46b4a60fdd1640bbeb82cf89a9d90289", size = 4659643, upload-time = "2026-04-18T04:32:37.932Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/4f/22be31f33727a5e4c7b01b0a874503026e50329b259d3587e0b923cf964b/lxml-6.1.0-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f15401d8d3dbf239e23c818afc10c7207f7b95f9a307e092122b6f86dd43209a", size = 5265963, upload-time = "2026-04-18T04:32:41.881Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/2b/d44d0e5c79226017f4ab8c87a802ebe4f89f97e6585a8e4166dffcdd7b6e/lxml-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fcf3da95e93349e0647d48d4b36a12783105bcc74cb0c416952f9988410846a3", size = 5045444, upload-time = "2026-04-18T04:32:44.512Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/c3/3f034fec1594c331a6dbf9491238fdcc9d66f68cc529e109ec75b97197e1/lxml-6.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0d082495c5fcf426e425a6e28daaba1fcb6d8f854a4ff01effb1f1f381203eb9", size = 4712703, upload-time = "2026-04-18T04:32:47.16Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/12/16/0b83fccc158218aca75a7aa33e97441df737950734246b9fffa39301603d/lxml-6.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e3c4f84b24a1fcba435157d111c4b755099c6ff00a3daee1ad281817de75ed11", size = 5252745, upload-time = "2026-04-18T04:32:50.427Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/ee/12e6c1b39a77666c02eaa77f94a870aaf63c4ac3a497b2d52319448b01c6/lxml-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:976a6b39b1b13e8c354ad8d3f261f3a4ac6609518af91bdb5094760a08f132c4", size = 5226822, upload-time = "2026-04-18T04:32:53.437Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/20/c7852904858b4723af01d2fc14b5d38ff57cb92f01934a127ebd9a9e51aa/lxml-6.1.0-cp311-cp311-win32.whl", hash = "sha256:857efde87d365706590847b916baff69c0bc9252dc5af030e378c9800c0b10e3", size = 3594026, upload-time = "2026-04-18T04:27:31.903Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/05/d60c732b56da5085175c07c74b2df4e6d181b0c9a61e1691474f06ef4b39/lxml-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:183bfb45a493081943be7ea2b5adfc2b611e1cf377cefa8b8a8be404f45ef9a7", size = 4025114, upload-time = "2026-04-18T04:27:34.077Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/df/c84dcc175fd690823436d15b41cb920cd5ba5e14cd8bfb00949d5903b320/lxml-6.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:19f4164243fc206d12ed3d866e80e74f5bc3627966520da1a5f97e42c32a3f39", size = 3667742, upload-time = "2026-04-18T04:27:38.45Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/88/55143966481409b1740a3ac669e611055f49efd68087a5ce41582325db3e/lxml-6.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:546b66c0dd1bb8d9fa89d7123e5fa19a8aff3a1f2141eb22df96112afb17b842", size = 3930134, upload-time = "2026-04-18T04:32:35.008Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/97/28b985c2983938d3cb696dd5501423afb90a8c3e869ef5d3c62569282c0f/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5cfa1a34df366d9dc0d5eaf420f4cf2bb1e1bebe1066d1c2fc28c179f8a4004c", size = 4210749, upload-time = "2026-04-18T04:36:03.626Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/67/dfab2b7d58214921935ccea7ce9b3df9b7d46f305d12f0f532ac7cf6b804/lxml-6.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db88156fcf544cdbf0d95588051515cfdfd4c876fc66444eb98bceb5d6db76de", size = 4318463, upload-time = "2026-04-18T04:36:06.309Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/a2/4ac7eb32a4d997dd352c32c32399aae27b3f268d440e6f9cfa405b575d2f/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:07f98f5496f96bf724b1e3c933c107f0cbf2745db18c03d2e13a291c3afd2635", size = 4251124, upload-time = "2026-04-18T04:36:09.056Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/ef/d6abd850bb4822f9b720cfe36b547a558e694881010ff7d012191e8769c6/lxml-6.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4642e04449a1e164b5ff71ffd901ddb772dfabf5c9adf1b7be5dffe1212bc037", size = 4401758, upload-time = "2026-04-18T04:36:11.803Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/40/44/3ee09a5b60cb44c4f2fbc1c9015cfd6ff5afc08f991cab295d3024dcbf2d/lxml-6.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7da13bb6fbadfafb474e0226a30570a3445cfd47c86296f2446dafbd77079ace", size = 3508860, upload-time = "2026-04-18T04:32:48.619Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mako"
|
name = "mako"
|
||||||
version = "1.3.11"
|
version = "1.3.11"
|
||||||
|
|
@ -1677,7 +1640,6 @@ dependencies = [
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
{ name = "pathvalidate" },
|
{ name = "pathvalidate" },
|
||||||
{ name = "pdfplumber" },
|
{ name = "pdfplumber" },
|
||||||
{ name = "python-pptx" },
|
|
||||||
{ name = "sqlmodel" },
|
{ name = "sqlmodel" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1693,13 +1655,12 @@ requires-dist = [
|
||||||
{ name = "fastembed-vectorstore", specifier = ">=0.5.2" },
|
{ name = "fastembed-vectorstore", specifier = ">=0.5.2" },
|
||||||
{ name = "fastmcp", specifier = ">=2.11.0" },
|
{ name = "fastmcp", specifier = ">=2.11.0" },
|
||||||
{ name = "google-genai", specifier = ">=1.28.0" },
|
{ name = "google-genai", specifier = ">=1.28.0" },
|
||||||
{ name = "llmai", url = "https://files.pythonhosted.org/packages/c6/86/5dcfd77b634947cd570680b13217b40bc72cd7d9e7f04cc1a52ff5f549a0/llmai-0.1.9-py3-none-any.whl" },
|
{ name = "llmai", specifier = "==0.1.9" },
|
||||||
{ name = "mem0ai", extras = ["nlp"], specifier = ">=0.1.115" },
|
{ name = "mem0ai", extras = ["nlp"], specifier = ">=0.1.115" },
|
||||||
{ name = "nltk", specifier = ">=3.9.1" },
|
{ name = "nltk", specifier = ">=3.9.1" },
|
||||||
{ name = "openai", specifier = ">=1.98.0" },
|
{ name = "openai", specifier = ">=1.98.0" },
|
||||||
{ name = "pathvalidate", specifier = ">=3.3.1" },
|
{ name = "pathvalidate", specifier = ">=3.3.1" },
|
||||||
{ name = "pdfplumber", specifier = ">=0.11.7" },
|
{ name = "pdfplumber", specifier = ">=0.11.7" },
|
||||||
{ name = "python-pptx", specifier = ">=1.0.2" },
|
|
||||||
{ name = "sqlmodel", specifier = ">=0.0.24" },
|
{ name = "sqlmodel", specifier = ">=0.0.24" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -2020,21 +1981,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "python-pptx"
|
|
||||||
version = "1.0.2"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "lxml" },
|
|
||||||
{ name = "pillow" },
|
|
||||||
{ name = "typing-extensions" },
|
|
||||||
{ name = "xlsxwriter" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pytz"
|
name = "pytz"
|
||||||
version = "2026.1.post1"
|
version = "2026.1.post1"
|
||||||
|
|
@ -2773,15 +2719,6 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
|
{ url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "xlsxwriter"
|
|
||||||
version = "3.2.9"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yarl"
|
name = "yarl"
|
||||||
version = "1.23.0"
|
version = "1.23.0"
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import { useDispatch, useSelector } from "react-redux";
|
||||||
|
|
||||||
import { RootState } from "@/store/store";
|
import { RootState } from "@/store/store";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { PptxPresentationModel } from "@/types/pptx_models";
|
|
||||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||||
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
|
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
|
||||||
import ToolTip from "@/components/ToolTip";
|
import ToolTip from "@/components/ToolTip";
|
||||||
|
|
@ -181,12 +180,6 @@ const PresentationHeader = ({
|
||||||
titleBlurIntentRef.current = "cancel";
|
titleBlurIntentRef.current = "cancel";
|
||||||
};
|
};
|
||||||
|
|
||||||
const get_presentation_pptx_model = async (id: string): Promise<PptxPresentationModel> => {
|
|
||||||
const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`);
|
|
||||||
const pptx_model = await response.json();
|
|
||||||
return pptx_model;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportPptx = async () => {
|
const handleExportPptx = async () => {
|
||||||
if (isStreaming) return;
|
if (isStreaming) return;
|
||||||
|
|
||||||
|
|
@ -201,26 +194,30 @@ const PresentationHeader = ({
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
// Save the presentation data before exporting
|
// Save the presentation data before exporting
|
||||||
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
||||||
|
|
||||||
const pptx_model = await get_presentation_pptx_model(presentation_id);
|
|
||||||
if (!pptx_model) {
|
|
||||||
throw new Error("Failed to get presentation PPTX model");
|
|
||||||
}
|
|
||||||
const safePptxFileName = buildSafeExportFileName(
|
const safePptxFileName = buildSafeExportFileName(
|
||||||
presentationData?.title,
|
presentationData?.title,
|
||||||
"pptx"
|
"pptx"
|
||||||
);
|
);
|
||||||
const safePptxTitle = safePptxFileName.replace(/\.pptx$/i, "");
|
const safePptxTitle = safePptxFileName.replace(/\.pptx$/i, "");
|
||||||
const pptx_path = await PresentationGenerationApi.exportAsPPTX({
|
const response = await fetch("/api/export-presentation", {
|
||||||
...pptx_model,
|
method: "POST",
|
||||||
name: safePptxTitle,
|
body: JSON.stringify({
|
||||||
|
format: "pptx",
|
||||||
|
id: presentation_id,
|
||||||
|
title: safePptxTitle,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (pptx_path) {
|
|
||||||
// window.open(pptx_path, '_self');
|
if (!response.ok) {
|
||||||
downloadLink(pptx_path, safePptxFileName);
|
throw new Error("Failed to export PPTX");
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
const { path: pptxPath } = await response.json();
|
||||||
|
if (!pptxPath) {
|
||||||
throw new Error("No path returned from export");
|
throw new Error("No path returned from export");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
downloadLink(pptxPath, safePptxFileName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Export failed:", error);
|
console.error("Export failed:", error);
|
||||||
toast.error("Having trouble exporting!", {
|
toast.error("Having trouble exporting!", {
|
||||||
|
|
@ -251,17 +248,17 @@ const PresentationHeader = ({
|
||||||
"pdf"
|
"pdf"
|
||||||
);
|
);
|
||||||
const safePdfTitle = safePdfFileName.replace(/\.pdf$/i, "");
|
const safePdfTitle = safePdfFileName.replace(/\.pdf$/i, "");
|
||||||
const response = await fetch('/api/export-as-pdf', {
|
const response = await fetch("/api/export-presentation", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
format: "pdf",
|
||||||
id: presentation_id,
|
id: presentation_id,
|
||||||
title: safePdfTitle,
|
title: safePdfTitle,
|
||||||
})
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const { path: pdfPath } = await response.json();
|
const { path: pdfPath } = await response.json();
|
||||||
// window.open(pdfPath, '_blank');
|
|
||||||
downloadLink(pdfPath, safePdfFileName);
|
downloadLink(pdfPath, safePdfFileName);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Failed to export PDF");
|
throw new Error("Failed to export PDF");
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
import { NextResponse, NextRequest } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BundledPresentationExportFormat,
|
||||||
bundledExportPackageAvailable,
|
bundledExportPackageAvailable,
|
||||||
runBundledPdfExport,
|
runBundledPresentationExport,
|
||||||
} from "@/lib/run-bundled-pdf-export";
|
} from "@/lib/run-bundled-presentation-export";
|
||||||
|
|
||||||
|
function isValidFormat(value: unknown): value is BundledPresentationExportFormat {
|
||||||
|
return value === "pdf" || value === "pptx";
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const { id, title } = await req.json();
|
const { format, id, title } = await req.json();
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Missing Presentation ID" },
|
{ error: "Missing Presentation ID" },
|
||||||
|
|
@ -14,6 +20,13 @@ export async function POST(req: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isValidFormat(format)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid export format" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!(await bundledExportPackageAvailable())) {
|
if (!(await bundledExportPackageAvailable())) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -21,17 +34,19 @@ export async function POST(req: NextRequest) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { path: outPath } = await runBundledPdfExport({
|
const { path: outPath } = await runBundledPresentationExport({
|
||||||
|
format,
|
||||||
presentationId: id,
|
presentationId: id,
|
||||||
title,
|
title,
|
||||||
});
|
});
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
path: outPath,
|
path: outPath,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const message = e instanceof Error ? e.message : String(e);
|
const message = e instanceof Error ? e.message : String(e);
|
||||||
console.error("[export-as-pdf]", message);
|
console.error(`[export-presentation:${format}]`, message);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: message, success: false },
|
{ error: message, success: false },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,90 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import puppeteer from "puppeteer";
|
import { validate as uuidValidate } from "uuid";
|
||||||
|
|
||||||
|
import { getSchemaByTemplateId, getSettingsByTemplateId } from "@/app/presentation-templates";
|
||||||
|
import { compileTemplateSchema } from "@/lib/compile-template-schema";
|
||||||
|
|
||||||
|
type CustomTemplateLayoutsResponse = {
|
||||||
|
layouts: Array<{
|
||||||
|
layout_code: string;
|
||||||
|
layout_id: string;
|
||||||
|
layout_name: string;
|
||||||
|
template: string;
|
||||||
|
}>;
|
||||||
|
template?: {
|
||||||
|
description?: string | null;
|
||||||
|
id: string;
|
||||||
|
name?: string | null;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getFastApiBaseUrl(): string {
|
||||||
|
return (
|
||||||
|
process.env.FAST_API_INTERNAL_URL?.trim() ||
|
||||||
|
process.env.NEXT_PUBLIC_FAST_API?.trim() ||
|
||||||
|
"http://127.0.0.1:8000"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCustomTemplateId(groupName: string): boolean {
|
||||||
|
return groupName.startsWith("custom-") || uuidValidate(groupName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCustomTemplateResponse(groupName: string) {
|
||||||
|
const templateId = groupName.startsWith("custom-")
|
||||||
|
? groupName.slice("custom-".length)
|
||||||
|
: groupName;
|
||||||
|
const response = await fetch(
|
||||||
|
`${getFastApiBaseUrl()}/api/v1/ppt/template/${templateId}/layouts`,
|
||||||
|
{
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch template data. HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as CustomTemplateLayoutsResponse;
|
||||||
|
return {
|
||||||
|
name: data.template?.name || groupName,
|
||||||
|
ordered: false,
|
||||||
|
slides: data.layouts
|
||||||
|
.map((layout) => {
|
||||||
|
const compiledLayout = compileTemplateSchema(layout.layout_code);
|
||||||
|
if (!compiledLayout) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
description: compiledLayout.layoutDescription,
|
||||||
|
id: `custom-${templateId}:${compiledLayout.layoutId}`,
|
||||||
|
json_schema: compiledLayout.schemaJSON,
|
||||||
|
name: compiledLayout.layoutName,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
layout
|
||||||
|
): layout is {
|
||||||
|
description: string;
|
||||||
|
id: string;
|
||||||
|
json_schema: unknown;
|
||||||
|
name: string;
|
||||||
|
} => layout !== null
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBuiltInTemplateResponse(groupName: string) {
|
||||||
|
const settings = getSettingsByTemplateId(groupName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: groupName,
|
||||||
|
ordered: settings?.ordered ?? false,
|
||||||
|
slides: getSchemaByTemplateId(groupName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
@ -9,78 +94,17 @@ export async function GET(request: Request) {
|
||||||
return NextResponse.json({ error: "Missing group name" }, { status: 400 });
|
return NextResponse.json({ error: "Missing group name" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const schemaPageUrl = `http://localhost/schema?group=${encodeURIComponent(
|
|
||||||
groupName
|
|
||||||
)}`;
|
|
||||||
|
|
||||||
let browser;
|
|
||||||
try {
|
try {
|
||||||
browser = await puppeteer.launch({
|
const response = isCustomTemplateId(groupName)
|
||||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
|
? await getCustomTemplateResponse(groupName)
|
||||||
headless: true,
|
: getBuiltInTemplateResponse(groupName);
|
||||||
args: [
|
|
||||||
"--no-sandbox",
|
|
||||||
"--disable-setuid-sandbox",
|
|
||||||
"--disable-dev-shm-usage",
|
|
||||||
"--disable-gpu",
|
|
||||||
"--disable-web-security",
|
|
||||||
"--disable-background-timer-throttling",
|
|
||||||
"--disable-backgrounding-occluded-windows",
|
|
||||||
"--disable-renderer-backgrounding",
|
|
||||||
"--disable-features=TranslateUI",
|
|
||||||
"--disable-ipc-flooding-protection",
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const page = await browser.newPage();
|
|
||||||
await page.setViewport({ width: 1280, height: 720 });
|
|
||||||
page.setDefaultNavigationTimeout(300000);
|
|
||||||
page.setDefaultTimeout(300000);
|
|
||||||
await page.goto(schemaPageUrl, {
|
|
||||||
waitUntil: "networkidle0",
|
|
||||||
timeout: 300000,
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.waitForSelector("[data-layouts]", { timeout: 300000 });
|
|
||||||
await page.waitForSelector("[data-settings]", { timeout: 300000 });
|
|
||||||
|
|
||||||
const { dataLayouts, dataGroupSettings } = await page.$eval(
|
|
||||||
"[data-layouts]",
|
|
||||||
(el) => ({
|
|
||||||
dataLayouts: el.getAttribute("data-layouts"),
|
|
||||||
dataGroupSettings: el.getAttribute("data-settings"),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
let slides, groupSettings;
|
|
||||||
try {
|
|
||||||
slides = JSON.parse(dataLayouts || "[]");
|
|
||||||
} catch (e) {
|
|
||||||
slides = [];
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
groupSettings = JSON.parse(dataGroupSettings || "null");
|
|
||||||
} catch (e) {
|
|
||||||
groupSettings = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
name: groupName,
|
|
||||||
ordered: groupSettings?.ordered ?? false,
|
|
||||||
slides: slides.map((slide: any) => ({
|
|
||||||
id: slide.id,
|
|
||||||
name: slide.name,
|
|
||||||
description: slide.description,
|
|
||||||
json_schema: slide.json_schema,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
return NextResponse.json(response);
|
return NextResponse.json(response);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
|
console.error("[api/template]", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to fetch or parse client page" },
|
{ error: "Failed to fetch template data" },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
if (browser) await browser.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
402
servers/nextjs/lib/compile-template-schema.ts
Normal file
402
servers/nextjs/lib/compile-template-schema.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,7 +57,9 @@ export async function bundledExportPackageAvailable(): Promise<boolean> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BundledPdfExportResult = { path: string };
|
export type BundledPresentationExportFormat = "pdf" | "pptx";
|
||||||
|
|
||||||
|
export type BundledPresentationExportResult = { path: string };
|
||||||
|
|
||||||
function normalizeExportOutputPath(params: {
|
function normalizeExportOutputPath(params: {
|
||||||
pathValue?: string;
|
pathValue?: string;
|
||||||
|
|
@ -110,11 +112,12 @@ function normalizeExportOutputPath(params: {
|
||||||
* Runs the bundled export entrypoint (`presentation-export/index.js`) with
|
* Runs the bundled export entrypoint (`presentation-export/index.js`) with
|
||||||
* `BUILT_PYTHON_MODULE_PATH` pointing at the PyInstaller converter binary.
|
* `BUILT_PYTHON_MODULE_PATH` pointing at the PyInstaller converter binary.
|
||||||
*/
|
*/
|
||||||
export async function runBundledPdfExport(params: {
|
export async function runBundledPresentationExport(params: {
|
||||||
presentationId: string;
|
presentationId: string;
|
||||||
title: string | undefined;
|
title: string | undefined;
|
||||||
}): Promise<BundledPdfExportResult> {
|
format: BundledPresentationExportFormat;
|
||||||
const { presentationId, title } = params;
|
}): Promise<BundledPresentationExportResult> {
|
||||||
|
const { presentationId, title, format } = params;
|
||||||
const exportRoot = getExportPackageRoot();
|
const exportRoot = getExportPackageRoot();
|
||||||
const entrypoint = await resolveExportEntrypoint(exportRoot);
|
const entrypoint = await resolveExportEntrypoint(exportRoot);
|
||||||
const converter = bundledConverterPath(exportRoot);
|
const converter = bundledConverterPath(exportRoot);
|
||||||
|
|
@ -140,7 +143,7 @@ export async function runBundledPdfExport(params: {
|
||||||
const exportTask = {
|
const exportTask = {
|
||||||
type: "export",
|
type: "export",
|
||||||
url: pptUrl,
|
url: pptUrl,
|
||||||
format: "pdf",
|
format,
|
||||||
title: sanitizeFilename(title ?? "presentation"),
|
title: sanitizeFilename(title ?? "presentation"),
|
||||||
fastapiUrl: fastapiUrl || undefined,
|
fastapiUrl: fastapiUrl || undefined,
|
||||||
};
|
};
|
||||||
794
servers/nextjs/package-lock.json
generated
794
servers/nextjs/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -10,8 +10,10 @@
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/parser": "^7.28.4",
|
||||||
"@babel/standalone": "^7.28.2",
|
"@babel/standalone": "^7.28.2",
|
||||||
"@babel/traverse": "^7.29.0",
|
"@babel/traverse": "^7.29.0",
|
||||||
|
"@babel/types": "^7.28.4",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
|
@ -53,7 +55,6 @@
|
||||||
"next": "^14.2.14",
|
"next": "^14.2.14",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"puppeteer": "^24.13.0",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-colorful": "^5.6.1",
|
"react-colorful": "^5.6.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|
@ -73,7 +74,6 @@
|
||||||
"@types/canvas-confetti": "^1.9.0",
|
"@types/canvas-confetti": "^1.9.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/prismjs": "^1.26.5",
|
"@types/prismjs": "^1.26.5",
|
||||||
"@types/puppeteer": "^5.4.7",
|
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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];
|
|
||||||
};
|
|
||||||
|
|
@ -47,7 +47,6 @@ export enum MixpanelEvent {
|
||||||
Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked',
|
Header_Export_PPTX_Button_Clicked = 'Header Export PPTX Button Clicked',
|
||||||
Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call',
|
Header_UpdatePresentationContent_API_Call = 'Header Update Presentation Content API Call',
|
||||||
Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call',
|
Header_ExportAsPDF_API_Call = 'Header Export As PDF API Call',
|
||||||
Header_GetPptxModel_API_Call = 'Header Get PPTX Model API Call',
|
|
||||||
Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call',
|
Header_ExportAsPPTX_API_Call = 'Header Export As PPTX API Call',
|
||||||
Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked',
|
Slide_Add_New_Slide_Button_Clicked = 'Slide Add New Slide Button Clicked',
|
||||||
Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked',
|
Slide_Delete_Slide_Button_Clicked = 'Slide Delete Slide Button Clicked',
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue