项目文件夹

文件
2026-07-13 13:08:41 +08:00

1918 行
65 KiB
Python

import asyncio
import base64
import contextlib
import html
import mimetypes
import os
import re
import urllib.parse
import tempfile
import uuid
import shutil
import zipfile
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, List, Optional, Set, Tuple
from fastapi import HTTPException, UploadFile
from pydantic import BaseModel
from services.export_task_service import EXPORT_TASK_SERVICE
from templates.pptx_font_utils import (
FontDetail,
_font_style_variant,
build_google_fonts_stylesheet_url,
check_google_font_availability,
extract_used_font_variants_from_pptx,
extract_used_fonts_from_pptx,
get_available_and_unavailable_fonts_for_pptx,
convert_eot_to_ttf,
extract_font_name_from_file,
extract_raw_fonts_and_embedded_details,
get_font_details,
get_google_font_file_urls,
get_index_of_matching_font_detail_or_none,
normalize_font_family_name,
normalize_font_variants,
replace_fonts_in_pptx,
)
from utils.asset_directory_utils import (
absolute_fastapi_asset_url,
resolve_app_path_to_filesystem,
)
from utils.download_helpers import download_file
from utils.get_env import get_app_data_directory_env
from utils.font_uploads import (
download_font_uploads,
get_font_upload_url,
get_font_uploads_for_names_by_variant,
persist_font_file,
)
class FontInfo(BaseModel):
name: str
url: str | None = None
original_name: str | None = None
family_name: str | None = None
variant: str | None = None
variants: List[str] | None = None
class FontCheckResponse(BaseModel):
available_fonts: List[FontInfo]
unavailable_fonts: List[FontInfo]
FontInfoData = Tuple[str, Optional[str]] | Tuple[str, Optional[str], List[str]]
class FontsUploadAndSlidesPreviewResponse(BaseModel):
slide_image_urls: List[str]
pptx_url: str
modified_pptx_url: str
fonts: dict
def _selected_google_font_maps(
google_font_original_names: Optional[List[str]],
google_font_replacement_names: Optional[List[str]],
google_font_names: Optional[List[str]],
google_font_urls: Optional[List[str]],
) -> Tuple[Dict[str, str], Dict[str, str]]:
replacement_names = google_font_replacement_names or google_font_names
num_original_names = len(google_font_original_names or [])
num_replacement_names = len(replacement_names or [])
num_urls = len(google_font_urls or [])
if (num_replacement_names and not num_urls) or (
num_urls and not num_replacement_names
):
raise HTTPException(
status_code=400,
detail="Both Google font replacement names and URLs must be provided together",
)
if num_replacement_names != num_urls:
raise HTTPException(
status_code=400,
detail="Number of Google font replacement names must match number of Google font URLs",
)
if num_original_names and num_original_names != num_replacement_names:
raise HTTPException(
status_code=400,
detail="Number of Google font original names must match number of Google font replacements",
)
selected_fonts: Dict[str, str] = {}
replacements: Dict[str, str] = {}
if google_font_original_names:
for original_name, replacement_name, url in zip(
google_font_original_names,
replacement_names or [],
google_font_urls or [],
):
original_name = original_name.strip()
replacement_name = replacement_name.strip()
url = url.strip()
if not original_name or not replacement_name or not url:
continue
selected_fonts[replacement_name] = url
replacements[original_name] = replacement_name
return selected_fonts, replacements
for replacement_name, url in zip(replacement_names or [], google_font_urls or []):
replacement_name = replacement_name.strip()
url = url.strip()
if replacement_name and url:
selected_fonts[replacement_name] = url
return selected_fonts, replacements
class _PreviewLogger:
def info(self, message: str):
print(f"[fonts-preview] {message}")
def warning(self, message: str):
print(f"[fonts-preview] WARNING: {message}")
def error(self, message: str):
print(f"[fonts-preview] ERROR: {message}")
PREVIEW_WIDTH = 1280
PREVIEW_HEIGHT = 720
TAILWIND_CDN_URL = "https://cdn.tailwindcss.com"
DEFAULT_CHART_JS_URL = "https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"
MAX_TEMPLATE_PREVIEW_SLIDES = 50
MAX_FONT_CHECK_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024
FONT_CHECK_UPLOAD_SIZE_ERROR = "File size must be less than 100MB."
INVALID_PPTX_UPLOAD_ERROR = (
"The uploaded PowerPoint file is corrupted or unsupported."
)
PPT_NS = {
"p": "http://schemas.openxmlformats.org/presentationml/2006/main",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
}
REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
ET.register_namespace("p", PPT_NS["p"])
ET.register_namespace("r", PPT_NS["r"])
def _preview_dimensions_from_document(width: float, height: float) -> Tuple[int, int]:
try:
resolved_width = int(round(float(width)))
resolved_height = int(round(float(height)))
except (TypeError, ValueError):
return PREVIEW_WIDTH, PREVIEW_HEIGHT
if resolved_width <= 0 or resolved_height <= 0:
return PREVIEW_WIDTH, PREVIEW_HEIGHT
return resolved_width, resolved_height
def _css_string(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _font_weight_for_css(font_detail: FontDetail, variant: str) -> int:
if font_detail.weight_class is not None:
return int(font_detail.weight_class)
if "bold" in variant:
return 700
return 400
def _font_style_for_css(variant: str) -> str:
if "italic" in variant:
return "italic"
return "normal"
def _font_face_css_for_local_fonts(font_paths: List[str]) -> str:
rules: List[str] = []
seen: Set[Tuple[str, str]] = set()
for font_path in font_paths:
if not os.path.isfile(font_path):
continue
font_detail = get_font_details(font_path)
if font_detail.error:
continue
variant = _font_detail_variant(font_detail, os.path.basename(font_path))
if variant == "unsupported":
variant = "regular"
family_names = {
name
for name in (
font_detail.family_name,
font_detail.full_name,
font_detail.postscript_name,
_actual_uploaded_font_name(font_detail, variant, font_path),
)
if name
}
font_url = Path(font_path).resolve().as_uri()
font_weight = _font_weight_for_css(font_detail, variant)
font_style = _font_style_for_css(variant)
for family_name in sorted(family_names):
key = (family_name, font_url)
if key in seen:
continue
seen.add(key)
rules.append(
'@font-face { '
f'font-family: "{_css_string(family_name)}"; '
f'src: url("{font_url}"); '
f"font-weight: {font_weight}; "
f"font-style: {font_style}; "
"font-display: block; "
"}"
)
return "\n".join(rules)
def _preview_asset_url_to_data_uri(url: str) -> str:
if not url:
return url
parsed = urllib.parse.urlparse(url)
fallback_url = url
if parsed.scheme in ("http", "https"):
if not parsed.path.startswith(("/app_data/", "/static/")):
return url
candidate = urllib.parse.unquote(parsed.path)
elif parsed.scheme == "file":
candidate = urllib.parse.unquote(parsed.path)
elif url.startswith(("/app_data/", "/static/")):
candidate = url
fallback_url = absolute_fastapi_asset_url(candidate)
else:
return url
resolved = resolve_app_path_to_filesystem(candidate)
if not resolved:
return fallback_url
try:
data = Path(resolved).read_bytes()
except OSError:
return fallback_url
mime_type = mimetypes.guess_type(resolved)[0] or "application/octet-stream"
encoded = base64.b64encode(data).decode("ascii")
return f"data:{mime_type};base64,{encoded}"
def _localize_preview_asset_urls(html: str) -> str:
def replace_attr(match: re.Match[str]) -> str:
return (
f"{match.group('prefix')}"
f"{_preview_asset_url_to_data_uri(match.group('url'))}"
f"{match.group('suffix')}"
)
def replace_css_url(match: re.Match[str]) -> str:
quote = match.group("quote") or ""
return f"url({quote}{_preview_asset_url_to_data_uri(match.group('url'))}{quote})"
html = re.sub(
r"(?P<prefix>\b(?:src|href|xlink:href)=['\"])(?P<url>[^'\"]+)(?P<suffix>['\"])",
replace_attr,
html,
flags=re.IGNORECASE,
)
return re.sub(
r"url\((?P<quote>['\"]?)(?P<url>[^)'\"]+)(?P=quote)\)",
replace_css_url,
html,
flags=re.IGNORECASE,
)
def _normalized_css_font_family(value: str) -> str:
return " ".join(value.replace("_", " ").split()).casefold()
def _font_stylesheet_links_for_slide_html(
slide_html: str, declared_font_css: str = ""
) -> str:
declared_font_names = {
_normalized_css_font_family(font_name)
for font_name in re.findall(
r"font-family\s*:\s*['\"]?([^;'\"}]+)",
declared_font_css,
flags=re.IGNORECASE,
)
if font_name.strip()
}
font_names = sorted(
{
font_name.replace("_", " ").strip()
for font_name in re.findall(r"font-\[\s*['\"]([^'\"]+)['\"]\s*\]", slide_html)
if font_name.strip()
and _normalized_css_font_family(font_name) not in declared_font_names
}
)
return "\n".join(
f'<link href="{html.escape(build_google_fonts_stylesheet_url(font_name), quote=True)}" rel="stylesheet">'
for font_name in font_names
)
def _is_font_stylesheet_url(url: str) -> bool:
return bool(re.search(r"\.css(?:\?|$)", url, flags=re.IGNORECASE)) or (
"fonts.googleapis.com" in url
)
def _font_stylesheet_links_for_urls(urls: List[str]) -> str:
links: List[str] = []
seen: Set[str] = set()
for url in urls:
if not url or url in seen or not _is_font_stylesheet_url(url):
continue
seen.add(url)
links.append(
f'<link href="{html.escape(url, quote=True)}" rel="stylesheet">'
)
return "\n".join(links)
def _font_css_family_aliases(font_css: str) -> str:
if not font_css:
return ""
aliases: List[str] = []
seen: Set[str] = set()
font_face_blocks = re.findall(
r"@font-face\s*{[^{}]*}",
font_css,
flags=re.IGNORECASE | re.DOTALL,
)
for block in font_face_blocks:
family_match = re.search(
r"font-family\s*:\s*['\"]?([^;'\"}]+)['\"]?",
block,
flags=re.IGNORECASE,
)
if not family_match:
continue
family_name = family_match.group(1).strip()
alias_name = " ".join(family_name.replace("_", " ").split())
if not alias_name or alias_name == family_name:
continue
alias_block = re.sub(
r"(font-family\s*:\s*)['\"]?[^;'\"}]+['\"]?",
lambda match: f'{match.group(1)}"{_css_string(alias_name)}"',
block,
count=1,
flags=re.IGNORECASE,
)
if alias_block in seen:
continue
seen.add(alias_block)
aliases.append(alias_block)
return "\n".join(aliases)
def _css_attribute_value(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"')
def _css_url_value(value: str) -> str:
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "")
def _font_face_css_for_declared_fonts(font_css: str) -> str:
if not font_css:
return ""
rules: List[str] = []
seen: Set[Tuple[str, str, int, str]] = set()
font_face_blocks = re.findall(
r"@font-face\s*{[^{}]*}",
font_css,
flags=re.IGNORECASE | re.DOTALL,
)
for block in font_face_blocks:
family_match = re.search(
r"font-family\s*:\s*['\"]?([^;'\"}]+)['\"]?",
block,
flags=re.IGNORECASE,
)
src_match = re.search(
r"url\(\s*['\"]?([^)'\"\s]+)['\"]?\s*\)",
block,
flags=re.IGNORECASE,
)
if not family_match or not src_match:
continue
declared_family = family_match.group(1).strip()
source_url = src_match.group(1).strip()
resolved_path = resolve_app_path_to_filesystem(source_url)
if not declared_family or not resolved_path:
continue
font_detail = get_font_details(resolved_path)
if font_detail.error:
continue
variant = _font_detail_variant(font_detail, os.path.basename(resolved_path))
if variant == "unsupported":
variant = "regular"
font_weight = _font_weight_for_css(font_detail, variant)
font_style = _font_style_for_css(variant)
family_names = {
declared_family,
" ".join(declared_family.replace("_", " ").split()),
font_detail.family_name,
font_detail.full_name,
font_detail.postscript_name,
}
for family_name in sorted(name for name in family_names if name):
key = (family_name, source_url, font_weight, font_style)
if key in seen:
continue
seen.add(key)
rules.append(
"@font-face { "
f'font-family: "{_css_string(family_name)}"; '
f'src: url("{_css_url_value(source_url)}"); '
f"font-weight: {font_weight}; "
f"font-style: {font_style}; "
"font-display: swap; "
"}"
)
return "\n".join(rules)
def _tailwind_fallback_css_for_slide_html(slide_html: str) -> str:
if not slide_html:
return ""
class_values = re.findall(
r"\bclass\s*=\s*(['\"])(.*?)\1",
slide_html,
flags=re.IGNORECASE | re.DOTALL,
)
classes: Set[str] = set()
for _quote, class_value in class_values:
classes.update(token for token in class_value.split() if token)
static_rules = {
"absolute": "position:absolute;",
"relative": "position:relative;",
"inset-0": "inset:0;",
"overflow-hidden": "overflow:hidden;",
"overflow-visible": "overflow:visible;",
"flex": "display:flex;",
"flex-col": "flex-direction:column;",
"justify-center": "justify-content:center;",
"items-center": "align-items:center;",
"text-left": "text-align:left;",
"text-center": "text-align:center;",
"text-right": "text-align:right;",
"font-bold": "font-weight:700;",
"w-full": "width:100%;",
"h-full": "height:100%;",
"min-h-[1.2em]": "min-height:1.2em;",
"object-cover": "object-fit:cover;",
"pointer-events-none": "pointer-events:none;",
}
spacing_scale = {"5": "1.25rem"}
rules: List[str] = []
seen: Set[str] = set()
def append_rule(class_name: str, declarations: str) -> None:
if class_name in seen:
return
seen.add(class_name)
rules.append(
f'[class~="{_css_attribute_value(class_name)}"]{{{declarations}}}'
)
for class_name in sorted(classes):
if class_name in static_rules:
append_rule(class_name, static_rules[class_name])
continue
match = re.fullmatch(r"(left|top|right|bottom)-\[(.+)\]", class_name)
if match:
append_rule(class_name, f"{match.group(1)}:{match.group(2)};")
continue
match = re.fullmatch(r"([wh])-\[(.+)\]", class_name)
if match:
property_name = "width" if match.group(1) == "w" else "height"
append_rule(class_name, f"{property_name}:{match.group(2)};")
continue
match = re.fullmatch(r"mb-(\d+)", class_name)
if match and match.group(1) in spacing_scale:
append_rule(class_name, f"margin-bottom:{spacing_scale[match.group(1)]};")
continue
match = re.fullmatch(r"rotate-\[(.+)\]", class_name)
if match:
append_rule(class_name, f"transform:rotate({match.group(1)});")
continue
match = re.fullmatch(
r"bg-\[(#[0-9A-Fa-f]{3,8}|rgba?\([^\]]+\))\]",
class_name,
)
if match:
append_rule(class_name, f"background-color:{match.group(1)};")
continue
match = re.fullmatch(
r"text-\[(#[0-9A-Fa-f]{3,8}|rgba?\([^\]]+\))\]",
class_name,
)
if match:
append_rule(class_name, f"color:{match.group(1)};")
continue
match = re.fullmatch(r"text-\[([0-9.]+(?:px|rem|em|%)?)\]", class_name)
if match:
size = match.group(1)
if re.fullmatch(r"[0-9.]+", size):
size = f"{size}px"
append_rule(class_name, f"font-size:{size};")
continue
match = re.fullmatch(r"font-\[['\"](.+)['\"]\]", class_name)
if match:
family = match.group(1)
append_rule(
class_name,
f'font-family:"{_css_string(family)}", Arial, sans-serif;',
)
return "\n".join(rules)
def _app_data_directory() -> str:
app_data_dir = get_app_data_directory_env() or "/tmp/presenton"
os.makedirs(app_data_dir, exist_ok=True)
return app_data_dir
def _get_fonts_directory() -> str:
fonts_dir = os.path.join(_app_data_directory(), "fonts")
os.makedirs(fonts_dir, exist_ok=True)
return fonts_dir
def _get_template_preview_session_dir(session_id: uuid.UUID) -> str:
session_dir = os.path.join(
_app_data_directory(), "uploads", "template-previews", str(session_id)
)
os.makedirs(session_dir, exist_ok=True)
return session_dir
def _chart_js_url() -> str:
return (
os.getenv("NEXT_PUBLIC_CHART_JS_URL")
or os.getenv("CHART_JS_URL")
or DEFAULT_CHART_JS_URL
)
def _build_slide_preview_html(
slide_html: str,
font_css: str,
font_links: str = "",
width: int = PREVIEW_WIDTH,
height: int = PREVIEW_HEIGHT,
) -> str:
fastapi_base = absolute_fastapi_asset_url("/").rstrip("/") + "/"
return f"""<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="{fastapi_base}" />
<script src="{html.escape(TAILWIND_CDN_URL, quote=True)}"></script>
<script src="{html.escape(_chart_js_url(), quote=True)}"></script>
{font_links}
<style>
html,
body {{
width: {width}px;
height: {height}px;
margin: 0;
padding: 0;
overflow: hidden;
background: #ffffff;
}}
*,
*::before,
*::after {{
box-sizing: border-box;
}}
#slide-preview-root {{
position: relative;
width: {width}px;
height: {height}px;
margin: 0;
padding: 0;
overflow: hidden;
background: #ffffff;
}}
.slide-container {{
width: {width}px;
height: {height}px;
margin: 0;
display: flex;
align-items: flex-start;
justify-content: center;
overflow: hidden;
}}
.slide-content {{
position: relative;
width: {width}px;
height: {height}px;
margin: 0;
flex: 0 0 auto;
overflow: hidden;
box-shadow: none;
}}
img,
svg,
video,
canvas {{
max-width: none;
}}
{font_css or ""}
</style>
</head>
<body>
<div id="slide-preview-root">{slide_html}</div>
</body>
</html>"""
async def render_pptx_slides_to_images(
modified_pptx_path: str,
font_paths_for_install: List[str],
max_slides: Optional[int],
logger,
font_stylesheet_urls: Optional[List[str]] = None,
) -> List[str]:
local_font_css = ""
if font_paths_for_install:
local_font_css = await asyncio.to_thread(
_font_face_css_for_local_fonts,
font_paths_for_install,
)
logger.info("Prepared custom font CSS for HTML preview rendering")
pptx_document = await EXPORT_TASK_SERVICE.convert_pptx_to_html(
modified_pptx_path, get_fonts=True
)
if not pptx_document.slides:
raise HTTPException(status_code=500, detail="PPTX-to-HTML returned no slides")
slide_htmls = pptx_document.slides
if max_slides:
slide_htmls = slide_htmls[:max_slides]
# The enterprise converter normalizes decks to a 1280px target width and
# derives height from the source slide size. Keep that aspect ratio here.
width, height = _preview_dimensions_from_document(
pptx_document.width,
pptx_document.height,
)
logger.info(
f"Rendering {len(slide_htmls)} slide previews from PPTX-to-HTML at {width}x{height}"
)
localized_slide_htmls = []
font_css = "\n".join(
css
for css in (
pptx_document.font_css,
_font_face_css_for_declared_fonts(pptx_document.font_css),
local_font_css,
)
if css
)
localized_font_css = _localize_preview_asset_urls(font_css)
localized_font_css = "\n".join(
css
for css in (
localized_font_css,
_font_css_family_aliases(localized_font_css),
)
if css
)
explicit_font_links = _font_stylesheet_links_for_urls(font_stylesheet_urls or [])
for slide_html in slide_htmls:
localized_slide_html = _localize_preview_asset_urls(slide_html)
inferred_font_links = _font_stylesheet_links_for_slide_html(
localized_slide_html, localized_font_css
)
font_links = "\n".join(
link for link in (explicit_font_links, inferred_font_links) if link
)
slide_fallback_css = _tailwind_fallback_css_for_slide_html(
localized_slide_html
)
localized_slide_htmls.append(
_build_slide_preview_html(
localized_slide_html,
"\n".join(
css for css in (slide_fallback_css, localized_font_css) if css
),
font_links=font_links,
width=width,
height=height,
)
)
rendered = await EXPORT_TASK_SERVICE.render_htmls_to_images(
htmls=localized_slide_htmls,
width=width,
height=height,
)
if len(rendered.paths) != len(localized_slide_htmls):
raise HTTPException(
status_code=500,
detail=(
"PPTX preview renderer returned an unexpected slide count: "
f"expected {len(localized_slide_htmls)}, got {len(rendered.paths)}"
),
)
logger.info(
f"Rendered {len(rendered.paths)} HTML slide previews in one Chromium task"
)
return rendered.paths
def _font_variants_by_normalized_name(pptx_path: str) -> Dict[str, Set[str]]:
font_variants = extract_used_font_variants_from_pptx(pptx_path)
normalized_variants: Dict[str, Set[str]] = {}
for font_name, variants in font_variants.items():
normalized_name = normalize_font_family_name(font_name)
if normalized_name:
normalized_variants.setdefault(normalized_name, set()).update(variants)
return normalized_variants
def _variants_for_font_name(
font_name: str, variants_by_normalized_name: Dict[str, Set[str]]
) -> List[str]:
return normalize_font_variants(
variants_by_normalized_name.get(normalize_font_family_name(font_name))
)
def _font_variant_display_name(font_name: str, variant: str) -> str:
labels = {
"regular": "Regular",
"bold": "Bold",
"italic": "Italic",
"bold_italic": "Bold Italic",
}
return f"{font_name} {labels.get(variant, variant.replace('_', ' ').title())}"
def _font_info_entry(
font_name: str,
url: Optional[str],
variant: str,
original_name: Optional[str] = None,
) -> FontInfo:
family_name = normalize_font_family_name(font_name)
if not family_name:
family_name = font_name
return FontInfo(
name=_font_variant_display_name(family_name, variant),
url=url,
original_name=original_name or font_name,
family_name=family_name,
variant=variant,
variants=[variant],
)
def _font_info_entries(
fonts_data: List[FontInfoData],
variants_by_normalized_name: Dict[str, Set[str]],
original_names_by_normalized_variant: Optional[Dict[Tuple[str, str], str]] = None,
) -> List[FontInfo]:
entries: List[FontInfo] = []
for font_data in fonts_data:
if len(font_data) == 3:
name, url, explicit_variants = font_data
variants = normalize_font_variants(explicit_variants)
else:
name, url = font_data
variants = _variants_for_font_name(name, variants_by_normalized_name)
for variant in variants:
original_name = (original_names_by_normalized_variant or {}).get(
(normalize_font_family_name(name), variant)
)
entries.append(_font_info_entry(name, url, variant, original_name))
return entries
def _original_names_by_normalized_variant(
font_variants: Dict[str, Set[str]],
) -> Dict[Tuple[str, str], str]:
originals: Dict[Tuple[str, str], str] = {}
for original_name, variants in font_variants.items():
normalized_name = normalize_font_family_name(original_name)
if not normalized_name:
continue
for variant in normalize_font_variants(variants):
originals.setdefault((normalized_name, variant), original_name)
return originals
def _font_detail_variant(font_detail: FontDetail, filename: str = "") -> str:
compact_metadata = " ".join(
value or ""
for value in (
font_detail.subfamily_name,
font_detail.full_name,
font_detail.postscript_name,
filename,
)
).lower()
compact_metadata = "".join(char for char in compact_metadata if char.isalnum())
italic = "italic" in compact_metadata or "oblique" in compact_metadata
if font_detail.weight_class is not None:
if font_detail.weight_class == 700:
bold = True
elif font_detail.weight_class == 400:
bold = False
else:
return "unsupported"
else:
bold = "bold" in compact_metadata or "gras" in compact_metadata
unsupported_weight = any(
token in compact_metadata
for token in ("semibold", "demibold", "medium", "extrabold", "black")
)
if unsupported_weight and not bold:
return "unsupported"
if bold and italic:
return "bold_italic"
if bold:
return "bold"
if italic:
return "italic"
return "regular"
def _font_variant_family_name(font_name: str, variant: str) -> str:
return _font_variant_display_name(font_name, variant)
def _font_name_has_explicit_variant(font_name: str) -> bool:
return bool(font_name and normalize_font_family_name(font_name) != font_name.strip())
def _actual_uploaded_font_name(
detail: FontDetail,
variant: str,
font_filename: str,
) -> str:
if detail.full_name:
return detail.full_name
if detail.family_name:
family_name = normalize_font_family_name(detail.family_name)
return _font_variant_family_name(family_name or detail.family_name, variant)
filename_family = normalize_font_family_name(
os.path.splitext(os.path.basename(font_filename))[0]
)
return _font_variant_family_name(filename_family or font_filename, variant)
def _direct_upload_replacement_font_name(
original_name: str,
requested_variant: str,
uploaded_variant: str,
detail: FontDetail,
font_filename: str,
) -> str:
actual_font_name = _actual_uploaded_font_name(
detail, uploaded_variant, font_filename
)
original_family_name = normalize_font_family_name(original_name)
if (
not original_family_name
or not _font_name_has_explicit_variant(original_name)
or requested_variant != uploaded_variant
):
return actual_font_name
uploaded_name_candidates = (
detail.family_name,
detail.full_name,
detail.postscript_name,
os.path.splitext(os.path.basename(font_filename))[0],
)
for candidate in uploaded_name_candidates:
if normalize_font_family_name(candidate) == original_family_name:
return _font_variant_family_name(original_family_name, requested_variant)
return actual_font_name
def _write_bytes_to_path(path: str, data: bytes) -> None:
with open(path, "wb") as file:
file.write(data)
def _strip_trailing_modified_suffix(name: str) -> str:
cleaned_name = (name or "").strip()
lowered_name = cleaned_name.casefold()
for suffix in ("-modified", "_modified", " modified"):
if lowered_name.endswith(suffix):
return cleaned_name[: -len(suffix)].rstrip(" -_")
return cleaned_name
def _build_modified_pptx_filename(original_filename: str) -> str:
safe_filename = os.path.basename((original_filename or "").strip())
stem, extension = os.path.splitext(safe_filename)
base_stem = _strip_trailing_modified_suffix(stem) or stem.strip() or "presentation"
return f"{base_stem}-modified{extension or '.pptx'}"
def _raise_if_font_check_upload_too_large(size: int | None) -> None:
if size is not None and size >= MAX_FONT_CHECK_UPLOAD_SIZE_BYTES:
raise HTTPException(status_code=413, detail=FONT_CHECK_UPLOAD_SIZE_ERROR)
def _validate_pptx_package(pptx_path: str) -> None:
try:
with zipfile.ZipFile(pptx_path, "r") as archive:
bad_member = archive.testzip()
names = set(archive.namelist())
except (OSError, zipfile.BadZipFile, RuntimeError) as exc:
raise HTTPException(
status_code=400,
detail=INVALID_PPTX_UPLOAD_ERROR,
) from exc
has_slide = any(
name.startswith("ppt/slides/slide") and name.endswith(".xml")
for name in names
)
if (
bad_member
or "[Content_Types].xml" not in names
or "ppt/presentation.xml" not in names
or not has_slide
):
raise HTTPException(status_code=400, detail=INVALID_PPTX_UPLOAD_ERROR)
def _resolve_template_preview_slide_cap(max_slides: Optional[int]) -> int:
if max_slides is None:
return MAX_TEMPLATE_PREVIEW_SLIDES
if max_slides < 1:
raise HTTPException(
status_code=400,
detail="max_slides must be greater than zero",
)
return min(max_slides, MAX_TEMPLATE_PREVIEW_SLIDES)
def _relationship_target_path(base_dir: str, target: str) -> str:
if target.startswith("/"):
return target.lstrip("/")
return os.path.normpath(os.path.join(base_dir, target)).replace(os.sep, "/")
def _trim_pptx_to_max_slides(
pptx_path: str,
max_slides: int,
temp_dir: str,
logger,
) -> str:
try:
with zipfile.ZipFile(pptx_path, "r") as archive:
presentation_xml = ET.fromstring(archive.read("ppt/presentation.xml"))
rels_xml = ET.fromstring(archive.read("ppt/_rels/presentation.xml.rels"))
slide_id_list = presentation_xml.find("p:sldIdLst", PPT_NS)
if slide_id_list is None:
raise ValueError("PPTX presentation is missing slide references")
slide_ids = list(slide_id_list.findall("p:sldId", PPT_NS))
if len(slide_ids) <= max_slides:
return pptx_path
rels_by_id = {
rel.get("Id"): rel
for rel in rels_xml.findall(f"{{{REL_NS}}}Relationship")
}
rel_attr = f"{{{PPT_NS['r']}}}id"
removed_parts: set[str] = set()
removed_part_names: set[str] = set()
for slide_id in slide_ids[max_slides:]:
slide_id_list.remove(slide_id)
rel = rels_by_id.get(slide_id.get(rel_attr))
if rel is None:
continue
rels_xml.remove(rel)
target = rel.get("Target") or ""
if target:
slide_path = _relationship_target_path("ppt", target)
removed_parts.add(slide_path)
removed_parts.add(
slide_path.replace("ppt/slides/", "ppt/slides/_rels/")
+ ".rels"
)
removed_part_names.add(f"/{slide_path}")
output_path = os.path.join(
temp_dir,
f"{Path(pptx_path).stem}-first-{max_slides}.pptx",
)
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as output:
for member in archive.infolist():
if member.filename in removed_parts:
continue
if member.filename == "ppt/presentation.xml":
output.writestr(member, ET.tostring(presentation_xml))
elif member.filename == "ppt/_rels/presentation.xml.rels":
output.writestr(member, ET.tostring(rels_xml))
elif member.filename == "[Content_Types].xml":
content_types_xml = ET.fromstring(archive.read(member.filename))
for override in list(content_types_xml):
if override.get("PartName") in removed_part_names:
content_types_xml.remove(override)
output.writestr(member, ET.tostring(content_types_xml))
else:
output.writestr(member, archive.read(member.filename))
logger.info(
f"Trimmed PPTX from {len(slide_ids)} to {max_slides} slides for template preview"
)
return output_path
except HTTPException:
raise
except (KeyError, OSError, zipfile.BadZipFile, ET.ParseError, ValueError) as exc:
raise HTTPException(
status_code=400,
detail="Failed to prepare PPTX slide preview. The file may be corrupted or unsupported.",
) from exc
async def check_fonts_in_pptx_handler(pptx_file: UploadFile) -> FontCheckResponse:
"""
Extract fonts from a PPTX file and check their availability in Google Fonts.
Returns:
FontCheckResponse with available and unavailable fonts
"""
# Validate PPTX file
filename = getattr(pptx_file, "filename", "") or ""
if not filename.lower().endswith(".pptx"):
raise HTTPException(
status_code=400, detail="Invalid file type. Expected PPTX file"
)
_raise_if_font_check_upload_too_large(getattr(pptx_file, "size", None))
with tempfile.TemporaryDirectory() as temp_dir:
# Save uploaded PPTX file
pptx_path = os.path.join(temp_dir, "presentation.pptx")
pptx_content = await pptx_file.read()
_raise_if_font_check_upload_too_large(len(pptx_content))
await asyncio.to_thread(_write_bytes_to_path, pptx_path, pptx_content)
await asyncio.to_thread(_validate_pptx_package, pptx_path)
font_variants_by_name = await asyncio.to_thread(
extract_used_font_variants_from_pptx, pptx_path
)
variants_by_normalized_name = await asyncio.to_thread(
_font_variants_by_normalized_name, pptx_path
)
original_names_by_normalized_variant = _original_names_by_normalized_variant(
font_variants_by_name
)
(
available_fonts_data,
unavailable_fonts_data,
) = await get_available_and_unavailable_fonts_for_pptx(pptx_path, temp_dir)
available_font_entries = _font_info_entries(
available_fonts_data,
variants_by_normalized_name,
original_names_by_normalized_variant,
)
unavailable_font_entries: List[FontInfo] = []
if unavailable_fonts_data:
uploaded_fonts_by_variant = await get_font_uploads_for_names_by_variant(
[font_data[0] for font_data in unavailable_fonts_data]
)
for font_data in unavailable_fonts_data:
font_name = font_data[0]
font_url = font_data[1]
variants = (
normalize_font_variants(font_data[2])
if len(font_data) == 3
else _variants_for_font_name(
font_name, variants_by_normalized_name
)
)
uploaded_variants = uploaded_fonts_by_variant.get(font_name, {})
for variant in variants:
original_name = original_names_by_normalized_variant.get(
(normalize_font_family_name(font_name), variant)
)
font_upload = uploaded_variants.get(variant)
if font_upload:
available_font_entries.append(
_font_info_entry(
font_name,
await get_font_upload_url(font_upload),
variant,
original_name,
)
)
else:
unavailable_font_entries.append(
_font_info_entry(
font_name, font_url, variant, original_name
)
)
return FontCheckResponse(
available_fonts=available_font_entries,
unavailable_fonts=unavailable_font_entries,
)
async def _build_upload_preview_font_urls(
raw_fonts: Set[str],
found_embedded_fonts_with_url: Dict[str, str],
font_mapping: Dict[str, str],
custom_font_files: List[Tuple[str, str]],
font_upload_pairs: List[Tuple[str, str]],
original_font_names: Optional[List[str]],
font_variant_mapping: Dict[str, Dict[str, str]],
variants_by_normalized_name: Dict[str, Set[str]],
logger,
) -> Dict[str, str]:
fonts: Dict[str, str] = {}
for name, url in found_embedded_fonts_with_url.items():
actual_name = font_mapping.get(name, name)
fonts[actual_name] = url
logger.info(f"Added embedded font: {actual_name} -> {url}")
if font_upload_pairs:
font_urls = _public_urls_for_local_paths(
[dest for dest, _ in font_upload_pairs]
)
for (font_path, original_name), font_url in zip(custom_font_files, font_urls):
detail = await asyncio.to_thread(get_font_details, font_path)
variant = _font_detail_variant(detail, os.path.basename(font_path))
actual_name = (
(font_variant_mapping.get(original_name) or {}).get(variant)
or font_mapping.get(original_name)
or detail.full_name
or detail.family_name
or original_name
)
fonts[actual_name] = font_url
logger.info(f"Added custom font: {actual_name} -> {font_url}")
logger.info("Checking for Google Fonts availability")
all_fonts = set(raw_fonts)
normalized_original_font_names = {
normalize_font_family_name(name) for name in (original_font_names or [])
}
replaced_names = set(normalized_original_font_names)
replaced_names.update(font_mapping.keys())
replaced_names.update(found_embedded_fonts_with_url.keys())
all_fonts = {
normalize_font_family_name(f) for f in all_fonts if f not in replaced_names
}
fonts_to_check = sorted(font for font in all_fonts if font)
tasks = [
check_google_font_availability(
font,
variants=_variants_for_font_name(font, variants_by_normalized_name),
)
for font in fonts_to_check
]
results = await asyncio.gather(*tasks)
for font, is_available in zip(fonts_to_check, results):
if is_available:
google_fonts_url = build_google_fonts_stylesheet_url(
font,
variants=_variants_for_font_name(font, variants_by_normalized_name),
)
fonts[font] = google_fonts_url
logger.info(f"Added Google Font: {font} -> {google_fonts_url}")
logger.info(
f"Found {len([k for k, v in fonts.items() if 'fonts.googleapis.com' in v])} available Google Fonts"
)
return fonts
async def upload_fonts_and_preview_handler(
pptx_file: UploadFile,
font_files: Optional[List[UploadFile]] = None,
original_font_names: Optional[List[str]] = None,
google_font_original_names: Optional[List[str]] = None,
google_font_replacement_names: Optional[List[str]] = None,
google_font_names: Optional[List[str]] = None,
google_font_urls: Optional[List[str]] = None,
max_slides: Optional[int] = None,
upload_fonts: bool = True,
get_slide_images: bool = True,
upload_presentation: bool = True,
temp_dir: Optional[str] = None,
) -> FontsUploadAndSlidesPreviewResponse:
"""
Upload custom fonts, replace them in the PPTX, generate preview images.
Args:
pptx_file: PPTX file to modify
font_files: List of font files to use as replacements
original_font_names: Original font names in PPTX to replace
Returns:
UploadPreviewResponse with slide preview URLs and modified PPTX URL
"""
num_font_files = len(font_files or [])
num_original_names = len(original_font_names or [])
# If one is provided without the other
if (num_font_files and not num_original_names) or (
num_original_names and not num_font_files
):
raise HTTPException(
status_code=400,
detail="Both font_files and original_font_names must be provided together",
)
if num_font_files != num_original_names:
raise HTTPException(
status_code=400,
detail="Number of font files must match number of original font names",
)
selected_google_fonts, google_font_replacements = _selected_google_font_maps(
google_font_original_names,
google_font_replacement_names,
google_font_names,
google_font_urls,
)
# Validate PPTX file
filename = getattr(pptx_file, "filename", "") or ""
if not filename.lower().endswith(".pptx"):
raise HTTPException(
status_code=400, detail="Invalid file type. Expected PPTX file"
)
logger = _PreviewLogger()
slide_cap = _resolve_template_preview_slide_cap(max_slides)
logger.info(f"Processing font upload and preview for {num_font_files} fonts")
temp_dir_context = (
contextlib.nullcontext(temp_dir) if temp_dir else tempfile.TemporaryDirectory()
)
with temp_dir_context as temp_dir:
# Save uploaded PPTX file
pptx_path = os.path.join(temp_dir, "presentation.pptx")
pptx_content = await pptx_file.read()
await asyncio.to_thread(_write_bytes_to_path, pptx_path, pptx_content)
await asyncio.to_thread(_validate_pptx_package, pptx_path)
logger.info(f"Saved PPTX file to {pptx_path}")
pptx_path = await asyncio.to_thread(
_trim_pptx_to_max_slides,
pptx_path,
slide_cap,
temp_dir,
logger,
)
variants_by_normalized_name = await asyncio.to_thread(
_font_variants_by_normalized_name, pptx_path
)
session_id = uuid.uuid4()
session_dir = _get_template_preview_session_dir(session_id)
(
raw_fonts,
found_embedded_fonts_with_url,
font_mapping,
custom_font_files,
modified_pptx_path,
font_paths_for_install,
font_upload_pairs,
embedded_font_aliases,
protected_embedded_font_names,
font_variant_mapping,
) = await upload_fonts_and_fix_fonts_in_pptx(
pptx_path=pptx_path,
temp_dir=temp_dir,
original_filename=filename,
font_files=font_files,
original_font_names=original_font_names,
google_font_replacements=google_font_replacements,
logger=logger,
session_dir=session_dir,
upload_fonts=upload_fonts,
)
replaced_font_names = [
*(original_font_names or []),
*google_font_replacements.keys(),
]
fonts = await _build_upload_preview_font_urls(
raw_fonts=raw_fonts,
found_embedded_fonts_with_url=found_embedded_fonts_with_url,
font_mapping=font_mapping,
custom_font_files=custom_font_files,
font_upload_pairs=font_upload_pairs,
original_font_names=replaced_font_names,
font_variant_mapping=font_variant_mapping,
variants_by_normalized_name=variants_by_normalized_name,
logger=logger,
)
fonts.update(selected_google_fonts)
slide_image_paths: List[str] = []
if get_slide_images:
slide_image_paths = await create_slide_previews(
modified_pptx_path=modified_pptx_path,
temp_dir=temp_dir,
font_paths_for_install=font_paths_for_install,
font_mapping=font_mapping,
explicit_font_aliases=embedded_font_aliases,
protected_font_names=protected_embedded_font_names,
max_slides=slide_cap,
logger=logger,
session_dir=session_dir,
font_stylesheet_urls=[
url for url in fonts.values() if _is_font_stylesheet_url(url)
],
)
modified_pptx_path_out = ""
if upload_presentation:
modified_pptx_path_out = await upload_presentations(
modified_pptx_path=modified_pptx_path,
logger=logger,
session_dir=session_dir,
)
slide_image_urls: List[str] = []
if get_slide_images:
slide_image_urls = _public_urls_for_local_paths(slide_image_paths)
modified_pptx_url = modified_pptx_path
if upload_presentation:
modified_pptx_url = _public_urls_for_local_paths([modified_pptx_path_out])[0]
logger.info("Generated public URLs")
logger.info(
f"Upload and preview completed successfully with {len(fonts)} total fonts"
)
return FontsUploadAndSlidesPreviewResponse(
slide_image_urls=slide_image_urls,
pptx_url=modified_pptx_url,
modified_pptx_url=modified_pptx_url,
fonts=fonts,
)
def _add_google_font_replacements_to_mapping(
google_font_replacements: Optional[Dict[str, str]],
font_mapping: Dict[str, str],
font_variant_mapping: Dict[str, Dict[str, str]],
logger,
) -> None:
if not google_font_replacements:
return
for original_name, replacement_name in google_font_replacements.items():
original_name = (original_name or "").strip()
replacement_name = (replacement_name or "").strip()
if not original_name or not replacement_name:
continue
if original_name in font_mapping:
logger.info(
f"Skipping Google font mapping for {original_name}; custom font mapping already exists"
)
continue
font_mapping[original_name] = replacement_name
if _font_name_has_explicit_variant(original_name):
requested_variant = _font_style_variant(original_name, None, [])
font_variant_mapping.setdefault(original_name, {})[
requested_variant
] = replacement_name
original_family_name = normalize_font_family_name(original_name)
if original_family_name:
font_variant_mapping.setdefault(original_family_name, {})[
requested_variant
] = replacement_name
logger.info(f"Google font mapping: {original_name} -> {replacement_name}")
async def upload_fonts_and_fix_fonts_in_pptx(
pptx_path: str,
temp_dir: str,
original_filename: str,
font_files: Optional[List[UploadFile]],
original_font_names: Optional[List[str]],
logger,
session_dir: str,
upload_fonts: bool = True,
google_font_replacements: Optional[Dict[str, str]] = None,
) -> Tuple[
Set[str],
Dict[str, str],
Dict[str, str],
List[Tuple[str, str]],
str,
List[str],
List[Tuple[str, str]],
Dict[str, str],
List[str],
Dict[str, Dict[str, str]],
]:
(
raw_fonts,
emb_font_details,
emb_font_paths,
) = await asyncio.to_thread(
extract_raw_fonts_and_embedded_details,
pptx_path,
temp_dir,
)
logger.info("Extracted raw fonts and embedded details")
if upload_fonts:
(
found_embedded_fonts_with_url,
found_embedded_fonts_with_path,
embedded_actual_names,
) = await _prepare_embedded_fonts(
raw_fonts,
emb_font_details,
emb_font_paths,
temp_dir,
logger,
)
logger.info("Prepared embedded fonts")
else:
found_embedded_fonts_with_url = {}
found_embedded_fonts_with_path = {}
embedded_actual_names = {}
custom_font_files, font_mapping, font_variant_mapping = await _save_uploaded_fonts_to_temp(
font_files, original_font_names, temp_dir, logger
)
logger.info("Saved uploaded fonts to temp")
_add_google_font_replacements_to_mapping(
google_font_replacements,
font_mapping,
font_variant_mapping,
logger,
)
embedded_font_aliases: Dict[str, str] = {}
protected_embedded_font_names = list(found_embedded_fonts_with_path.keys())
font_paths_for_install: List[str] = [
font_path for font_path, _ in custom_font_files
]
font_paths_for_install.extend(found_embedded_fonts_with_path.values())
variants_by_normalized_name = await asyncio.to_thread(
_font_variants_by_normalized_name, pptx_path
)
explicit_source_font_names = [
*(original_font_names or []),
*(google_font_replacements or {}).keys(),
]
original_font_name_set = {name for name in explicit_source_font_names if name}
explicitly_replaced = {
normalize_font_family_name(name) for name in explicit_source_font_names
}
embedded_replaced = set(found_embedded_fonts_with_path.keys())
normalized_embedded_replaced = {
normalize_font_family_name(name) for name in embedded_replaced
}
candidate_uploaded_fonts = [
font_name
for font_name in raw_fonts
if font_name not in original_font_name_set
and normalize_font_family_name(font_name) not in explicitly_replaced
and font_name not in embedded_replaced
and normalize_font_family_name(font_name) not in normalized_embedded_replaced
]
uploaded_fonts_by_variant = await get_font_uploads_for_names_by_variant(
candidate_uploaded_fonts
)
if uploaded_fonts_by_variant:
unique_uploaded_fonts = list(
{
font_upload.id: font_upload
for variants in uploaded_fonts_by_variant.values()
for font_upload in variants.values()
}.values()
)
downloaded_paths = await download_font_uploads(
unique_uploaded_fonts, temp_dir
)
appended_downloads: Set[str] = set()
for original_name in candidate_uploaded_fonts:
required_variants = _variants_for_font_name(
original_name, variants_by_normalized_name
)
uploaded_variants = uploaded_fonts_by_variant.get(original_name, {})
matched_uploads = [
uploaded_variants[variant]
for variant in required_variants
if variant in uploaded_variants
]
if not matched_uploads:
continue
for font_upload in matched_uploads:
downloaded_path = downloaded_paths.get(font_upload.id)
if downloaded_path and downloaded_path not in appended_downloads:
font_paths_for_install.append(downloaded_path)
appended_downloads.add(downloaded_path)
if len(matched_uploads) != len(required_variants):
logger.info(
f"Partial uploaded font coverage for {original_name}: "
f"matched {len(matched_uploads)}/"
f"{len(required_variants)} variants"
)
continue
variant_replacement_names: Dict[str, str] = {}
normalized_original_name = normalize_font_family_name(original_name)
replacement_family_name = normalized_original_name or original_name
for variant, font_upload in uploaded_variants.items():
if variant not in required_variants:
continue
replacement_name = _font_variant_family_name(
replacement_family_name, variant
)
variant_replacement_names[variant] = replacement_name
found_embedded_fonts_with_url[replacement_name] = (
await get_font_upload_url(font_upload)
)
font_variant_mapping.setdefault(original_name, {}).update(
variant_replacement_names
)
if normalized_original_name:
font_variant_mapping.setdefault(normalized_original_name, {}).update(
variant_replacement_names
)
actual_name = (
variant_replacement_names.get("regular")
or next(iter(variant_replacement_names.values()))
)
font_mapping[original_name] = actual_name
logger.info(
f"Font upload mapping: {original_name} -> {actual_name} "
f"({', '.join(required_variants)})"
)
# Replace fonts in PPTX using python-pptx
modified_pptx_filename = _build_modified_pptx_filename(original_filename)
modified_pptx_path = os.path.join(temp_dir, modified_pptx_filename)
if font_mapping:
logger.info("Replacing fonts in PPTX")
await asyncio.to_thread(
replace_fonts_in_pptx,
pptx_path,
font_mapping,
modified_pptx_path,
font_variant_mapping,
)
logger.info("Fonts replaced successfully")
else:
modified_pptx_path = pptx_path
logger.info(
"No custom fonts provided; using original PPTX without replacement"
)
font_upload_pairs: List[Tuple[str, str]] = []
if upload_fonts:
for font_path, _original_name in custom_font_files:
_font_upload, persisted_path = await persist_font_file(
font_path, os.path.basename(font_path)
)
font_upload_pairs.append((persisted_path, font_path))
if font_upload_pairs:
logger.info(f"Persisted {len(font_upload_pairs)} reusable font files")
return (
raw_fonts,
found_embedded_fonts_with_url,
font_mapping,
custom_font_files,
modified_pptx_path,
font_paths_for_install,
font_upload_pairs,
embedded_font_aliases,
protected_embedded_font_names,
font_variant_mapping,
)
async def create_slide_previews(
modified_pptx_path: str,
temp_dir: str,
font_paths_for_install: List[str],
font_mapping: Dict[str, str],
explicit_font_aliases: Optional[Dict[str, str]],
protected_font_names: Optional[List[str]],
max_slides: Optional[int],
logger,
session_dir: str,
font_stylesheet_urls: Optional[List[str]] = None,
) -> List[str]:
del temp_dir, font_mapping, explicit_font_aliases, protected_font_names
screenshot_paths = await render_pptx_slides_to_images(
modified_pptx_path=modified_pptx_path,
font_paths_for_install=font_paths_for_install,
max_slides=max_slides,
logger=logger,
font_stylesheet_urls=font_stylesheet_urls,
)
logger.info("Generated slide previews from PPTX-to-HTML with Chromium")
if not screenshot_paths:
raise HTTPException(status_code=500, detail="Failed to generate slide images")
slide_upload_pairs = [
(os.path.join(session_dir, f"slide_{idx}.png"), screenshot_path)
for idx, screenshot_path in enumerate(screenshot_paths, start=1)
]
logger.info(f"Persisting {len(slide_upload_pairs)} slide images")
slide_image_paths = await _persist_files_to_session(slide_upload_pairs)
logger.info("Persisted slide images")
return slide_image_paths
async def upload_presentations(
modified_pptx_path: str,
logger,
session_dir: str,
) -> str:
pptx_upload_pairs = [
(
os.path.join(session_dir, os.path.basename(modified_pptx_path)),
modified_pptx_path,
),
]
logger.info("Persisting modified PPTX")
persisted_paths = await _persist_files_to_session(pptx_upload_pairs)
logger.info("Persisted PPTX file")
return persisted_paths[0]
async def _save_uploaded_fonts_to_temp(
font_files: Optional[List[UploadFile]],
original_font_names: Optional[List[str]],
temp_dir: str,
logger,
) -> Tuple[List[Tuple[str, str]], Dict[str, str], Dict[str, Dict[str, str]]]:
saved_fonts: List[Tuple[str, str]] = []
font_mapping: Dict[str, str] = {}
font_variant_mapping: Dict[str, Dict[str, str]] = {}
if not font_files or not original_font_names:
return saved_fonts, font_mapping, font_variant_mapping
for i, (font_file, original_name) in enumerate(
zip(font_files, original_font_names)
):
font_filename = getattr(font_file, "filename", f"font_{i}")
font_path = os.path.join(temp_dir, font_filename)
font_content = await font_file.read()
await asyncio.to_thread(_write_bytes_to_path, font_path, font_content)
saved_fonts.append((font_path, original_name))
detail = await asyncio.to_thread(get_font_details, font_path)
uploaded_variant = _font_detail_variant(detail, font_filename)
requested_variant = (
_font_style_variant(original_name, None, [])
if _font_name_has_explicit_variant(original_name)
else uploaded_variant
)
original_family_name = normalize_font_family_name(original_name)
actual_font_name = _direct_upload_replacement_font_name(
original_name,
requested_variant,
uploaded_variant,
detail,
font_filename,
)
font_mapping[original_name] = actual_font_name
font_variant_mapping.setdefault(original_name, {})[
requested_variant
] = actual_font_name
if original_family_name:
font_variant_mapping.setdefault(original_family_name, {})[
requested_variant
] = actual_font_name
logger.info(
f"Font mapping: {original_name} {requested_variant} -> {actual_font_name} ({font_filename})"
)
return saved_fonts, font_mapping, font_variant_mapping
async def _prepare_embedded_fonts(
raw_fonts: Set[str],
emb_font_details: List[FontDetail],
emb_font_paths: List[str],
temp_dir: str,
logger,
) -> Tuple[Dict[str, str], Dict[str, str], Dict[str, str]]:
if not raw_fonts or not emb_font_details:
return {}, {}, {}
random_id = str(uuid.uuid4())
upload_tasks = []
async def _process_font(
font_name: str, font_detail: FontDetail, font_path: str
) -> Tuple[str, str, str, str]:
converted_font_path = await asyncio.to_thread(
convert_eot_to_ttf, font_path, temp_dir
)
extension = os.path.splitext(converted_font_path)[1] or ".ttf"
base_name = font_detail.full_name or font_detail.family_name or font_name
safe_name = base_name.replace("/", "_")
embedded_dir = os.path.join(_get_fonts_directory(), "embedded", random_id)
await asyncio.to_thread(os.makedirs, embedded_dir, exist_ok=True)
dest_path = os.path.join(embedded_dir, f"{safe_name}{extension}")
await asyncio.to_thread(shutil.copy2, converted_font_path, dest_path)
url = _public_urls_for_local_paths([dest_path])[0]
actual_font_name = (
font_detail.full_name
or font_detail.family_name
or await asyncio.to_thread(extract_font_name_from_file, converted_font_path)
)
return font_name, url, converted_font_path, actual_font_name
for font_name in raw_fonts:
match_index = get_index_of_matching_font_detail_or_none(
font_name, emb_font_details
)
if match_index is None or match_index >= len(emb_font_paths):
continue
font_detail = emb_font_details[match_index]
font_path = emb_font_paths[match_index]
upload_tasks.append(
asyncio.create_task(_process_font(font_name, font_detail, font_path))
)
if not upload_tasks:
return {}, {}, {}
logger.info(f"Preparing {len(upload_tasks)} embedded fonts for delivery")
results = await asyncio.gather(*upload_tasks)
found_urls: Dict[str, str] = {}
found_paths: Dict[str, str] = {}
actual_names: Dict[str, str] = {}
for font_name, url, converted_path, actual_font_name in results:
found_urls[font_name] = url
found_paths[font_name] = converted_path
actual_names[font_name] = actual_font_name
return found_urls, found_paths, actual_names
async def _download_available_google_fonts(
candidate_google_fonts: Set[str],
temp_dir: str,
logger,
variants_by_normalized_name: Optional[Dict[str, Set[str]]] = None,
) -> List[str]:
if not candidate_google_fonts:
return []
api_key = (os.environ.get("GOOGLE_FONTS_API_KEY") or "").strip()
if not api_key:
logger.warning("GOOGLE_FONTS_API_KEY not set; skipping Google Fonts download")
return []
logger.info(f"Checking and downloading {len(candidate_google_fonts)} Google Fonts")
availability = await asyncio.gather(
*[
check_google_font_availability(
f,
variants=_variants_for_font_name(f, variants_by_normalized_name or {}),
)
for f in candidate_google_fonts
]
)
google_download_dir = os.path.join(temp_dir, "google_fonts")
await asyncio.to_thread(os.makedirs, google_download_dir, exist_ok=True)
downloaded_paths: List[str] = []
for family, is_available in zip(candidate_google_fonts, availability):
if not is_available:
continue
file_urls = await get_google_font_file_urls(family, api_key)
if not file_urls:
logger.warning(f"Webfonts API returned no TTF/OTF URLs for '{family}'")
continue
extract_dir = os.path.join(google_download_dir, family.replace(" ", "_"))
await asyncio.to_thread(os.makedirs, extract_dir, exist_ok=True)
downloaded_for_family = 0
for idx, file_url in enumerate(file_urls):
filename = (
os.path.basename(urllib.parse.urlparse(file_url).path)
or f"{family}_{idx}.ttf"
)
dest_path = os.path.join(extract_dir, filename)
try:
downloaded_path = await download_file(file_url, extract_dir)
if not downloaded_path or not os.path.exists(downloaded_path):
raise RuntimeError(
f"download_file returned invalid path for {file_url}"
)
if os.path.abspath(downloaded_path) != os.path.abspath(dest_path):
await asyncio.to_thread(shutil.move, downloaded_path, dest_path)
downloaded_paths.append(dest_path)
downloaded_for_family += 1
except Exception as exc:
logger.warning(
f"Failed to download font file for '{family}' from {file_url}: {exc}"
)
if downloaded_for_family:
logger.info(f"Downloaded {downloaded_for_family} file(s) for '{family}'")
return downloaded_paths
async def _persist_files_to_session(pairs: List[Tuple[str, str]]) -> List[str]:
if not pairs:
return []
persisted_paths: List[str] = []
async def _copy_pair(dest_path: str, src_path: str) -> str:
await asyncio.to_thread(os.makedirs, os.path.dirname(dest_path), exist_ok=True)
await asyncio.to_thread(shutil.copy2, src_path, dest_path)
return dest_path
results = await asyncio.gather(
*[_copy_pair(dest_path, src_path) for dest_path, src_path in pairs]
)
persisted_paths.extend(results)
return persisted_paths
def _public_urls_for_local_paths(paths: List[str]) -> List[str]:
if not paths:
return []
app_data = _app_data_directory()
urls: List[str] = []
for path in paths:
rel = os.path.relpath(os.path.abspath(path), os.path.abspath(app_data))
rel = rel.replace("\\", "/")
urls.append(absolute_fastapi_asset_url(f"/app_data/{rel}"))
return urls