Files
2026-07-13 13:08:41 +08:00

2330 lines
71 KiB
Python

import asyncio
import io
import os
import struct
import zipfile
import xml.etree.ElementTree as ET
from types import SimpleNamespace
import pytest
from templates import fonts_and_slides_preview
from templates import pptx_font_utils
from models.sql.font_upload import FontUpload
class DummyLogger:
def info(self, *_args, **_kwargs):
return None
def warning(self, *_args, **_kwargs):
return None
_SIZE_UNSET = object()
class DummyUploadFile:
def __init__(
self,
filename: str,
content: bytes = b"font",
size: int | None | object = _SIZE_UNSET,
) -> None:
self.filename = filename
self._content = content
self.size = len(content) if size is _SIZE_UNSET else size
async def read(self) -> bytes:
return self._content
async def _run_sync_in_test(func, *args, **kwargs):
return func(*args, **kwargs)
def _fake_corrupted_pptx_bytes() -> bytes:
return b"this is not a valid powerpoint zip package"
def _fake_pptx_bytes(slide_count: int) -> bytes:
buffer = io.BytesIO()
slide_ids = "\n".join(
f'<p:sldId id="{255 + index}" r:id="rId{index}" />'
for index in range(1, slide_count + 1)
)
rels = "\n".join(
f'<Relationship Id="rId{index}" '
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" '
f'Target="slides/slide{index}.xml" />'
for index in range(1, slide_count + 1)
)
overrides = "\n".join(
f'<Override PartName="/ppt/slides/slide{index}.xml" '
'ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml" />'
for index in range(1, slide_count + 1)
)
with zipfile.ZipFile(buffer, "w") as archive:
archive.writestr(
"[Content_Types].xml",
f"""\
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
{overrides}
</Types>""",
)
archive.writestr(
"ppt/presentation.xml",
f"""\
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:sldIdLst>
{slide_ids}
</p:sldIdLst>
</p:presentation>""",
)
archive.writestr(
"ppt/_rels/presentation.xml.rels",
f"""\
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
{rels}
</Relationships>""",
)
for index in range(1, slide_count + 1):
archive.writestr(f"ppt/slides/slide{index}.xml", "<p:sld />")
archive.writestr(f"ppt/slides/_rels/slide{index}.xml.rels", "<rels />")
return buffer.getvalue()
def test_build_google_fonts_stylesheet_url_includes_regular_and_bold_weights():
assert (
pptx_font_utils.build_google_fonts_stylesheet_url("Open Sans")
== "https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&display=swap"
)
def test_normalize_font_family_name_strips_localized_bold_token():
assert pptx_font_utils.normalize_font_family_name("Arial Gras") == "Arial"
def test_normalize_font_family_name_preserves_width_tokens():
assert (
pptx_font_utils.normalize_font_family_name("Latin Condensed")
== "Latin Condensed"
)
assert (
pptx_font_utils.normalize_font_family_name("Roboto Condensed Bold")
== "Roboto Condensed"
)
assert (
pptx_font_utils.normalize_font_family_name("Arial Narrow Bold")
== "Arial Narrow"
)
def test_extract_font_from_eot_uses_header_font_data_offset(tmp_path):
embedded_font = b"\x00\x01\x00\x00valid-font-data"
header_body = b"metadata-before-font\x00\x01\x00\x00not-the-font"
eot_size = 8 + len(header_body) + len(embedded_font)
font_data_size = len(embedded_font)
eot_path = tmp_path / "font.fntdata"
eot_path.write_bytes(
struct.pack("<II", eot_size, font_data_size) + header_body + embedded_font
)
assert pptx_font_utils.extract_font_from_eot(eot_path) == embedded_font
def test_check_fonts_in_pptx_rejects_100mb_file_from_upload_size():
upload = DummyUploadFile(
"deck.pptx",
content=b"",
size=fonts_and_slides_preview.MAX_FONT_CHECK_UPLOAD_SIZE_BYTES,
)
with pytest.raises(fonts_and_slides_preview.HTTPException) as exc_info:
asyncio.run(fonts_and_slides_preview.check_fonts_in_pptx_handler(upload))
assert exc_info.value.status_code == 413
assert exc_info.value.detail == "File size must be less than 100MB."
def test_check_fonts_in_pptx_rejects_oversize_after_read_when_size_missing(
monkeypatch,
):
monkeypatch.setattr(
fonts_and_slides_preview, "MAX_FONT_CHECK_UPLOAD_SIZE_BYTES", 4
)
upload = DummyUploadFile("deck.pptx", content=b"abcd", size=None)
with pytest.raises(fonts_and_slides_preview.HTTPException) as exc_info:
asyncio.run(fonts_and_slides_preview.check_fonts_in_pptx_handler(upload))
assert exc_info.value.status_code == 413
assert exc_info.value.detail == "File size must be less than 100MB."
def test_check_fonts_in_pptx_rejects_corrupted_pptx():
upload = DummyUploadFile("corrupted.pptx", content=_fake_corrupted_pptx_bytes())
with pytest.raises(fonts_and_slides_preview.HTTPException) as exc_info:
asyncio.run(fonts_and_slides_preview.check_fonts_in_pptx_handler(upload))
assert exc_info.value.status_code == 400
assert exc_info.value.detail == fonts_and_slides_preview.INVALID_PPTX_UPLOAD_ERROR
def test_upload_fonts_and_preview_rejects_corrupted_pptx():
upload = DummyUploadFile("corrupted.pptx", content=_fake_corrupted_pptx_bytes())
with pytest.raises(fonts_and_slides_preview.HTTPException) as exc_info:
asyncio.run(
fonts_and_slides_preview.upload_fonts_and_preview_handler(
pptx_file=upload,
font_files=[],
original_font_names=[],
get_slide_images=True,
)
)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == fonts_and_slides_preview.INVALID_PPTX_UPLOAD_ERROR
def test_template_preview_slide_cap_and_pptx_trim(tmp_path):
pptx_path = tmp_path / "deck.pptx"
pptx_path.write_bytes(_fake_pptx_bytes(52))
assert fonts_and_slides_preview._resolve_template_preview_slide_cap(None) == 50
assert fonts_and_slides_preview._resolve_template_preview_slide_cap(100) == 50
with pytest.raises(fonts_and_slides_preview.HTTPException) as exc_info:
fonts_and_slides_preview._resolve_template_preview_slide_cap(0)
assert exc_info.value.status_code == 400
trimmed_path = fonts_and_slides_preview._trim_pptx_to_max_slides(
str(pptx_path),
50,
str(tmp_path),
DummyLogger(),
)
assert trimmed_path != str(pptx_path)
with zipfile.ZipFile(trimmed_path) as archive:
assert "ppt/slides/slide50.xml" in archive.namelist()
assert "ppt/slides/slide51.xml" not in archive.namelist()
presentation_xml = ET.fromstring(archive.read("ppt/presentation.xml"))
slide_ids = presentation_xml.findall(
".//p:sldId", fonts_and_slides_preview.PPT_NS
)
assert len(slide_ids) == 50
rels_xml = ET.fromstring(archive.read("ppt/_rels/presentation.xml.rels"))
assert rels_xml.find(
".//*[@Id='rId51']",
) is None
content_types = archive.read("[Content_Types].xml").decode()
assert "/ppt/slides/slide51.xml" not in content_types
@pytest.mark.anyio
async def test_upload_fonts_and_preview_uses_trimmed_pptx_for_processing(
monkeypatch,
tmp_path,
):
captured = {}
slide_path = tmp_path / "slide_1.png"
slide_path.write_bytes(b"png")
def assert_slide_count(path, expected_count):
with zipfile.ZipFile(path) as archive:
presentation_xml = ET.fromstring(archive.read("ppt/presentation.xml"))
slide_ids = presentation_xml.findall(
".//p:sldId", fonts_and_slides_preview.PPT_NS
)
assert len(slide_ids) == expected_count
assert "ppt/slides/slide50.xml" in archive.namelist()
assert "ppt/slides/slide51.xml" not in archive.namelist()
def fake_font_variants(path):
captured["font_variant_pptx_path"] = path
assert_slide_count(path, 50)
return {}
async def fake_upload_fonts_and_fix_fonts_in_pptx(
pptx_path,
temp_dir,
original_filename,
font_files,
original_font_names,
logger,
session_dir,
upload_fonts=True,
google_font_replacements=None,
):
del temp_dir, original_filename, font_files, original_font_names, logger
del session_dir, upload_fonts, google_font_replacements
captured["font_processing_pptx_path"] = pptx_path
assert_slide_count(pptx_path, 50)
return set(), {}, {}, [], pptx_path, [], [], {}, [], {}
async def fake_create_slide_previews(
modified_pptx_path,
temp_dir,
font_paths_for_install,
font_mapping,
explicit_font_aliases,
protected_font_names,
max_slides,
logger,
session_dir,
font_stylesheet_urls=None,
):
del temp_dir, font_paths_for_install, font_mapping, explicit_font_aliases
del protected_font_names, logger, session_dir
assert font_stylesheet_urls == []
captured["preview_pptx_path"] = modified_pptx_path
assert max_slides == 50
assert_slide_count(modified_pptx_path, 50)
return [str(slide_path)]
async def fake_upload_presentations(modified_pptx_path, logger, session_dir):
del logger, session_dir
captured["uploaded_pptx_path"] = modified_pptx_path
assert_slide_count(modified_pptx_path, 50)
return modified_pptx_path
monkeypatch.setattr(
fonts_and_slides_preview,
"_font_variants_by_normalized_name",
fake_font_variants,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"upload_fonts_and_fix_fonts_in_pptx",
fake_upload_fonts_and_fix_fonts_in_pptx,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"create_slide_previews",
fake_create_slide_previews,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"upload_presentations",
fake_upload_presentations,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"_public_urls_for_local_paths",
lambda paths: list(paths),
)
response = await fonts_and_slides_preview.upload_fonts_and_preview_handler(
pptx_file=DummyUploadFile("deck.pptx", content=_fake_pptx_bytes(52)),
font_files=[],
original_font_names=[],
temp_dir=str(tmp_path),
)
assert captured["font_variant_pptx_path"].endswith("presentation-first-50.pptx")
assert captured["font_processing_pptx_path"] == captured["font_variant_pptx_path"]
assert captured["preview_pptx_path"] == captured["font_variant_pptx_path"]
assert captured["uploaded_pptx_path"] == captured["font_variant_pptx_path"]
assert response.pptx_url == captured["font_variant_pptx_path"]
assert response.modified_pptx_url == captured["font_variant_pptx_path"]
assert response.slide_image_urls == [str(slide_path)]
@pytest.mark.anyio
async def test_upload_fonts_and_preview_passes_google_fonts_to_html_preview(
monkeypatch,
tmp_path,
):
captured = {}
slide_path = tmp_path / "slide_1.png"
slide_path.write_bytes(b"png")
monkeypatch.setattr(
fonts_and_slides_preview,
"_font_variants_by_normalized_name",
lambda *_args: {"Open Sans": {"bold"}},
)
async def fake_upload_fonts_and_fix_fonts_in_pptx(
pptx_path,
temp_dir,
original_filename,
font_files,
original_font_names,
logger,
session_dir,
upload_fonts=True,
google_font_replacements=None,
):
del temp_dir, original_filename, font_files, original_font_names
del logger, session_dir, upload_fonts, google_font_replacements
return {"Open Sans"}, {}, {}, [], pptx_path, [], [], {}, [], {}
async def fake_check_google_font_availability(font_name, variants=None):
captured["checked_font"] = font_name
captured["checked_variants"] = variants
return True
async def fake_create_slide_previews(
modified_pptx_path,
temp_dir,
font_paths_for_install,
font_mapping,
explicit_font_aliases,
protected_font_names,
max_slides,
logger,
session_dir,
font_stylesheet_urls=None,
):
del modified_pptx_path, temp_dir, font_paths_for_install, font_mapping
del explicit_font_aliases, protected_font_names, max_slides, logger
del session_dir
captured["font_stylesheet_urls"] = font_stylesheet_urls
return [str(slide_path)]
monkeypatch.setattr(
fonts_and_slides_preview,
"upload_fonts_and_fix_fonts_in_pptx",
fake_upload_fonts_and_fix_fonts_in_pptx,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"check_google_font_availability",
fake_check_google_font_availability,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"create_slide_previews",
fake_create_slide_previews,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"_public_urls_for_local_paths",
lambda paths: list(paths),
)
response = await fonts_and_slides_preview.upload_fonts_and_preview_handler(
pptx_file=DummyUploadFile("deck.pptx", content=_fake_pptx_bytes(1)),
font_files=[],
original_font_names=[],
upload_presentation=False,
temp_dir=str(tmp_path),
)
expected_url = (
"https://fonts.googleapis.com/css2"
"?family=Open+Sans:wght@400;700&display=swap"
)
assert captured["checked_font"] == "Open Sans"
assert captured["checked_variants"] == ["bold"]
assert captured["font_stylesheet_urls"] == [expected_url]
assert response.fonts == {"Open Sans": expected_url}
assert response.slide_image_urls == [str(slide_path)]
@pytest.mark.anyio
async def test_upload_fonts_and_preview_replaces_pptx_fonts_with_selected_google_fonts(
monkeypatch,
tmp_path,
):
captured = {}
slide_path = tmp_path / "slide_1.png"
slide_path.write_bytes(b"png")
google_url = (
"https://fonts.googleapis.com/css2"
"?family=Poppins:wght@100..900&display=swap"
)
monkeypatch.setattr(
fonts_and_slides_preview,
"_font_variants_by_normalized_name",
lambda *_args: {"Open Sans": {"bold"}},
)
async def fake_upload_fonts_and_fix_fonts_in_pptx(
pptx_path,
temp_dir,
original_filename,
font_files,
original_font_names,
logger,
session_dir,
upload_fonts=True,
google_font_replacements=None,
):
del temp_dir, original_filename, font_files, original_font_names
del logger, session_dir, upload_fonts
captured["google_font_replacements"] = google_font_replacements
return (
{"Open Sans"},
{},
google_font_replacements or {},
[],
pptx_path,
[],
[],
{},
[],
{},
)
async def fake_create_slide_previews(
modified_pptx_path,
temp_dir,
font_paths_for_install,
font_mapping,
explicit_font_aliases,
protected_font_names,
max_slides,
logger,
session_dir,
font_stylesheet_urls=None,
):
del modified_pptx_path, temp_dir, font_paths_for_install, font_mapping
del explicit_font_aliases, protected_font_names, max_slides, logger
del session_dir
captured["font_stylesheet_urls"] = font_stylesheet_urls
return [str(slide_path)]
monkeypatch.setattr(
fonts_and_slides_preview,
"upload_fonts_and_fix_fonts_in_pptx",
fake_upload_fonts_and_fix_fonts_in_pptx,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"create_slide_previews",
fake_create_slide_previews,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"_public_urls_for_local_paths",
lambda paths: list(paths),
)
response = await fonts_and_slides_preview.upload_fonts_and_preview_handler(
pptx_file=DummyUploadFile("deck.pptx", content=_fake_pptx_bytes(1)),
font_files=[],
original_font_names=[],
google_font_original_names=["Open Sans Bold"],
google_font_replacement_names=["Poppins"],
google_font_urls=[google_url],
upload_presentation=False,
temp_dir=str(tmp_path),
)
assert captured["google_font_replacements"] == {"Open Sans Bold": "Poppins"}
assert captured["font_stylesheet_urls"] == [google_url]
assert response.fonts == {"Poppins": google_url}
assert response.slide_image_urls == [str(slide_path)]
def test_build_google_fonts_stylesheet_url_sorts_and_deduplicates_weights():
assert (
pptx_font_utils.build_google_fonts_stylesheet_url("DM Sans", weights=[700, 400, 700])
== "https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;700&display=swap"
)
def test_build_google_fonts_stylesheet_url_supports_italic_variants():
assert (
pptx_font_utils.build_google_fonts_stylesheet_url(
"Montserrat", variants=["regular", "bold", "italic", "bold_italic"]
)
== "https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,400;0,700;1,400;1,700&display=swap"
)
class _FakeGoogleFontsResponse:
def __init__(self, status, css):
self.status = status
self._css = css
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
async def text(self):
return self._css
class _FakeGoogleFontsSession:
def __init__(self, status, css, requested_urls):
self._status = status
self._css = css
self._requested_urls = requested_urls
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def get(self, url, timeout=None):
self._requested_urls.append(url)
return _FakeGoogleFontsResponse(self._status, self._css)
def test_check_google_font_availability_rejects_compatibility_font_kit(monkeypatch):
css = """\
@font-face {
font-family: 'Calibri';
src: url(https://fonts.gstatic.com/l/font?kit=J7afnpV-BGlaFfdAhLEY6w) format('woff2');
}
"""
monkeypatch.setattr(
pptx_font_utils.aiohttp,
"ClientSession",
lambda: _FakeGoogleFontsSession(200, css, []),
)
assert asyncio.run(pptx_font_utils.check_google_font_availability("Calibri")) is False
def test_check_google_font_availability_checks_requested_variant_url(monkeypatch):
requested_urls = []
css = """\
@font-face {
font-family: 'Montserrat';
src: url(https://fonts.gstatic.com/s/montserrat/v31/font.woff2) format('woff2');
}
"""
monkeypatch.setattr(
pptx_font_utils.aiohttp,
"ClientSession",
lambda: _FakeGoogleFontsSession(200, css, requested_urls),
)
assert (
asyncio.run(
pptx_font_utils.check_google_font_availability(
"Montserrat", variants=["regular", "bold", "italic"]
)
)
is True
)
assert requested_urls == [
"https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,400;0,700;1,400&display=swap"
]
def test_extract_fonts_from_oxml_ignores_embedded_font_declarations():
xml = """\
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:embeddedFontLst>
<p:embeddedFont>
<p:font typeface="Unused Embedded Font"/>
<p:regular r:id="rId1"/>
</p:embeddedFont>
</p:embeddedFontLst>
</p:presentation>
"""
assert pptx_font_utils.extract_fonts_from_oxml(xml) == []
def test_extract_fonts_from_oxml_prefers_latin_font_over_script_fallbacks():
xml = """\
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp>
<p:txBody>
<a:p>
<a:r>
<a:rPr>
<a:latin typeface="Montserrat"/>
<a:ea typeface="Arial"/>
<a:cs typeface="Arial"/>
</a:rPr>
<a:t>Hello</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>
"""
assert pptx_font_utils.extract_fonts_from_oxml(xml) == ["Montserrat"]
def test_font_info_entries_preserve_original_variant_name():
original_names = fonts_and_slides_preview._original_names_by_normalized_variant(
{"Arial Gras": {"bold"}}
)
entries = fonts_and_slides_preview._font_info_entries(
[("Arial", None)],
{"Arial": {"bold"}},
original_names,
)
assert len(entries) == 1
assert entries[0].name == "Arial Bold"
assert entries[0].original_name == "Arial Gras"
assert entries[0].family_name == "Arial"
assert entries[0].variant == "bold"
assert entries[0].variants == ["bold"]
def test_font_info_entries_use_explicit_variants_without_family_expansion():
entries = fonts_and_slides_preview._font_info_entries(
[("HK Grotesk", None, ["regular"])],
{"HK Grotesk": {"regular", "bold", "bold_italic"}},
)
assert len(entries) == 1
assert entries[0].name == "HK Grotesk Regular"
assert entries[0].variant == "regular"
assert entries[0].variants == ["regular"]
def test_font_info_entries_do_not_duplicate_explicit_variant_name():
entries = fonts_and_slides_preview._font_info_entries(
[("Aileron Bold", None, ["bold"])],
{"Aileron": {"bold"}},
{("Aileron", "bold"): "Aileron Bold"},
)
assert len(entries) == 1
assert entries[0].name == "Aileron Bold"
assert entries[0].original_name == "Aileron Bold"
assert entries[0].family_name == "Aileron"
assert entries[0].variant == "bold"
assert entries[0].variants == ["bold"]
def test_preview_dimensions_preserve_converter_aspect_ratio():
assert fonts_and_slides_preview._preview_dimensions_from_document(
1280.0, 960.0
) == (1280, 960)
assert fonts_and_slides_preview._preview_dimensions_from_document(0, 0) == (
1280,
720,
)
def test_build_slide_preview_html_adds_fixed_viewport_css(monkeypatch):
monkeypatch.setattr(
fonts_and_slides_preview,
"absolute_fastapi_asset_url",
lambda path: f"http://backend.test{path}",
)
html = fonts_and_slides_preview._build_slide_preview_html(
'<div class="slide-content">Slide</div>',
'@font-face { font-family: "Khand"; src: url("file:///font.ttf"); }',
font_links='<link href="https://fonts.googleapis.com/css2?family=Khand:wght@400;700&amp;display=swap" rel="stylesheet">',
width=1024,
height=768,
)
assert '<base href="http://backend.test/" />' in html
assert '<script src="https://cdn.tailwindcss.com"></script>' in html
assert (
'<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>'
in html
)
assert "width: 1024px;" in html
assert "height: 768px;" in html
assert ".slide-content" in html
assert "position: relative;" in html
assert "fonts.googleapis.com/css2?family=Khand" in html
assert 'font-family: "Khand"' in html
assert '<div class="slide-content">Slide</div>' in html
def test_font_stylesheet_links_for_slide_html_extracts_tailwind_font_classes():
links = fonts_and_slides_preview._font_stylesheet_links_for_slide_html(
"<span class=\"font-['Poppins']\"></span>"
"<span class=\"font-['DM_Sans']\"></span>"
)
assert "family=Poppins:wght@400;700" in links
assert "family=DM+Sans:wght@400;700" in links
assert links.count('rel="stylesheet"') == 2
def test_font_stylesheet_links_skip_embedded_and_uploaded_fonts():
links = fonts_and_slides_preview._font_stylesheet_links_for_slide_html(
"<span class=\"font-['Poppins']\"></span>"
"<span class=\"font-['Snell_Roundhand']\"></span>"
"<span class=\"font-['DM_Sans']\"></span>",
"@font-face { font-family: 'Poppins'; src: url(data:font/ttf;base64,AA); }"
'@font-face { font-family: "Snell Roundhand"; src: url(data:font/ttf;base64,AA); }',
)
assert "family=Poppins" not in links
assert "family=Snell+Roundhand" not in links
assert "family=DM+Sans:wght@400;700" in links
assert links.count('rel="stylesheet"') == 1
def test_font_css_family_aliases_match_tailwind_underscore_font_values():
css = (
"@font-face { font-family: 'Hagrid_Text_Heavy'; "
"src: url('/app_data/pptx-to-html/session/fonts/hagrid.otf'); }"
"@font-face { font-family: 'Aileron'; "
"src: url('/app_data/pptx-to-html/session/fonts/aileron.otf'); }"
)
aliases = fonts_and_slides_preview._font_css_family_aliases(css)
assert 'font-family: "Hagrid Text Heavy"' in aliases
assert "hagrid.otf" in aliases
assert "Aileron" not in aliases
def test_font_face_css_for_declared_fonts_uses_actual_font_metadata(
monkeypatch,
tmp_path,
):
font_path = tmp_path / "Formula-Bold.otf"
font_path.write_bytes(b"font")
font_url = font_path.resolve().as_uri()
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_details",
lambda path: pptx_font_utils.FontDetail(
file=path,
size_bytes=123,
family_name="Formula",
full_name="Formula Bold",
subfamily_name="Regular",
postscript_name="FormulaCondensed-Bold",
weight_class=700,
),
)
css = fonts_and_slides_preview._font_face_css_for_declared_fonts(
"@font-face { "
"font-family: 'Formula_Bold'; "
"font-weight: 400; "
f"src: url('{font_url}') format('opentype'); "
"}"
)
assert 'font-family: "Formula_Bold";' in css
assert 'font-family: "Formula Bold";' in css
assert 'font-family: "Formula";' in css
assert "font-weight: 700;" in css
assert f'url("{font_url}")' in css
def test_tailwind_fallback_css_handles_converted_slide_utility_classes():
css = fonts_and_slides_preview._tailwind_fallback_css_for_slide_html(
'<div class="absolute left-[72.0px] top-[109.7px] '
'w-[440.5px] h-[57.5px] overflow-visible bg-[#F15560]">'
'<span class="text-[42.7px] text-[#FFFFFF] font-bold '
"font-['Formula_Bold']\">CAMPAIGN GOALS</span>"
"</div>"
)
assert '[class~="absolute"]{position:absolute;}' in css
assert '[class~="left-[72.0px]"]{left:72.0px;}' in css
assert '[class~="text-[42.7px]"]{font-size:42.7px;}' in css
assert '[class~="text-[#FFFFFF]"]{color:#FFFFFF;}' in css
assert '[class~="font-bold"]{font-weight:700;}' in css
assert (
'[class~="font-[\'Formula_Bold\']"]{font-family:"Formula_Bold", Arial, sans-serif;}'
in css
)
def test_font_face_css_for_local_fonts_includes_family_and_full_names(
monkeypatch,
tmp_path,
):
font_path = tmp_path / "Khand-Bold.ttf"
font_path.write_bytes(b"font")
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_details",
lambda path: pptx_font_utils.FontDetail(
file=path,
size_bytes=123,
family_name="Khand",
full_name="Khand Bold",
subfamily_name="Bold",
weight_class=700,
),
)
css = fonts_and_slides_preview._font_face_css_for_local_fonts([str(font_path)])
assert 'font-family: "Khand";' in css
assert 'font-family: "Khand Bold";' in css
assert f'url("{font_path.resolve().as_uri()}")' in css
assert "font-weight: 700;" in css
assert "font-style: normal;" in css
def test_localize_preview_asset_urls_rewrites_app_data_http_urls(monkeypatch, tmp_path):
image_path = tmp_path / "asset.png"
image_path.write_bytes(b"png")
def fake_resolve(path_or_url):
assert path_or_url == "/app_data/pptx-to-html/session/images/asset.png"
return str(image_path)
monkeypatch.setattr(
fonts_and_slides_preview,
"resolve_app_path_to_filesystem",
fake_resolve,
)
html = (
'<img src="http://127.0.0.1:5001/app_data/pptx-to-html/session/images/asset.png">'
"<div style=\"background-image: url('/app_data/pptx-to-html/session/images/asset.png')\"></div>"
)
localized = fonts_and_slides_preview._localize_preview_asset_urls(html)
assert localized.count("data:image/png;base64,cG5n") == 2
assert "http://127.0.0.1:5001/app_data" not in localized
assert "url('data:image/png;base64,cG5n')" in localized
def test_localize_preview_asset_urls_absolutizes_unresolved_app_data_urls(
monkeypatch,
):
monkeypatch.setattr(
fonts_and_slides_preview,
"resolve_app_path_to_filesystem",
lambda _path_or_url: None,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"absolute_fastapi_asset_url",
lambda path: f"http://backend.test{path}",
)
html = (
"@font-face { font-family: 'Aileron'; "
"src: url('/app_data/pptx-to-html/session/fonts/aileron.otf'); }"
)
localized = fonts_and_slides_preview._localize_preview_asset_urls(html)
assert (
"url('http://backend.test/app_data/pptx-to-html/session/fonts/aileron.otf')"
in localized
)
def test_localize_preview_asset_urls_leaves_external_urls(monkeypatch):
calls = []
monkeypatch.setattr(
fonts_and_slides_preview,
"resolve_app_path_to_filesystem",
lambda path_or_url: calls.append(path_or_url) or None,
)
html = '<img src="https://example.com/image.png">'
assert fonts_and_slides_preview._localize_preview_asset_urls(html) == html
assert calls == []
@pytest.mark.anyio
async def test_create_slide_previews_from_html_uses_converter_dimensions_and_fonts(
monkeypatch,
tmp_path,
):
font_path = tmp_path / "Khand-Bold.ttf"
font_path.write_bytes(b"font")
rendered_path = tmp_path / "slide.png"
rendered_path.write_bytes(b"png")
render_calls = []
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_details",
lambda path: pptx_font_utils.FontDetail(
file=path,
size_bytes=123,
family_name="Khand",
full_name="Khand Bold",
subfamily_name="Bold",
weight_class=700,
),
)
class FakeExportTaskService:
async def convert_pptx_to_html(self, pptx_path, get_fonts=False):
assert pptx_path == "deck.pptx"
assert get_fonts is True
return SimpleNamespace(
slides=['<div class="slide-content">Slide</div>'],
font_css=".deck-font { color: black; }",
width=1024.0,
height=768.0,
)
async def render_htmls_to_images(self, htmls, width, height):
render_calls.append((htmls, width, height))
return SimpleNamespace(paths=[str(rendered_path)])
monkeypatch.setattr(
fonts_and_slides_preview,
"EXPORT_TASK_SERVICE",
FakeExportTaskService(),
)
result = await fonts_and_slides_preview.render_pptx_slides_to_images(
modified_pptx_path="deck.pptx",
font_paths_for_install=[str(font_path)],
max_slides=1,
logger=DummyLogger(),
font_stylesheet_urls=[
"https://fonts.googleapis.com/css2?family=Montserrat:wght@400&display=swap"
],
)
assert result == [str(rendered_path)]
assert len(render_calls) == 1
htmls, width, height = render_calls[0]
assert width == 1024
assert height == 768
assert len(htmls) == 1
html = htmls[0]
assert '<script src="https://cdn.tailwindcss.com"></script>' in html
assert (
'<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>'
in html
)
assert ".deck-font { color: black; }" in html
assert 'font-family: "Khand Bold";' in html
assert "fonts.googleapis.com/css2?family=Montserrat" in html
@pytest.mark.anyio
async def test_create_slide_previews_from_html_batches_slides_in_one_task(
monkeypatch,
tmp_path,
):
output_paths = [tmp_path / "slide-1.png", tmp_path / "slide-2.png"]
for output_path in output_paths:
output_path.write_bytes(b"png")
render_calls = []
class FakeExportTaskService:
async def convert_pptx_to_html(self, pptx_path, get_fonts=False):
return SimpleNamespace(
slides=[
'<div class="slide-content">One</div>',
'<div class="slide-content">Two</div>',
],
font_css="",
width=320.0,
height=180.0,
)
async def render_htmls_to_images(self, htmls, width, height):
render_calls.append((htmls, width, height))
return SimpleNamespace(paths=[str(path) for path in output_paths])
monkeypatch.setattr(
fonts_and_slides_preview,
"EXPORT_TASK_SERVICE",
FakeExportTaskService(),
)
result = await fonts_and_slides_preview.render_pptx_slides_to_images(
modified_pptx_path="deck.pptx",
font_paths_for_install=[],
max_slides=None,
logger=DummyLogger(),
)
assert len(render_calls) == 1
htmls, width, height = render_calls[0]
assert width == 320
assert height == 180
assert len(htmls) == 2
assert "One" in htmls[0]
assert "Two" in htmls[1]
assert result == [str(path) for path in output_paths]
@pytest.mark.anyio
async def test_render_pptx_slides_to_images_rejects_image_count_mismatch(monkeypatch):
class FakeExportTaskService:
async def convert_pptx_to_html(self, pptx_path, get_fonts=False):
return SimpleNamespace(
slides=["<div>One</div>", "<div>Two</div>"],
font_css="",
width=320.0,
height=180.0,
)
async def render_htmls_to_images(self, htmls, width, height):
return SimpleNamespace(paths=["slide-1.png"])
monkeypatch.setattr(
fonts_and_slides_preview,
"EXPORT_TASK_SERVICE",
FakeExportTaskService(),
)
with pytest.raises(fonts_and_slides_preview.HTTPException) as exc_info:
await fonts_and_slides_preview.render_pptx_slides_to_images(
modified_pptx_path="deck.pptx",
font_paths_for_install=[],
max_slides=None,
logger=DummyLogger(),
)
assert exc_info.value.status_code == 500
assert "expected 2, got 1" in exc_info.value.detail
@pytest.mark.anyio
async def test_create_slide_previews_uses_html_render_path(monkeypatch, tmp_path):
html_paths = [str(tmp_path / "slide1.png"), str(tmp_path / "slide2.png")]
async def fake_create_from_html(
modified_pptx_path,
font_paths_for_install,
max_slides,
logger,
font_stylesheet_urls=None,
):
assert modified_pptx_path == "deck.pptx"
assert font_paths_for_install == ["font.ttf"]
assert max_slides == 2
assert font_stylesheet_urls is None
return html_paths
async def fake_persist_files_to_session(pairs):
return [destination for destination, _source in pairs]
monkeypatch.setattr(
fonts_and_slides_preview,
"render_pptx_slides_to_images",
fake_create_from_html,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"_persist_files_to_session",
fake_persist_files_to_session,
)
result = await fonts_and_slides_preview.create_slide_previews(
modified_pptx_path="deck.pptx",
temp_dir=str(tmp_path),
font_paths_for_install=["font.ttf"],
font_mapping={},
explicit_font_aliases=None,
protected_font_names=None,
max_slides=2,
logger=DummyLogger(),
session_dir=str(tmp_path / "session"),
)
assert result == [
str(tmp_path / "session" / "slide_1.png"),
str(tmp_path / "session" / "slide_2.png"),
]
def test_create_font_alias_config_protects_embedded_font_names(tmp_path):
alias_path = pptx_font_utils.create_font_alias_config(
["Akzidenz-Grotesk Heavy", "Open Sauce Bold"],
temp_dir=str(tmp_path),
protected_font_names=["Akzidenz-Grotesk Heavy"],
)
alias_xml = open(alias_path, encoding="utf-8").read()
assert "<string>Akzidenz-Grotesk</string>" not in alias_xml
assert "<string>Akzidenz-Grotesk Heavy</string>" not in alias_xml
assert "<string>Open Sauce Bold</string>" in alias_xml
assert "<string>Open Sauce</string>" in alias_xml
def test_create_font_alias_config_preserves_explicit_aliases(tmp_path):
alias_path = pptx_font_utils.create_font_alias_config(
["Legacy Font Heavy", "Installed Font Heavy"],
temp_dir=str(tmp_path),
explicit_aliases={"Legacy Font Heavy": "Installed Font Heavy"},
)
alias_xml = open(alias_path, encoding="utf-8").read()
assert "<string>Legacy Font Heavy</string>" in alias_xml
assert "<string>Installed Font Heavy</string>" in alias_xml
assert "<string>Legacy Font</string>" not in alias_xml
def test_get_available_and_unavailable_fonts_for_pptx_returns_bold_google_font_url(
monkeypatch,
):
async def fake_to_thread(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(pptx_font_utils.asyncio, "to_thread", fake_to_thread)
monkeypatch.setattr(
pptx_font_utils,
"extract_raw_fonts_and_embedded_details",
lambda pptx_path, temp_dir: ({"Open Sans"}, [], []),
)
async def fake_check_google_font_availability(font_name: str, variants=None) -> bool:
assert font_name == "Open Sans"
assert variants == ["regular"]
return True
monkeypatch.setattr(
pptx_font_utils,
"check_google_font_availability",
fake_check_google_font_availability,
)
available_fonts, unavailable_fonts = asyncio.run(
pptx_font_utils.get_available_and_unavailable_fonts_for_pptx(
"presentation.pptx", "/tmp"
)
)
assert unavailable_fonts == []
assert available_fonts == [
(
"Open Sans",
"https://fonts.googleapis.com/css2?family=Open+Sans:wght@400&display=swap",
["regular"],
)
]
def test_get_available_and_unavailable_fonts_for_pptx_returns_variant_google_font_url(
monkeypatch,
):
async def fake_to_thread(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(pptx_font_utils.asyncio, "to_thread", fake_to_thread)
monkeypatch.setattr(
pptx_font_utils,
"extract_raw_fonts_and_embedded_details",
lambda pptx_path, temp_dir: ({"Montserrat"}, [], []),
)
monkeypatch.setattr(
pptx_font_utils,
"extract_used_font_variants_from_pptx",
lambda pptx_path: {"Montserrat": {"regular", "bold", "italic"}},
)
async def fake_check_google_font_availability(font_name: str, variants=None) -> bool:
assert variants == ["regular", "bold", "italic"]
return True
monkeypatch.setattr(
pptx_font_utils,
"check_google_font_availability",
fake_check_google_font_availability,
)
available_fonts, unavailable_fonts = asyncio.run(
pptx_font_utils.get_available_and_unavailable_fonts_for_pptx(
"presentation.pptx", "/tmp"
)
)
assert unavailable_fonts == []
assert available_fonts == [
(
"Montserrat",
"https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,400;0,700;1,400&display=swap",
["regular", "bold", "italic"],
)
]
def test_get_available_and_unavailable_fonts_for_pptx_is_variant_aware(
monkeypatch,
):
async def fake_to_thread(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(pptx_font_utils.asyncio, "to_thread", fake_to_thread)
monkeypatch.setattr(
pptx_font_utils,
"extract_raw_fonts_and_embedded_details",
lambda pptx_path, temp_dir: (
{"HK Grotesk", "HK Grotesk Semi-Bold"},
[object()],
[],
),
)
monkeypatch.setattr(
pptx_font_utils,
"extract_used_font_variants_from_pptx",
lambda pptx_path: {
"HK Grotesk": {"regular"},
"HK Grotesk Semi-Bold": {"bold"},
},
)
monkeypatch.setattr(
pptx_font_utils,
"get_index_of_matching_font_detail_or_none",
lambda font_name, _details: 0
if font_name == "HK Grotesk Semi-Bold"
else None,
)
async def fake_check_google_font_availability(font_name: str, variants=None) -> bool:
assert font_name == "HK Grotesk"
assert variants == ["regular"]
return False
monkeypatch.setattr(
pptx_font_utils,
"check_google_font_availability",
fake_check_google_font_availability,
)
available_fonts, unavailable_fonts = asyncio.run(
pptx_font_utils.get_available_and_unavailable_fonts_for_pptx(
"presentation.pptx", "/tmp"
)
)
assert available_fonts == [
(
"HK Grotesk Semi-Bold",
"https://example.com/just-a-placeholder-url.ttf",
["bold"],
)
]
assert unavailable_fonts == [("HK Grotesk", None, ["regular"])]
def test_extract_used_fonts_from_pptx_only_returns_fonts_used_by_slide_content(tmp_path):
pptx_path = tmp_path / "font-check.pptx"
files = {
"ppt/presentation.xml": """\
<p:presentation xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:sldIdLst>
<p:sldId id="256" r:id="rId1"/>
</p:sldIdLst>
<p:embeddedFontLst>
<p:embeddedFont>
<p:font typeface="Unused Embedded Font"/>
<p:regular r:id="rIdEmbedded"/>
</p:embeddedFont>
</p:embeddedFontLst>
</p:presentation>
""",
"ppt/_rels/presentation.xml.rels": """\
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
Target="slides/slide1.xml"/>
<Relationship Id="rIdTheme"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme"
Target="theme/theme1.xml"/>
</Relationships>
""",
"ppt/theme/theme1.xml": """\
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:themeElements>
<a:fontScheme name="Custom">
<a:majorFont>
<a:latin typeface="Heading Theme Font"/>
</a:majorFont>
<a:minorFont>
<a:latin typeface="Body Theme Font"/>
</a:minorFont>
</a:fontScheme>
</a:themeElements>
</a:theme>
""",
"ppt/slides/slide1.xml": """\
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:cSld>
<p:spTree>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="2" name="Body Placeholder"/>
<p:cNvSpPr/>
<p:nvPr>
<p:ph type="body" idx="1"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr/>
<p:txBody>
<a:bodyPr/>
<a:lstStyle/>
<a:p>
<a:r>
<a:t>Hello world</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>
""",
"ppt/slides/_rels/slide1.xml.rels": """\
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout"
Target="../slideLayouts/slideLayout1.xml"/>
</Relationships>
""",
"ppt/slideLayouts/slideLayout1.xml": """\
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp>
<p:nvSpPr>
<p:cNvPr id="3" name="Layout Placeholder"/>
<p:cNvSpPr/>
<p:nvPr>
<p:ph type="body" idx="1"/>
</p:nvPr>
</p:nvSpPr>
<p:spPr/>
<p:txBody>
<a:bodyPr/>
<a:lstStyle/>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sldLayout>
""",
"ppt/slideLayouts/_rels/slideLayout1.xml.rels": """\
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"
Target="../slideMasters/slideMaster1.xml"/>
</Relationships>
""",
"ppt/slideMasters/slideMaster1.xml": """\
<p:sldMaster xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree/>
</p:cSld>
<p:txStyles>
<p:bodyStyle>
<a:lvl1pPr>
<a:defRPr>
<a:latin typeface="+mn-lt"/>
</a:defRPr>
</a:lvl1pPr>
</p:bodyStyle>
</p:txStyles>
</p:sldMaster>
""",
}
with zipfile.ZipFile(pptx_path, "w") as archive:
for name, content in files.items():
archive.writestr(name, content)
assert pptx_font_utils.extract_used_fonts_from_pptx(str(pptx_path)) == {
"Body Theme Font"
}
def test_extract_used_fonts_from_pptx_prefers_latin_font_over_fallbacks(tmp_path):
pptx_path = tmp_path / "font-fallbacks.pptx"
with zipfile.ZipFile(pptx_path, "w") as archive:
archive.writestr(
"ppt/presentation.xml",
"""\
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:sldIdLst>
<p:sldId id="256" r:id="rId1"/>
</p:sldIdLst>
</p:presentation>
""",
)
archive.writestr(
"ppt/_rels/presentation.xml.rels",
"""\
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
Target="slides/slide1.xml"/>
</Relationships>
""",
)
archive.writestr(
"ppt/slides/slide1.xml",
"""\
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp>
<p:txBody>
<a:p>
<a:r>
<a:rPr>
<a:latin typeface="Georgia"/>
<a:ea typeface="Arial"/>
<a:cs typeface="Arial"/>
</a:rPr>
<a:t>Hello world</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>
""",
)
assert pptx_font_utils.extract_used_fonts_from_pptx(str(pptx_path)) == {"Georgia"}
def test_extract_used_font_variants_from_pptx_reads_bold_and_italic_runs(tmp_path):
pptx_path = tmp_path / "font-variants.pptx"
with zipfile.ZipFile(pptx_path, "w") as archive:
archive.writestr(
"ppt/presentation.xml",
"""\
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:sldIdLst>
<p:sldId id="256" r:id="rId1"/>
</p:sldIdLst>
</p:presentation>
""",
)
archive.writestr(
"ppt/_rels/presentation.xml.rels",
"""\
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1"
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
Target="slides/slide1.xml"/>
</Relationships>
""",
)
archive.writestr(
"ppt/slides/slide1.xml",
"""\
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp>
<p:txBody>
<a:p>
<a:r>
<a:rPr b="1">
<a:latin typeface="Montserrat"/>
</a:rPr>
<a:t>Bold</a:t>
</a:r>
<a:r>
<a:rPr i="1">
<a:latin typeface="Montserrat"/>
</a:rPr>
<a:t>Italic</a:t>
</a:r>
<a:r>
<a:rPr b="1" i="1">
<a:latin typeface="Georgia"/>
</a:rPr>
<a:t>Bold italic</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>
""",
)
assert pptx_font_utils.extract_used_font_variants_from_pptx(str(pptx_path)) == {
"Georgia": {"bold_italic"},
"Montserrat": {"bold", "italic"},
}
def test_replace_fonts_in_pptx_uses_variant_specific_family_names(tmp_path):
pptx_path = tmp_path / "font-replace.pptx"
output_path = tmp_path / "font-replaced.pptx"
with zipfile.ZipFile(pptx_path, "w") as archive:
archive.writestr(
"ppt/slides/slide1.xml",
"""\
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp>
<p:txBody>
<a:p>
<a:pPr>
<a:defRPr>
<a:latin typeface="Arial"/>
</a:defRPr>
</a:pPr>
<a:r>
<a:rPr>
<a:latin typeface="Arial"/>
</a:rPr>
<a:t>Regular</a:t>
</a:r>
<a:r>
<a:rPr b="1">
<a:latin typeface="Arial"/>
</a:rPr>
<a:t>Bold</a:t>
</a:r>
<a:r>
<a:rPr i="1">
<a:latin typeface="Arial"/>
</a:rPr>
<a:t>Italic</a:t>
</a:r>
<a:r>
<a:rPr b="1"/>
<a:t>Inherited bold</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>
""",
)
pptx_font_utils.replace_fonts_in_pptx(
str(pptx_path),
{"Arial": "Arial Regular"},
str(output_path),
font_variant_mapping={
"Arial": {
"regular": "Arial Regular",
"bold": "Arial Bold",
"italic": "Arial Italic",
}
},
)
with zipfile.ZipFile(output_path, "r") as archive:
xml = archive.read("ppt/slides/slide1.xml").decode("utf-8")
assert 'typeface="Arial Regular"' in xml
assert 'typeface="Arial Bold"' in xml
assert 'typeface="Arial Italic"' in xml
assert xml.count('typeface="Arial Bold"') == 2
def test_replace_fonts_in_pptx_rewrites_xml_without_variant_mapping(tmp_path):
pptx_path = tmp_path / "font-replace-simple.pptx"
output_path = tmp_path / "font-replaced-simple.pptx"
files = {
"ppt/slides/slide1.xml": """\
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp>
<p:txBody>
<a:p>
<a:r>
<a:rPr>
<a:latin typeface="Akzidenz-Grotesk Heavy"/>
</a:rPr>
<a:t>Slide</a:t>
</a:r>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sld>
""",
"ppt/slideLayouts/slideLayout1.xml": """\
<p:sldLayout xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main">
<p:cSld>
<p:spTree>
<p:sp>
<p:txBody>
<a:p>
<a:pPr>
<a:defRPr>
<a:latin typeface="Akzidenz-Grotesk Heavy"/>
</a:defRPr>
</a:pPr>
</a:p>
</p:txBody>
</p:sp>
</p:spTree>
</p:cSld>
</p:sldLayout>
""",
"ppt/charts/chart1.xml": """\
<c:chartSpace xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart">
<c:chart>
<c:title>
<c:tx>
<c:rich>
<a:p>
<a:r>
<a:rPr>
<a:latin typeface="Akzidenz-Grotesk Heavy"/>
</a:rPr>
<a:t>Chart</a:t>
</a:r>
</a:p>
</c:rich>
</c:tx>
</c:title>
</c:chart>
</c:chartSpace>
""",
}
with zipfile.ZipFile(pptx_path, "w") as archive:
for name, content in files.items():
archive.writestr(name, content)
pptx_font_utils.replace_fonts_in_pptx(
str(pptx_path),
{"Akzidenz-Grotesk Heavy": "Akzidenz-Grotesk Black"},
str(output_path),
)
with zipfile.ZipFile(output_path, "r") as archive:
for name in files:
xml = archive.read(name).decode("utf-8")
assert 'typeface="Akzidenz-Grotesk Black"' in xml
assert "Akzidenz-Grotesk Heavy" not in xml
@pytest.mark.anyio
async def test_uploaded_font_mapping_uses_original_variant_name_and_uploaded_actual_name(
monkeypatch,
tmp_path,
):
monkeypatch.setattr(
fonts_and_slides_preview.asyncio, "to_thread", _run_sync_in_test
)
def fake_get_font_details(path: str) -> pptx_font_utils.FontDetail:
if path.endswith("Calibri-Bold.ttf"):
return pptx_font_utils.FontDetail(
file=path,
size_bytes=123,
family_name="Calibri",
full_name="Calibri Bold",
subfamily_name="Bold",
weight_class=700,
)
return pptx_font_utils.FontDetail(
file=path,
size_bytes=123,
family_name="Calibri",
full_name="Calibri Regular",
subfamily_name="Regular",
weight_class=400,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_details",
fake_get_font_details,
)
(
custom_font_files,
font_mapping,
font_variant_mapping,
) = await fonts_and_slides_preview._save_uploaded_fonts_to_temp(
[
DummyUploadFile("Calibri-Bold.ttf"),
DummyUploadFile("Calibri-Regular.ttf"),
],
["Arial Bold", "Arial Regular"],
str(tmp_path),
DummyLogger(),
)
assert [original_name for _, original_name in custom_font_files] == [
"Arial Bold",
"Arial Regular",
]
assert font_mapping == {
"Arial Bold": "Calibri Bold",
"Arial Regular": "Calibri Regular",
}
assert font_variant_mapping["Arial"] == {
"bold": "Calibri Bold",
"regular": "Calibri Regular",
}
assert font_variant_mapping["Arial Bold"] == {"bold": "Calibri Bold"}
assert font_variant_mapping["Arial Regular"] == {"regular": "Calibri Regular"}
@pytest.mark.anyio
async def test_direct_upload_uses_canonical_name_for_localized_same_family_variant(
monkeypatch,
tmp_path,
):
monkeypatch.setattr(
fonts_and_slides_preview.asyncio, "to_thread", _run_sync_in_test
)
def fake_get_font_details(path: str) -> pptx_font_utils.FontDetail:
return pptx_font_utils.FontDetail(
file=path,
size_bytes=123,
family_name="Arial Gras",
full_name="Arial Gras",
subfamily_name="Gras",
weight_class=700,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_details",
fake_get_font_details,
)
(
_custom_font_files,
font_mapping,
font_variant_mapping,
) = await fonts_and_slides_preview._save_uploaded_fonts_to_temp(
[DummyUploadFile("arialbd.ttf")],
["Arial Bold"],
str(tmp_path),
DummyLogger(),
)
assert font_mapping == {"Arial Bold": "Arial Bold"}
assert font_variant_mapping["Arial"] == {"bold": "Arial Bold"}
assert font_variant_mapping["Arial Bold"] == {"bold": "Arial Bold"}
@pytest.mark.anyio
async def test_direct_upload_keeps_different_replacement_font_family(
monkeypatch,
tmp_path,
):
monkeypatch.setattr(
fonts_and_slides_preview.asyncio, "to_thread", _run_sync_in_test
)
def fake_get_font_details(path: str) -> pptx_font_utils.FontDetail:
return pptx_font_utils.FontDetail(
file=path,
size_bytes=123,
family_name="Khand",
full_name="Khand Bold",
subfamily_name="Bold",
weight_class=700,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_details",
fake_get_font_details,
)
(
_custom_font_files,
font_mapping,
font_variant_mapping,
) = await fonts_and_slides_preview._save_uploaded_fonts_to_temp(
[DummyUploadFile("Khand-Bold.ttf")],
["Arial Bold"],
str(tmp_path),
DummyLogger(),
)
assert font_mapping == {"Arial Bold": "Khand Bold"}
assert font_variant_mapping["Arial"] == {"bold": "Khand Bold"}
assert font_variant_mapping["Arial Bold"] == {"bold": "Khand Bold"}
@pytest.mark.anyio
async def test_custom_uploads_skip_saved_font_lookup_for_exact_variant_names(
monkeypatch,
tmp_path,
):
captured_candidates = []
captured_replacement = {}
def fake_get_font_details(path: str) -> pptx_font_utils.FontDetail:
if path.endswith("Calibri-Bold.ttf"):
return pptx_font_utils.FontDetail(
file=path,
size_bytes=123,
family_name="Calibri",
full_name="Calibri Bold",
subfamily_name="Bold",
weight_class=700,
)
return pptx_font_utils.FontDetail(
file=path,
size_bytes=123,
family_name="Calibri",
full_name="Calibri Regular",
subfamily_name="Regular",
weight_class=400,
)
async def fake_get_font_uploads_for_names_by_variant(font_names):
captured_candidates.extend(font_names)
return {}
def fake_replace_fonts_in_pptx(
_pptx_path,
font_mapping,
_output_path,
font_variant_mapping=None,
):
captured_replacement["font_mapping"] = font_mapping
captured_replacement["font_variant_mapping"] = font_variant_mapping
monkeypatch.setattr(
fonts_and_slides_preview,
"extract_raw_fonts_and_embedded_details",
lambda *_args: ({"Arial Bold", "Arial Regular"}, [], []),
)
monkeypatch.setattr(
fonts_and_slides_preview,
"_font_variants_by_normalized_name",
lambda *_args: {"Arial": {"regular", "bold"}},
)
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_details",
fake_get_font_details,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_uploads_for_names_by_variant",
fake_get_font_uploads_for_names_by_variant,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"replace_fonts_in_pptx",
fake_replace_fonts_in_pptx,
)
await fonts_and_slides_preview.upload_fonts_and_fix_fonts_in_pptx(
pptx_path="deck.pptx",
temp_dir=str(tmp_path),
original_filename="deck.pptx",
font_files=[
DummyUploadFile("Calibri-Bold.ttf"),
DummyUploadFile("Calibri-Regular.ttf"),
],
original_font_names=["Arial Bold", "Arial Regular"],
logger=DummyLogger(),
session_dir=str(tmp_path / "session"),
upload_fonts=False,
)
assert captured_candidates == []
assert captured_replacement["font_mapping"] == {
"Arial Bold": "Calibri Bold",
"Arial Regular": "Calibri Regular",
}
assert captured_replacement["font_variant_mapping"]["Arial"] == {
"bold": "Calibri Bold",
"regular": "Calibri Regular",
}
@pytest.mark.anyio
async def test_saved_font_uploads_are_used_for_missing_preview_fonts(
monkeypatch,
tmp_path,
):
saved_regular = FontUpload(
filename="Arial-Regular.ttf",
path=str(tmp_path / "Arial-Regular.ttf"),
normalized_family_name="Arial",
family_name="Arial",
full_name="Arial Regular",
subfamily_name="Regular",
weight_class=400,
size_bytes=123,
)
saved_bold = FontUpload(
filename="Arial-Bold.ttf",
path=str(tmp_path / "Arial-Bold.ttf"),
normalized_family_name="Arial",
family_name="Arial",
full_name="Arial Bold",
subfamily_name="Bold",
weight_class=700,
size_bytes=123,
)
regular_path = tmp_path / "downloaded-regular.ttf"
bold_path = tmp_path / "downloaded-bold.ttf"
regular_path.write_bytes(b"regular")
bold_path.write_bytes(b"bold")
captured_replacement = {}
captured_candidates = []
async def fake_get_font_uploads_for_names_by_variant(font_names):
captured_candidates.extend(font_names)
return {"Arial": {"regular": saved_regular, "bold": saved_bold}}
async def fake_download_font_uploads(font_uploads, _temp_dir):
assert {font.filename for font in font_uploads} == {
"Arial-Regular.ttf",
"Arial-Bold.ttf",
}
return {
saved_regular.id: str(regular_path),
saved_bold.id: str(bold_path),
}
async def fake_get_font_upload_url(font_upload):
return f"/app_data/fonts/{font_upload.filename}"
def fake_replace_fonts_in_pptx(
_pptx_path,
font_mapping,
_output_path,
font_variant_mapping=None,
):
captured_replacement["font_mapping"] = font_mapping
captured_replacement["font_variant_mapping"] = font_variant_mapping
monkeypatch.setattr(
fonts_and_slides_preview,
"extract_raw_fonts_and_embedded_details",
lambda *_args: ({"Arial"}, [], []),
)
monkeypatch.setattr(
fonts_and_slides_preview,
"_font_variants_by_normalized_name",
lambda *_args: {"Arial": {"regular", "bold"}},
)
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_uploads_for_names_by_variant",
fake_get_font_uploads_for_names_by_variant,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"download_font_uploads",
fake_download_font_uploads,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_upload_url",
fake_get_font_upload_url,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"replace_fonts_in_pptx",
fake_replace_fonts_in_pptx,
)
result = await fonts_and_slides_preview.upload_fonts_and_fix_fonts_in_pptx(
pptx_path="deck.pptx",
temp_dir=str(tmp_path),
original_filename="deck.pptx",
font_files=None,
original_font_names=None,
logger=DummyLogger(),
session_dir=str(tmp_path / "session"),
upload_fonts=True,
)
assert captured_candidates == ["Arial"]
assert captured_replacement["font_mapping"] == {"Arial": "Arial Regular"}
assert captured_replacement["font_variant_mapping"]["Arial"] == {
"regular": "Arial Regular",
"bold": "Arial Bold",
}
assert str(regular_path) in result[5]
assert str(bold_path) in result[5]
assert result[1] == {
"Arial Regular": "/app_data/fonts/Arial-Regular.ttf",
"Arial Bold": "/app_data/fonts/Arial-Bold.ttf",
}
@pytest.mark.anyio
async def test_saved_font_upload_for_explicit_variant_uses_normalized_family_name(
monkeypatch,
tmp_path,
):
saved_bold = FontUpload(
filename="Arial-Bold.ttf",
path=str(tmp_path / "Arial-Bold.ttf"),
normalized_family_name="Arial",
family_name="Arial",
full_name="Arial Bold",
subfamily_name="Bold",
weight_class=700,
size_bytes=123,
)
bold_path = tmp_path / "downloaded-bold.ttf"
bold_path.write_bytes(b"bold")
captured_replacement = {}
async def fake_get_font_uploads_for_names_by_variant(font_names):
assert font_names == ["Arial Bold"]
return {"Arial Bold": {"bold": saved_bold}}
async def fake_download_font_uploads(_font_uploads, _temp_dir):
return {saved_bold.id: str(bold_path)}
async def fake_get_font_upload_url(font_upload):
return f"/app_data/fonts/{font_upload.filename}"
def fake_replace_fonts_in_pptx(
_pptx_path,
font_mapping,
_output_path,
font_variant_mapping=None,
):
captured_replacement["font_mapping"] = font_mapping
captured_replacement["font_variant_mapping"] = font_variant_mapping
monkeypatch.setattr(
fonts_and_slides_preview,
"extract_raw_fonts_and_embedded_details",
lambda *_args: ({"Arial Bold"}, [], []),
)
monkeypatch.setattr(
fonts_and_slides_preview,
"_font_variants_by_normalized_name",
lambda *_args: {"Arial": {"bold"}},
)
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_uploads_for_names_by_variant",
fake_get_font_uploads_for_names_by_variant,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"download_font_uploads",
fake_download_font_uploads,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_upload_url",
fake_get_font_upload_url,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"replace_fonts_in_pptx",
fake_replace_fonts_in_pptx,
)
result = await fonts_and_slides_preview.upload_fonts_and_fix_fonts_in_pptx(
pptx_path="deck.pptx",
temp_dir=str(tmp_path),
original_filename="deck.pptx",
font_files=None,
original_font_names=None,
logger=DummyLogger(),
session_dir=str(tmp_path / "session"),
upload_fonts=True,
)
assert captured_replacement["font_mapping"] == {"Arial Bold": "Arial Bold"}
assert captured_replacement["font_variant_mapping"]["Arial Bold"] == {
"bold": "Arial Bold"
}
assert captured_replacement["font_variant_mapping"]["Arial"] == {
"bold": "Arial Bold"
}
assert result[1] == {"Arial Bold": "/app_data/fonts/Arial-Bold.ttf"}
@pytest.mark.anyio
async def test_google_font_replacements_are_used_for_pptx_font_mapping(
monkeypatch,
tmp_path,
):
captured_replacement = {}
captured_candidates = []
async def fake_get_font_uploads_for_names_by_variant(font_names):
captured_candidates.extend(font_names)
return {}
def fake_replace_fonts_in_pptx(
_pptx_path,
font_mapping,
_output_path,
font_variant_mapping=None,
):
captured_replacement["font_mapping"] = font_mapping
captured_replacement["font_variant_mapping"] = font_variant_mapping
monkeypatch.setattr(
fonts_and_slides_preview,
"extract_raw_fonts_and_embedded_details",
lambda *_args: ({"Arial"}, [], []),
)
monkeypatch.setattr(
fonts_and_slides_preview,
"_font_variants_by_normalized_name",
lambda *_args: {"Arial": {"bold"}},
)
monkeypatch.setattr(
fonts_and_slides_preview,
"get_font_uploads_for_names_by_variant",
fake_get_font_uploads_for_names_by_variant,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"replace_fonts_in_pptx",
fake_replace_fonts_in_pptx,
)
await fonts_and_slides_preview.upload_fonts_and_fix_fonts_in_pptx(
pptx_path="deck.pptx",
temp_dir=str(tmp_path),
original_filename="deck.pptx",
font_files=None,
original_font_names=None,
logger=DummyLogger(),
session_dir=str(tmp_path / "session"),
upload_fonts=False,
google_font_replacements={"Arial Bold": "Poppins"},
)
assert captured_candidates == []
assert captured_replacement["font_mapping"] == {"Arial Bold": "Poppins"}
assert captured_replacement["font_variant_mapping"]["Arial"] == {
"bold": "Poppins"
}
assert captured_replacement["font_variant_mapping"]["Arial Bold"] == {
"bold": "Poppins"
}
@pytest.mark.anyio
async def test_embedded_fonts_are_installed_without_rewriting_pptx_names(
monkeypatch,
tmp_path,
):
pptx_path = tmp_path / "deck.pptx"
pptx_path.write_bytes(b"pptx")
captured_replacement = {}
async def fake_prepare_embedded_fonts(*_args, **_kwargs):
return (
{"Akzidenz-Grotesk Heavy": "https://example.com/akzidenz-black.otf"},
{"Akzidenz-Grotesk Heavy": str(tmp_path / "akzidenz-black.otf")},
{"Akzidenz-Grotesk Heavy": "Akzidenz-Grotesk Black"},
)
def fake_replace_fonts_in_pptx(
_pptx_path,
font_mapping,
_output_path,
font_variant_mapping=None,
):
captured_replacement["font_mapping"] = font_mapping
captured_replacement["font_variant_mapping"] = font_variant_mapping
monkeypatch.setattr(
fonts_and_slides_preview,
"extract_raw_fonts_and_embedded_details",
lambda *_args: ({"Akzidenz-Grotesk Heavy"}, [], []),
)
monkeypatch.setattr(
fonts_and_slides_preview,
"_prepare_embedded_fonts",
fake_prepare_embedded_fonts,
)
monkeypatch.setattr(
fonts_and_slides_preview,
"replace_fonts_in_pptx",
fake_replace_fonts_in_pptx,
)
result = await fonts_and_slides_preview.upload_fonts_and_fix_fonts_in_pptx(
pptx_path=str(pptx_path),
temp_dir=str(tmp_path),
original_filename="deck.pptx",
font_files=None,
original_font_names=None,
logger=DummyLogger(),
session_dir=str(tmp_path / "session"),
upload_fonts=True,
)
assert "font_mapping" not in captured_replacement
assert result[1] == {
"Akzidenz-Grotesk Heavy": "https://example.com/akzidenz-black.otf",
}
assert result[2] == {}
assert result[5] == [str(tmp_path / "akzidenz-black.otf")]
assert result[4] == str(pptx_path)
assert result[7] == {}
assert result[8] == ["Akzidenz-Grotesk Heavy"]
@pytest.mark.anyio
async def test_download_available_google_fonts_skips_when_api_key_missing(
monkeypatch,
tmp_path,
):
calls = []
async def fake_get_google_font_file_urls(*args, **kwargs):
calls.append((args, kwargs))
return []
monkeypatch.delenv("GOOGLE_FONTS_API_KEY", raising=False)
monkeypatch.setattr(
fonts_and_slides_preview,
"get_google_font_file_urls",
fake_get_google_font_file_urls,
)
result = await fonts_and_slides_preview._download_available_google_fonts(
{"Montserrat"},
str(tmp_path),
DummyLogger(),
)
assert result == []
assert calls == []