ppt-tool/backend/api/v1/ppt/endpoints/fonts.py
Vadym Samoilenko cf21ba4516 Phase 1-2: Foundation + Admin Panel & Client Management
Phase 1 (Foundation):
- Project restructure (presenton-main → backend/ + frontend/)
- Database schema (8 new models, Alembic config, seed script)
- Auth (Azure AD SSO + dev bypass, JWT sessions, AuthMiddleware)
- RBAC (access_service, rbac_middleware, admin routers)
- Audit logging (fire-and-forget, AuditMiddleware, admin router)
- i18n (react-i18next with 5 namespace files)

Phase 2 (Admin Panel & Client Management):
- Admin panel shell (sidebar layout, role guard, 12 pages)
- Redux admin slice with 18 async thunks
- User management (role changes, deactivation)
- Client management (CRUD, brand config, team management)
- Brand config editor (colors, fonts, logos, voice rules)
- Master deck upload & parser (PPTX → HTML → React pipeline)
- Audit log viewer with filters and CSV/JSON export

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 15:37:17 +00:00

290 lines
No EOL
9.6 KiB
Python

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
import 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}_{str(uuid.uuid4())[: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)}"
)