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'' for index in range(1, slide_count + 1) ) rels = "\n".join( f'' for index in range(1, slide_count + 1) ) overrides = "\n".join( f'' for index in range(1, slide_count + 1) ) with zipfile.ZipFile(buffer, "w") as archive: archive.writestr( "[Content_Types].xml", f"""\ {overrides} """, ) archive.writestr( "ppt/presentation.xml", f"""\ {slide_ids} """, ) archive.writestr( "ppt/_rels/presentation.xml.rels", f"""\ {rels} """, ) for index in range(1, slide_count + 1): archive.writestr(f"ppt/slides/slide{index}.xml", "") archive.writestr(f"ppt/slides/_rels/slide{index}.xml.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(" """ assert pptx_font_utils.extract_fonts_from_oxml(xml) == [] def test_extract_fonts_from_oxml_prefers_latin_font_over_script_fallbacks(): xml = """\ Hello """ 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( '
Slide
', '@font-face { font-family: "Khand"; src: url("file:///font.ttf"); }', font_links='', width=1024, height=768, ) assert '' in html assert '' in html assert ( '' 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 '
Slide
' 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( "" "" ) 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( "" "" "", "@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( '
' 'CAMPAIGN GOALS" "
" ) 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 = ( '' "
" ) 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 = '' 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=['
Slide
'], 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 '' in html assert ( '' 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=[ '
One
', '
Two
', ], 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=["
One
", "
Two
"], 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 "Akzidenz-Grotesk" not in alias_xml assert "Akzidenz-Grotesk Heavy" not in alias_xml assert "Open Sauce Bold" in alias_xml assert "Open Sauce" 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 "Legacy Font Heavy" in alias_xml assert "Installed Font Heavy" in alias_xml assert "Legacy Font" 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": """\ """, "ppt/_rels/presentation.xml.rels": """\ """, "ppt/theme/theme1.xml": """\ """, "ppt/slides/slide1.xml": """\ Hello world """, "ppt/slides/_rels/slide1.xml.rels": """\ """, "ppt/slideLayouts/slideLayout1.xml": """\ """, "ppt/slideLayouts/_rels/slideLayout1.xml.rels": """\ """, "ppt/slideMasters/slideMaster1.xml": """\ """, } 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", """\ """, ) archive.writestr( "ppt/_rels/presentation.xml.rels", """\ """, ) archive.writestr( "ppt/slides/slide1.xml", """\ Hello world """, ) 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", """\ """, ) archive.writestr( "ppt/_rels/presentation.xml.rels", """\ """, ) archive.writestr( "ppt/slides/slide1.xml", """\ Bold Italic Bold italic """, ) 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", """\ Regular Bold Italic Inherited bold """, ) 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": """\ Slide """, "ppt/slideLayouts/slideLayout1.xml": """\ """, "ppt/charts/chart1.xml": """\ Chart """, } 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 == []