chwellofficial/nt360Slides
0
1import os2import random3import re4import uuid5from datetime import datetime6from typing import Any, List, Optional7 8import aiohttp9from fastapi import Body, Depends, File, Form, HTTPException, Path, Query, UploadFile10from pydantic import BaseModel11from sqlalchemy import func12from sqlalchemy.ext.asyncio import AsyncSession13from sqlmodel import delete, select14 15from constants.presentation import DEFAULT_TEMPLATES16from models.sql.presentation_layout_code import PresentationLayoutCodeModel17from models.sql.template import TemplateModel18from models.sql.template_create_info import TemplateCreateInfoModel19from services.database import get_async_session20from services.export_task_service import EXPORT_TASK_SERVICE21from templates.example import build_template_example22from templates.get_layout_by_name import get_layout_by_name23from templates.presentation_layout import PresentationLayoutModel24from templates.preview import (25 FontsUploadAndSlidesPreviewResponse,26 upload_fonts_and_slides_preview_handler,27)28from templates.prompts import (29 SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,30 SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,31 SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,32)33from templates.providers import edit_slide_layout_code, generate_slide_layout_code34from utils.asset_directory_utils import (35 resolve_app_path_to_filesystem,36 resolve_image_path_to_filesystem,37)38 39 40class TemplateDetail(BaseModel):41 id: str42 name: str43 total_layouts: Optional[int] = None44 45 46class TemplateLayoutData(BaseModel):47 template: uuid.UUID48 layout_id: str49 layout_name: str50 layout_code: str51 fonts: Optional[Any] = None52 53 54class TemplateData(BaseModel):55 id: uuid.UUID56 init_id: Optional[uuid.UUID] = None57 name: str58 description: Optional[str] = None59 created_at: datetime60 61 62class GetTemplateLayoutsResponse(BaseModel):63 layouts: list[TemplateLayoutData]64 template: Optional[TemplateData] = None65 fonts: Optional[Any] = None66 67 68class TemplateExample(BaseModel):69 template: str70 slides: List[dict]71 72 73class CreateTemplateInitRequest(BaseModel):74 pptx_url: str75 slide_image_urls: List[str]76 fonts: dict = {}77 78 79class CreateSlideLayoutRequest(BaseModel):80 id: uuid.UUID81 index: int82 83 84class CreateSlideLayoutResponse(BaseModel):85 react_component: str86 87 88class EditSlideLayoutRequest(BaseModel):89 react_component: str90 prompt: str91 92 93class EditSlideLayoutResponse(CreateSlideLayoutResponse):94 pass95 96 97class EditSlideLayoutSectionRequest(BaseModel):98 react_component: str99 section: str100 prompt: str101 102 103class EditSlideLayoutSectionResponse(CreateSlideLayoutResponse):104 pass105 106 107class SaveTemplateLayoutData(BaseModel):108 layout_id: str109 layout_name: str110 layout_code: str111 112 113class SaveTemplateRequest(BaseModel):114 template_info_id: uuid.UUID115 name: str116 description: Optional[str] = None117 layouts: List[SaveTemplateLayoutData]118 119 120class SaveTemplateResponse(BaseModel):121 id: uuid.UUID122 name: str123 description: Optional[str] = None124 created_at: datetime125 126 127class CloneTemplateRequest(BaseModel):128 id: str129 name: str130 description: Optional[str] = None131 132 133class UpdateTemplateRequest(BaseModel):134 id: uuid.UUID135 layouts: List[SaveTemplateLayoutData]136 137 138class SaveSlideLayoutRequest(BaseModel):139 template_id: uuid.UUID140 layout_id: str141 layout_code: str142 143 144class CloneSlideLayoutRequest(BaseModel):145 template_id: str146 layout_id: str147 layout_name: Optional[str] = None148 149 150def _strip_code_fences(value: str) -> str:151 return (152 value.replace("```tsx", "")153 .replace("```typescript", "")154 .replace("```ts", "")155 .replace("```", "")156 .strip()157 )158 159 160def _normalize_layout_code_for_create(code: str) -> str:161 normalized = _strip_code_fences(code)162 normalized = (163 normalized.replace("image_url", "__image_url__")164 .replace("icon_url", "__icon_url__")165 .replace("image_prompt", "__image_prompt__")166 .replace("icon_query", "__icon_query__")167 )168 169 first_import_match = re.search(r"(?m)^\s*import\b", normalized)170 if first_import_match:171 normalized = normalized[first_import_match.start() :]172 173 first_export_match = re.search(r"(?m)^\s*export\b", normalized)174 if first_export_match:175 normalized = normalized[: first_export_match.start()]176 177 normalized = re.sub(178 r"(?ms)^\s*(?:import|export)\b.*?;(?:\r?\n|$)",179 "",180 normalized,181 )182 normalized = re.sub(183 r"(?m)^\s*(?:import|export)\b.*(?:\r?\n|$)",184 "",185 normalized,186 )187 normalized = normalized.strip()188 normalized = re.sub(189 r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',190 lambda match: (191 match.group(0)192 if re.search(r"-\d{4}$", match.group(2))193 else f"{match.group(1)}{match.group(2)}-{random.randint(1000, 9999)}{match.group(3)}"194 ),195 normalized,196 )197 return normalized198 199 200def _update_layout_id_in_code(code: str) -> tuple[str, str]:201 match = re.search(r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])', code)202 if not match:203 raise HTTPException(status_code=400, detail="layoutId not found in layout code")204 205 current_id = match.group(2)206 suffix = f"{random.randint(1000, 9999)}"207 new_id = re.sub(r"-\d{4}$", f"-{suffix}", current_id)208 if new_id == current_id:209 new_id = f"{current_id}-{suffix}"210 211 new_code = re.sub(212 r'(layoutId\s*=\s*["\'])([^"\']+)(["\'])',213 f"\\1{new_id}\\3",214 code,215 count=1,216 )217 return new_code, new_id218 219 220async def _download_image_bytes(image_url: str) -> bytes:221 async with aiohttp.ClientSession() as session:222 async with session.get(image_url) as response:223 if response.status != 200:224 raise HTTPException(225 status_code=400,226 detail=f"Failed to download slide image: {image_url}",227 )228 return await response.read()229 230 231async def _read_image_bytes_and_media_type(image_url: str) -> tuple[bytes, str]:232 actual_image_path = resolve_image_path_to_filesystem(image_url)233 if actual_image_path and os.path.isfile(actual_image_path):234 with open(actual_image_path, "rb") as image_file:235 image_bytes = image_file.read()236 file_extension = os.path.splitext(actual_image_path)[1].lower()237 else:238 image_bytes = await _download_image_bytes(image_url)239 file_extension = os.path.splitext(image_url)[1].lower()240 241 media_type_map = {242 ".png": "image/png",243 ".jpg": "image/jpeg",244 ".jpeg": "image/jpeg",245 ".gif": "image/gif",246 ".webp": "image/webp",247 }248 return image_bytes, media_type_map.get(file_extension, "image/png")249 250 251async def get_all_templates(252 include_defaults: bool = Query(253 default=True, description="Whether to include default templates"254 ),255 sql_session: AsyncSession = Depends(get_async_session),256):257 result = await sql_session.execute(258 select(259 TemplateModel.id,260 TemplateModel.name,261 func.count(PresentationLayoutCodeModel.id).label("total_layouts"),262 )263 .join(264 PresentationLayoutCodeModel,265 PresentationLayoutCodeModel.presentation == TemplateModel.id,266 )267 .group_by(TemplateModel.id, TemplateModel.name)268 )269 rows = result.all()270 271 templates: list[TemplateDetail] = []272 if include_defaults:273 templates.extend(274 TemplateDetail(id=template, name=template) for template in DEFAULT_TEMPLATES275 )276 277 templates.extend(278 TemplateDetail(279 id=f"custom-{template_id}",280 name=template_name,281 total_layouts=total_layouts,282 )283 for template_id, template_name, total_layouts in rows284 )285 return templates286 287 288async def get_layouts(289 template_id: str = Path(..., description="The id of the template"),290 session: AsyncSession = Depends(get_async_session),291):292 if not template_id or not template_id.strip():293 raise HTTPException(status_code=400, detail="Template ID cannot be empty")294 295 try:296 cleaned_template_id = template_id.replace("custom-", "")297 template_id_uuid = uuid.UUID(cleaned_template_id)298 except Exception as exc:299 raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc300 301 result = await session.execute(302 select(PresentationLayoutCodeModel).where(303 PresentationLayoutCodeModel.presentation == template_id_uuid304 )305 )306 layouts_db = result.scalars().all()307 if not layouts_db:308 raise HTTPException(309 status_code=404,310 detail=f"No layouts found for template ID: {template_id}",311 )312 313 template_meta = await session.get(TemplateModel, template_id_uuid)314 template = None315 if template_meta:316 template = TemplateData(317 id=template_id_uuid,318 init_id=None,319 name=template_meta.name,320 description=template_meta.description,321 created_at=template_meta.created_at,322 )323 324 layouts = [325 TemplateLayoutData(326 template=template_id_uuid,327 layout_id=layout.layout_id,328 layout_name=layout.layout_name,329 layout_code=layout.layout_code,330 fonts=layout.fonts,331 )332 for layout in layouts_db333 ]334 return GetTemplateLayoutsResponse(335 layouts=layouts,336 template=template,337 fonts=layouts[0].fonts if layouts else None,338 )339 340 341async def get_template_by_id(342 id: str = Path(343 ...,344 description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",345 ),346 sql_session: AsyncSession = Depends(get_async_session),347):348 if id.startswith("custom-"):349 try:350 template_id = uuid.UUID(id.replace("custom-", ""))351 except Exception as exc:352 raise HTTPException(353 status_code=400,354 detail="Template not found. Please use a valid template.",355 ) from exc356 357 template = await sql_session.get(TemplateModel, template_id)358 if not template:359 raise HTTPException(360 status_code=400,361 detail="Template not found. Please use a valid template.",362 )363 364 return await get_layout_by_name(id)365 366 367async def get_template_example(368 id: str = Path(369 ...,370 description=f"The id of the template, must be one of {', '.join(DEFAULT_TEMPLATES)} or your custom template",371 ),372 sql_session: AsyncSession = Depends(get_async_session),373):374 template = await get_template_by_id(id=id, sql_session=sql_session)375 return TemplateExample(**build_template_example(id, template))376 377 378async def upload_fonts_and_slides_preview(379 pptx_file: UploadFile = File(..., description="PPTX file to preview"),380 font_files: Optional[List[UploadFile]] = File(381 default=None, description="Font files to upload"382 ),383 original_font_names: Optional[List[str]] = Form(default=None),384):385 return await upload_fonts_and_slides_preview_handler(386 pptx_file=pptx_file,387 font_files=font_files,388 original_font_names=original_font_names,389 max_slides=25,390 )391 392 393async def init_create_template(394 request: CreateTemplateInitRequest,395 sql_session: AsyncSession = Depends(get_async_session),396):397 if not request.slide_image_urls:398 raise HTTPException(399 status_code=400, detail="At least one slide image is required"400 )401 402 pptx_path = resolve_app_path_to_filesystem(request.pptx_url)403 if not pptx_path or not os.path.isfile(pptx_path):404 raise HTTPException(status_code=400, detail="PPTX file not found")405 406 pptx_document = await EXPORT_TASK_SERVICE.convert_pptx_to_html(407 pptx_path, get_fonts=False408 )409 if not pptx_document.slides:410 raise HTTPException(411 status_code=500,412 detail="PPTX-to-HTML export returned no slides",413 )414 415 if len(pptx_document.slides) < len(request.slide_image_urls):416 raise HTTPException(417 status_code=400,418 detail=(419 "PPTX-to-HTML export returned fewer slides than the preview images. "420 f"Expected at least {len(request.slide_image_urls)}, got {len(pptx_document.slides)}."421 ),422 )423 424 slide_htmls = pptx_document.slides[: len(request.slide_image_urls)]425 template_create_info = TemplateCreateInfoModel(426 fonts=request.fonts or {},427 pptx_url=request.pptx_url,428 slide_image_urls=request.slide_image_urls,429 slide_htmls=slide_htmls,430 )431 sql_session.add(template_create_info)432 await sql_session.commit()433 await sql_session.refresh(template_create_info)434 return template_create_info.id435 436 437async def create_slide_layout(438 request: CreateSlideLayoutRequest = Body(...),439 sql_session: AsyncSession = Depends(get_async_session),440):441 template_info = await sql_session.get(TemplateCreateInfoModel, request.id)442 if not template_info:443 raise HTTPException(status_code=400, detail="Template not found")444 445 total_slides = len(template_info.slide_htmls)446 if request.index < 0 or request.index >= total_slides:447 raise HTTPException(status_code=400, detail="Invalid slide index")448 449 slide_html = template_info.slide_htmls[request.index]450 slide_image_url = template_info.slide_image_urls[request.index]451 image_bytes, media_type = await _read_image_bytes_and_media_type(slide_image_url)452 453 fonts_text = ""454 if template_info.fonts:455 font_names = [font.replace(" ", "_") for font in template_info.fonts.keys()]456 fonts_text = "#PROVIDED FONTS\n- " + "\n- ".join(font_names)457 458 user_text = f"{fonts_text}\n\n#SLIDE HTML REFERENCE\n{slide_html}"459 react_component = await generate_slide_layout_code(460 system_prompt=SLIDE_LAYOUT_CREATION_SYSTEM_PROMPT,461 user_text=user_text,462 image_bytes=image_bytes,463 media_type=media_type,464 )465 normalized_react_component = _normalize_layout_code_for_create(react_component)466 467 return CreateSlideLayoutResponse(react_component=normalized_react_component)468 469 470async def edit_slide_layout(471 request: EditSlideLayoutRequest,472):473 user_text = f"#Prompt\n{request.prompt}\n\n#TSX code\n{request.react_component}"474 react_component = await edit_slide_layout_code(475 system_prompt=SLIDE_LAYOUT_EDIT_SYSTEM_PROMPT,476 user_text=user_text,477 )478 return EditSlideLayoutResponse(react_component=_strip_code_fences(react_component))479 480 481async def edit_slide_layout_section(482 request: EditSlideLayoutSectionRequest,483):484 user_text = (485 f"#Prompt\n{request.prompt}\n\n"486 f"#Section to make changes around\n{request.section}\n\n"487 f"#TSX code\n{request.react_component}"488 )489 react_component = await edit_slide_layout_code(490 system_prompt=SLIDE_LAYOUT_EDIT_SECTION_SYSTEM_PROMPT,491 user_text=user_text,492 )493 return EditSlideLayoutSectionResponse(494 react_component=_strip_code_fences(react_component)495 )496 497 498async def save_template(499 request: SaveTemplateRequest,500 sql_session: AsyncSession = Depends(get_async_session),501):502 if not request.layouts:503 raise HTTPException(status_code=400, detail="Layouts are required")504 505 template_info = await sql_session.get(TemplateCreateInfoModel, request.template_info_id)506 if not template_info:507 raise HTTPException(status_code=400, detail="Template info not found")508 509 template = TemplateModel(510 id=uuid.uuid4(),511 name=request.name,512 description=request.description,513 )514 sql_session.add(template)515 516 sql_session.add_all(517 [518 PresentationLayoutCodeModel(519 presentation=template.id,520 layout_id=layout.layout_id,521 layout_name=layout.layout_name,522 layout_code=layout.layout_code,523 fonts=template_info.fonts,524 )525 for layout in request.layouts526 ]527 )528 await sql_session.commit()529 await sql_session.refresh(template)530 531 return SaveTemplateResponse(532 id=template.id,533 name=template.name,534 description=template.description,535 created_at=template.created_at,536 )537 538 539async def clone_template(540 request: CloneTemplateRequest = Body(...),541 sql_session: AsyncSession = Depends(get_async_session),542):543 if not request.id or not request.id.strip():544 raise HTTPException(status_code=400, detail="Template ID cannot be empty")545 546 try:547 template_id_uuid = uuid.UUID(request.id.replace("custom-", ""))548 except Exception as exc:549 raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc550 551 template = await sql_session.get(TemplateModel, template_id_uuid)552 if not template:553 raise HTTPException(554 status_code=400,555 detail="Template not found. Please use a valid template.",556 )557 558 result = await sql_session.execute(559 select(PresentationLayoutCodeModel).where(560 PresentationLayoutCodeModel.presentation == template_id_uuid561 )562 )563 layouts_db = result.scalars().all()564 if not layouts_db:565 raise HTTPException(status_code=400, detail="No layouts found for template")566 567 new_template = TemplateModel(568 id=uuid.uuid4(),569 name=request.name,570 description=template.description571 if request.description is None572 else request.description,573 )574 sql_session.add(new_template)575 576 sql_session.add_all(577 [578 PresentationLayoutCodeModel(579 presentation=new_template.id,580 layout_id=layout.layout_id,581 layout_name=layout.layout_name,582 layout_code=layout.layout_code,583 fonts=layout.fonts,584 )585 for layout in layouts_db586 ]587 )588 await sql_session.commit()589 await sql_session.refresh(new_template)590 591 return SaveTemplateResponse(592 id=new_template.id,593 name=new_template.name,594 description=new_template.description,595 created_at=new_template.created_at,596 )597 598 599async def update_template(600 request: UpdateTemplateRequest,601 sql_session: AsyncSession = Depends(get_async_session),602):603 if not request.layouts:604 raise HTTPException(status_code=400, detail="Layouts are required")605 606 template = await sql_session.get(TemplateModel, request.id)607 if not template:608 raise HTTPException(status_code=400, detail="Template not found")609 610 existing_layout = await sql_session.scalar(611 select(PresentationLayoutCodeModel).where(612 PresentationLayoutCodeModel.presentation == request.id613 )614 )615 fonts = existing_layout.fonts if existing_layout else None616 617 await sql_session.execute(618 delete(PresentationLayoutCodeModel).where(619 PresentationLayoutCodeModel.presentation == request.id620 )621 )622 sql_session.add_all(623 [624 PresentationLayoutCodeModel(625 presentation=template.id,626 layout_id=layout.layout_id,627 layout_name=layout.layout_name,628 layout_code=layout.layout_code,629 fonts=fonts,630 )631 for layout in request.layouts632 ]633 )634 await sql_session.commit()635 636 return SaveTemplateResponse(637 id=template.id,638 name=template.name,639 description=template.description,640 created_at=template.created_at,641 )642 643 644async def save_slide_layout(645 request: SaveSlideLayoutRequest,646 sql_session: AsyncSession = Depends(get_async_session),647):648 template = await sql_session.get(TemplateModel, request.template_id)649 if not template:650 raise HTTPException(status_code=400, detail="Template not found")651 652 layout = await sql_session.scalar(653 select(PresentationLayoutCodeModel).where(654 PresentationLayoutCodeModel.presentation == request.template_id,655 PresentationLayoutCodeModel.layout_id == request.layout_id,656 )657 )658 if not layout:659 raise HTTPException(status_code=400, detail="Layout not found")660 661 layout.layout_code = request.layout_code662 sql_session.add(layout)663 await sql_session.commit()664 665 666async def clone_slide_layout(667 request: CloneSlideLayoutRequest = Body(...),668 sql_session: AsyncSession = Depends(get_async_session),669):670 if not request.template_id or not request.template_id.strip():671 raise HTTPException(status_code=400, detail="Template ID cannot be empty")672 673 try:674 template_id_uuid = uuid.UUID(request.template_id.replace("custom-", ""))675 except Exception as exc:676 raise HTTPException(status_code=400, detail="Invalid custom template ID") from exc677 678 template = await sql_session.get(TemplateModel, template_id_uuid)679 if not template:680 raise HTTPException(status_code=400, detail="Template not found")681 682 layout = await sql_session.scalar(683 select(PresentationLayoutCodeModel).where(684 PresentationLayoutCodeModel.presentation == template_id_uuid,685 PresentationLayoutCodeModel.layout_id == request.layout_id,686 )687 )688 if not layout:689 raise HTTPException(status_code=400, detail="Layout not found")690 691 new_layout_code, new_layout_id = _update_layout_id_in_code(layout.layout_code)692 new_layout = PresentationLayoutCodeModel(693 presentation=template_id_uuid,694 layout_id=new_layout_id,695 layout_name=request.layout_name or layout.layout_name,696 layout_code=new_layout_code,697 fonts=layout.fonts,698 )699 sql_session.add(new_layout)700 await sql_session.commit()701 await sql_session.refresh(new_layout)702 703 return SaveTemplateLayoutData(704 layout_id=new_layout.layout_id,705 layout_name=new_layout.layout_name,706 layout_code=new_layout.layout_code,707 )708 