From 3e14c0a7e8497b7d556da050b8df287cfe75da98 Mon Sep 17 00:00:00 2001 From: Suraj Jha Date: Fri, 1 Aug 2025 16:18:49 +0545 Subject: [PATCH] feat: add font endpoint --- nginx.conf | 6 + servers/fastapi/api/main.py | 15 + .../fastapi/api/v1/ppt/endpoints/__init__.py | 0 servers/fastapi/api/v1/ppt/endpoints/fonts.py | 290 ++++++++++++++++++ servers/fastapi/api/v1/ppt/router.py | 6 +- servers/fastapi/requirements.txt | 1 + servers/nextjs/next.config.mjs | 9 + 7 files changed, 325 insertions(+), 2 deletions(-) create mode 100644 servers/fastapi/api/v1/ppt/endpoints/__init__.py create mode 100644 servers/fastapi/api/v1/ppt/endpoints/fonts.py diff --git a/nginx.conf b/nginx.conf index d5d944f9..f10fee18 100644 --- a/nginx.conf +++ b/nginx.conf @@ -66,5 +66,11 @@ http { expires 1y; add_header Cache-Control "public, immutable"; } + + location /app_data/fonts/ { + alias /app_data/fonts/; + expires 1y; + add_header Cache-Control "public, immutable"; + } } } \ No newline at end of file diff --git a/servers/fastapi/api/main.py b/servers/fastapi/api/main.py index 51eb2f02..0aa4adb7 100644 --- a/servers/fastapi/api/main.py +++ b/servers/fastapi/api/main.py @@ -5,6 +5,8 @@ from api.lifespan import app_lifespan from api.middlewares import UserConfigEnvUpdateMiddleware from api.v1.ppt.router import API_V1_PPT_ROUTER from utils.asset_directory_utils import get_exports_directory, get_images_directory, get_uploads_directory +import os +from utils.get_env import get_app_data_directory_env # Import models to ensure they are registered with SQLModel from models.sql.presentation_layout_code import PresentationLayoutCodeModel @@ -15,6 +17,14 @@ app = FastAPI(lifespan=app_lifespan) # Routers app.include_router(API_V1_PPT_ROUTER) +# Helper function to get fonts directory +def get_fonts_directory() -> str: + """Get the fonts directory path, create if it doesn't exist""" + app_data_dir = get_app_data_directory_env() or "/tmp/presenton" + fonts_dir = os.path.join(app_data_dir, "fonts") + os.makedirs(fonts_dir, exist_ok=True) + return fonts_dir + # Static files app.mount("/static", StaticFiles(directory="static"), name="static") app.mount( @@ -32,6 +42,11 @@ app.mount( StaticFiles(directory=get_uploads_directory()), name="app_data/uploads", ) +app.mount( + "/app_data/fonts", + StaticFiles(directory=get_fonts_directory()), + name="app_data/fonts", +) # Middlewares diff --git a/servers/fastapi/api/v1/ppt/endpoints/__init__.py b/servers/fastapi/api/v1/ppt/endpoints/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/servers/fastapi/api/v1/ppt/endpoints/fonts.py b/servers/fastapi/api/v1/ppt/endpoints/fonts.py new file mode 100644 index 00000000..9b14fe3a --- /dev/null +++ b/servers/fastapi/api/v1/ppt/endpoints/fonts.py @@ -0,0 +1,290 @@ +import os +import uuid +import shutil +from pathlib import Path +from typing import List, Dict, Any, Optional +from fastapi import APIRouter, HTTPException, File, UploadFile +from pydantic import BaseModel +from utils.asset_directory_utils import get_app_data_directory_env +from utils.randomizers import get_random_uuid + +try: + from fontTools.ttLib import TTFont + from fontTools.ttLib.tables._n_a_m_e import table__n_a_m_e + FONTTOOLS_AVAILABLE = True +except ImportError: + FONTTOOLS_AVAILABLE = False + +FONTS_ROUTER = APIRouter(prefix="/fonts", tags=["fonts"]) + +# Supported font file extensions +SUPPORTED_FONT_EXTENSIONS = { + '.ttf': 'font/ttf', + '.otf': 'font/otf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', + '.eot': 'application/vnd.ms-fontobject' +} + +class FontUploadResponse(BaseModel): + success: bool + font_name: str + font_url: str + font_path: str + message: Optional[str] = None + +class FontListResponse(BaseModel): + success: bool + fonts: List[dict] + message: Optional[str] = None + + +def get_fonts_directory() -> str: + """Get the fonts directory path, create if it doesn't exist""" + app_data_dir = get_app_data_directory_env() or "/tmp/presenton" + fonts_dir = os.path.join(app_data_dir, "fonts") + os.makedirs(fonts_dir, exist_ok=True) + return fonts_dir + + +def is_valid_font_file(file: UploadFile) -> bool: + """Validate font file by extension and MIME type""" + if not file.filename: + return False + + file_ext = os.path.splitext(file.filename)[1].lower() + if file_ext not in SUPPORTED_FONT_EXTENSIONS: + return False + + # Check MIME type + content_type = file.content_type or "" + valid_mime_types = [ + "font/ttf", "font/otf", "font/woff", "font/woff2", + "application/font-ttf", "application/font-otf", + "application/font-woff", "application/font-woff2", + "application/x-font-ttf", "application/x-font-otf", + "font/truetype", "font/opentype" + ] + + return content_type in valid_mime_types + + +def extract_font_name_from_file(file_path: str) -> str: + """Extract the actual font family name from font file metadata""" + if not FONTTOOLS_AVAILABLE: + # Fallback to filename parsing if fonttools not available + filename = os.path.basename(file_path) + base_name = os.path.splitext(filename)[0] + if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8: + # Remove UUID part + parts = filename.split('_') + if len(parts) > 1: + return '_'.join(parts[:-1]) + return base_name + + try: + font = TTFont(file_path) + + # Try to get font family name from name table + if 'name' in font: + name_table = font['name'] + + # Preferred order: Family name (ID 1), then Full name (ID 4), then PostScript name (ID 6) + for name_id in [1, 4, 6]: + for record in name_table.names: + if record.nameID == name_id: + # Prefer English names + if record.langID == 0x409 or record.langID == 0: # English + font_name = record.toUnicode().strip() + if font_name: + font.close() + return font_name + + # If no English name found, use any available family name + for record in name_table.names: + if record.nameID == 1: # Family name + font_name = record.toUnicode().strip() + if font_name: + font.close() + return font_name + + font.close() + except Exception as e: + # If font parsing fails, fallback to filename + print(f"Error reading font metadata from {file_path}: {e}") + + # Fallback to filename parsing + filename = os.path.basename(file_path) + base_name = os.path.splitext(filename)[0] + if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8: + # Remove UUID part + parts = filename.split('_') + if len(parts) > 1: + return '_'.join(parts[:-1]) + return base_name + + +@FONTS_ROUTER.post("/upload", response_model=FontUploadResponse) +async def upload_font( + font_file: UploadFile = File(..., description="Font file to upload (.ttf, .otf, .woff, .woff2, .eot)") +): + """ + Upload a font file and save it to the fonts directory. + + Args: + font_file: Uploaded font file + + Returns: + FontUploadResponse with font details and accessible URL + + Raises: + HTTPException: If file validation fails or upload error occurs + """ + try: + # Validate file + if not font_file.filename: + raise HTTPException( + status_code=400, + detail="No file name provided" + ) + + if not is_valid_font_file(font_file): + raise HTTPException( + status_code=400, + detail=f"Invalid font file. Supported formats: {', '.join(SUPPORTED_FONT_EXTENSIONS.keys())}" + ) + + # Generate unique filename to avoid conflicts + file_ext = os.path.splitext(font_file.filename)[1].lower() + base_name = os.path.splitext(font_file.filename)[0] + unique_filename = f"{base_name}_{get_random_uuid()[:8]}{file_ext}" + + # Get fonts directory + fonts_dir = get_fonts_directory() + font_path = os.path.join(fonts_dir, unique_filename) + + # Save the uploaded file + with open(font_path, "wb") as buffer: + shutil.copyfileobj(font_file.file, buffer) + + # Generate accessible URL + font_url = f"/app_data/fonts/{unique_filename}" + + return FontUploadResponse( + success=True, + font_name=base_name, + font_url=font_url, + font_path=font_path, + message=f"Font '{base_name}' uploaded successfully" + ) + + except HTTPException: + # Re-raise HTTP exceptions as-is + raise + except Exception as e: + print(f"Error uploading font: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error uploading font: {str(e)}" + ) + + +@FONTS_ROUTER.get("/list", response_model=FontListResponse) +async def list_fonts(): + """ + List all uploaded fonts with their accessible URLs. + + Returns: + FontListResponse with list of available fonts + """ + try: + fonts_dir = get_fonts_directory() + fonts = [] + + # Get all font files in the directory + if os.path.exists(fonts_dir): + for filename in os.listdir(fonts_dir): + file_path = os.path.join(fonts_dir, filename) + + if os.path.isfile(file_path): + file_ext = os.path.splitext(filename)[1].lower() + + if file_ext in SUPPORTED_FONT_EXTENSIONS: + # Get the real font name from file metadata + font_name = extract_font_name_from_file(file_path) + + # Extract original name (remove UUID suffix for display) + base_name = filename + if '_' in filename and len(filename.split('_')[-1].split('.')[0]) == 8: + # Remove UUID part for original_name display + parts = filename.split('_') + if len(parts) > 1: + base_name = '_'.join(parts[:-1]) + file_ext + + fonts.append({ + "filename": filename, + "font_name": font_name, # Real font family name from metadata + "original_name": base_name, + "font_url": f"/app_data/fonts/{filename}", + "font_type": SUPPORTED_FONT_EXTENSIONS.get(file_ext, 'unknown'), + "file_size": os.path.getsize(file_path) + }) + + return FontListResponse( + success=True, + fonts=fonts, + message=f"Found {len(fonts)} font files" + ) + + except Exception as e: + print(f"Error listing fonts: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error listing fonts: {str(e)}" + ) + + +@FONTS_ROUTER.delete("/delete/{filename}") +async def delete_font(filename: str): + """ + Delete a font file from the fonts directory. + + Args: + filename: Name of the font file to delete + + Returns: + Success message + """ + try: + fonts_dir = get_fonts_directory() + font_path = os.path.join(fonts_dir, filename) + + if not os.path.exists(font_path): + raise HTTPException( + status_code=404, + detail=f"Font file '{filename}' not found" + ) + + # Validate it's actually a font file before deleting + file_ext = os.path.splitext(filename.lower())[1] + if file_ext not in SUPPORTED_FONT_EXTENSIONS: + raise HTTPException( + status_code=400, + detail="File is not a recognized font format" + ) + + os.remove(font_path) + + return { + "success": True, + "message": f"Font '{filename}' deleted successfully" + } + + except HTTPException: + raise + except Exception as e: + print(f"Error deleting font: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error deleting font: {str(e)}" + ) \ No newline at end of file diff --git a/servers/fastapi/api/v1/ppt/router.py b/servers/fastapi/api/v1/ppt/router.py index 5bbea0c7..cad80228 100644 --- a/servers/fastapi/api/v1/ppt/router.py +++ b/servers/fastapi/api/v1/ppt/router.py @@ -2,6 +2,7 @@ from fastapi import APIRouter from api.v1.ppt.endpoints.custom_llm import CUSTOM_LLM_ROUTER from api.v1.ppt.endpoints.files import FILES_ROUTER +from api.v1.ppt.endpoints.fonts import FONTS_ROUTER from api.v1.ppt.endpoints.icons import ICONS_ROUTER from api.v1.ppt.endpoints.images import IMAGES_ROUTER from api.v1.ppt.endpoints.ollama import OLLAMA_ROUTER @@ -15,6 +16,7 @@ from api.v1.ppt.endpoints.slide_to_html import SLIDE_TO_HTML_ROUTER, HTML_TO_REA API_V1_PPT_ROUTER = APIRouter(prefix="/api/v1/ppt") API_V1_PPT_ROUTER.include_router(FILES_ROUTER) +API_V1_PPT_ROUTER.include_router(FONTS_ROUTER) API_V1_PPT_ROUTER.include_router(OUTLINES_ROUTER) API_V1_PPT_ROUTER.include_router(PRESENTATION_ROUTER) API_V1_PPT_ROUTER.include_router(PPTX_SLIDES_ROUTER) @@ -22,8 +24,8 @@ API_V1_PPT_ROUTER.include_router(SLIDE_ROUTER) API_V1_PPT_ROUTER.include_router(SLIDE_TO_HTML_ROUTER) API_V1_PPT_ROUTER.include_router(HTML_TO_REACT_ROUTER) API_V1_PPT_ROUTER.include_router(HTML_EDIT_ROUTER) +API_V1_PPT_ROUTER.include_router(LAYOUT_MANAGEMENT_ROUTER) API_V1_PPT_ROUTER.include_router(IMAGES_ROUTER) API_V1_PPT_ROUTER.include_router(ICONS_ROUTER) API_V1_PPT_ROUTER.include_router(OLLAMA_ROUTER) -API_V1_PPT_ROUTER.include_router(CUSTOM_LLM_ROUTER) -API_V1_PPT_ROUTER.include_router(LAYOUT_MANAGEMENT_ROUTER) \ No newline at end of file +API_V1_PPT_ROUTER.include_router(CUSTOM_LLM_ROUTER) \ No newline at end of file diff --git a/servers/fastapi/requirements.txt b/servers/fastapi/requirements.txt index 685964dd..bb9de363 100644 --- a/servers/fastapi/requirements.txt +++ b/servers/fastapi/requirements.txt @@ -30,6 +30,7 @@ fastapi-cloud-cli==0.1.4 fastembed==0.7.1 filelock==3.18.0 flatbuffers==25.2.10 +fonttools==4.59.0 frozenlist==1.7.0 fsspec==2025.7.0 google-auth==2.40.3 diff --git a/servers/nextjs/next.config.mjs b/servers/nextjs/next.config.mjs index e19c31de..31875cb0 100644 --- a/servers/nextjs/next.config.mjs +++ b/servers/nextjs/next.config.mjs @@ -9,6 +9,15 @@ const nextConfig = { reactStrictMode: false, + // Rewrites for development - proxy font requests to FastAPI backend + async rewrites() { + return [ + { + source: '/app_data/fonts/:path*', + destination: 'http://localhost:8000/app_data/fonts/:path*', + }, + ]; + }, images: { remotePatterns: [