CoolFace
Apppublic

chwellofficial/nt360Slides

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
handler.py708 linesDownload Raw Back to templates
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