diff --git a/tests/plugins/tesseract_cache.py b/tests/plugins/tesseract_cache.py
index c417ccd2..8164c2e9 100644
--- a/tests/plugins/tesseract_cache.py
+++ b/tests/plugins/tesseract_cache.py
@@ -134,7 +134,7 @@ def cached_run(options, run_args, **run_kwargs):
args.configfiles.append('txt')
for configfile in args.configfiles:
- if configfile not in ('hocr', 'pdf', 'txt'):
+ if configfile not in ('fpdf2', 'pdf', 'txt'):
continue
# cp pwd/{outputbase}.{configfile} -> {cache}/{configfile}
tessfile = args.outputbase + '.' + configfile
diff --git a/tests/plugins/tesseract_debug_rotate.py b/tests/plugins/tesseract_debug_rotate.py
index 6bd8ba3c..771bbd28 100644
--- a/tests/plugins/tesseract_debug_rotate.py
+++ b/tests/plugins/tesseract_debug_rotate.py
@@ -5,7 +5,7 @@
To quickly run tests where getting OCR output is not necessary and we want to test
the rotation pipeline.
-In 'hocr' mode, create a .hocr file that specifies no text found.
+In generate_hocr mode, create a .hocr file that specifies no text found.
In 'pdf' mode, convert the image to PDF using another program.
diff --git a/tests/plugins/tesseract_noop.py b/tests/plugins/tesseract_noop.py
index 7f55a821..b8e109df 100644
--- a/tests/plugins/tesseract_noop.py
+++ b/tests/plugins/tesseract_noop.py
@@ -4,7 +4,7 @@
To quickly run tests where getting OCR output is not necessary.
-In 'hocr' mode, create a .hocr file that specifies no text found.
+In generate_hocr mode, create a .hocr file that specifies no text found.
In 'pdf' mode, convert the image to PDF using another program.
diff --git a/tests/resources/arabic.hocr b/tests/resources/arabic.hocr
new file mode 100644
index 00000000..af24e8ba
--- /dev/null
+++ b/tests/resources/arabic.hocr
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+नमस्ते
+दुनिया
+
+
+
+
+यह
+हिंदी
+पाठ
+है
+
+
+
+
+संस्कृत
+भाषा
+
+
+
+
+
+
diff --git a/tests/resources/hello_world_scripts.hocr b/tests/resources/hello_world_scripts.hocr
new file mode 100644
index 00000000..c7ff54ad
--- /dev/null
+++ b/tests/resources/hello_world_scripts.hocr
@@ -0,0 +1,192 @@
+
+
+
+
+
+
+
+
+The
+quick
+brown
+fox
+jumps
+
+
+
+
+Café
+résumé
+naïve
+
+
+
+
+Größe
+Zürich
+Ärger
+
+
+
+
+
+
diff --git a/tests/resources/multilingual.hocr b/tests/resources/multilingual.hocr
new file mode 100644
index 00000000..64254a92
--- /dev/null
+++ b/tests/resources/multilingual.hocr
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+English
+Text
+Here
+
+
+
+
+مرحبا
+بك
+
+
+
+
+
+
diff --git a/tests/test_fpdf_renderer.py b/tests/test_fpdf_renderer.py
new file mode 100644
index 00000000..ad04327a
--- /dev/null
+++ b/tests/test_fpdf_renderer.py
@@ -0,0 +1,366 @@
+# SPDX-FileCopyrightText: 2025 James R. Barlow
+# SPDX-License-Identifier: MPL-2.0
+
+"""Tests for fpdf2-based PDF renderer."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from ocrmypdf.font import MultiFontManager
+from ocrmypdf.fpdf_renderer import DebugRenderOptions, Fpdf2MultiPageRenderer, Fpdf2PdfRenderer
+from ocrmypdf.hocrtransform.hocr_parser import HocrParser
+from ocrmypdf.hocrtransform.ocr_element import OcrClass
+
+
+@pytest.fixture
+def font_dir():
+ """Return path to font directory."""
+ return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data"
+
+
+@pytest.fixture
+def multi_font_manager(font_dir):
+ """Create MultiFontManager instance for testing."""
+ return MultiFontManager(font_dir)
+
+
+@pytest.fixture
+def resources():
+ """Return path to test resources directory."""
+ return Path(__file__).parent / "resources"
+
+
+class TestFpdf2RendererImports:
+ """Test that all fpdf2 renderer modules can be imported."""
+
+ def test_imports(self):
+ """Test that all fpdf_renderer modules can be imported."""
+ from ocrmypdf.fpdf_renderer import (
+ DebugRenderOptions,
+ Fpdf2MultiPageRenderer,
+ Fpdf2PdfRenderer,
+ )
+ assert DebugRenderOptions is not None
+ assert Fpdf2PdfRenderer is not None
+ assert Fpdf2MultiPageRenderer is not None
+
+
+class TestDebugRenderOptions:
+ """Test DebugRenderOptions dataclass."""
+
+ def test_defaults(self):
+ """Test default values."""
+ opts = DebugRenderOptions()
+ assert opts.render_baseline is False
+ assert opts.render_line_bbox is False
+ assert opts.render_word_bbox is False
+
+ def test_custom_values(self):
+ """Test custom values."""
+ opts = DebugRenderOptions(
+ render_baseline=True,
+ render_line_bbox=True,
+ render_word_bbox=True,
+ )
+ assert opts.render_baseline is True
+ assert opts.render_line_bbox is True
+ assert opts.render_word_bbox is True
+
+
+class TestFpdf2PdfRenderer:
+ """Test Fpdf2PdfRenderer."""
+
+ def test_requires_page_element(self, multi_font_manager):
+ """Test that renderer requires ocr_page element."""
+ from ocrmypdf.hocrtransform.ocr_element import BoundingBox, OcrElement
+
+ # Create a non-page element
+ word = OcrElement(
+ ocr_class=OcrClass.WORD,
+ text="test",
+ bbox=BoundingBox(left=0, top=0, right=100, bottom=20),
+ )
+
+ with pytest.raises(ValueError, match="Root element must be ocr_page"):
+ Fpdf2PdfRenderer(
+ page=word,
+ dpi=300,
+ multi_font_manager=multi_font_manager,
+ )
+
+ def test_requires_bbox(self, multi_font_manager):
+ """Test that renderer requires page with bounding box."""
+ from ocrmypdf.hocrtransform.ocr_element import OcrElement
+
+ page = OcrElement(ocr_class=OcrClass.PAGE)
+
+ with pytest.raises(ValueError, match="Page must have bounding box"):
+ Fpdf2PdfRenderer(
+ page=page,
+ dpi=300,
+ multi_font_manager=multi_font_manager,
+ )
+
+ def test_render_simple_page(self, multi_font_manager, tmp_path):
+ """Test rendering a simple page with one word."""
+ from ocrmypdf.hocrtransform.ocr_element import BoundingBox, OcrElement
+
+ # Create a simple page with one word
+ word = OcrElement(
+ ocr_class=OcrClass.WORD,
+ text="Hello",
+ bbox=BoundingBox(left=100, top=100, right=200, bottom=130),
+ )
+ line = OcrElement(
+ ocr_class=OcrClass.LINE,
+ bbox=BoundingBox(left=100, top=100, right=200, bottom=130),
+ children=[word],
+ )
+ page = OcrElement(
+ ocr_class=OcrClass.PAGE,
+ bbox=BoundingBox(left=0, top=0, right=612, bottom=792),
+ children=[line],
+ )
+
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=72, # 1:1 mapping to PDF points
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+
+ output_path = tmp_path / "test_simple.pdf"
+ renderer.render(output_path)
+
+ assert output_path.exists()
+ assert output_path.stat().st_size > 0
+
+ def test_render_invisible_text(self, multi_font_manager, tmp_path):
+ """Test rendering invisible text (OCR layer)."""
+ from ocrmypdf.hocrtransform.ocr_element import BoundingBox, OcrElement
+
+ word = OcrElement(
+ ocr_class=OcrClass.WORD,
+ text="Invisible",
+ bbox=BoundingBox(left=100, top=100, right=250, bottom=130),
+ )
+ line = OcrElement(
+ ocr_class=OcrClass.LINE,
+ bbox=BoundingBox(left=100, top=100, right=250, bottom=130),
+ children=[word],
+ )
+ page = OcrElement(
+ ocr_class=OcrClass.PAGE,
+ bbox=BoundingBox(left=0, top=0, right=612, bottom=792),
+ children=[line],
+ )
+
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=72,
+ multi_font_manager=multi_font_manager,
+ invisible_text=True, # This is the default
+ )
+
+ output_path = tmp_path / "test_invisible.pdf"
+ renderer.render(output_path)
+
+ assert output_path.exists()
+ assert output_path.stat().st_size > 0
+
+
+class TestFpdf2MultiPageRenderer:
+ """Test Fpdf2MultiPageRenderer."""
+
+ def test_requires_pages(self, multi_font_manager):
+ """Test that renderer requires at least one page."""
+ with pytest.raises(ValueError, match="No pages to render"):
+ renderer = Fpdf2MultiPageRenderer(
+ pages_data=[],
+ multi_font_manager=multi_font_manager,
+ )
+ renderer.render(Path("/tmp/test.pdf"))
+
+ def test_render_multiple_pages(self, multi_font_manager, tmp_path):
+ """Test rendering multiple pages."""
+ from ocrmypdf.hocrtransform.ocr_element import BoundingBox, OcrElement
+
+ pages_data = []
+ for i in range(3):
+ word = OcrElement(
+ ocr_class=OcrClass.WORD,
+ text=f"Page{i+1}",
+ bbox=BoundingBox(left=100, top=100, right=200, bottom=130),
+ )
+ line = OcrElement(
+ ocr_class=OcrClass.LINE,
+ bbox=BoundingBox(left=100, top=100, right=200, bottom=130),
+ children=[word],
+ )
+ page = OcrElement(
+ ocr_class=OcrClass.PAGE,
+ bbox=BoundingBox(left=0, top=0, right=612, bottom=792),
+ children=[line],
+ )
+ pages_data.append((i + 1, page, 72))
+
+ renderer = Fpdf2MultiPageRenderer(
+ pages_data=pages_data,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+
+ output_path = tmp_path / "test_multipage.pdf"
+ renderer.render(output_path)
+
+ assert output_path.exists()
+ assert output_path.stat().st_size > 0
+
+
+class TestFpdf2RendererWithHocr:
+ """Test fpdf2 renderer with actual hOCR files."""
+
+ def test_render_latin_hocr(self, resources, multi_font_manager, tmp_path):
+ """Test rendering Latin text from hOCR."""
+ hocr_path = resources / "latin.hocr"
+ if not hocr_path.exists():
+ pytest.skip("latin.hocr not found")
+
+ parser = HocrParser(hocr_path)
+ page = parser.parse()
+
+ # Ensure we got a page
+ assert page.ocr_class == OcrClass.PAGE
+ assert page.bbox is not None
+
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=300,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+
+ output_path = tmp_path / "latin_fpdf2.pdf"
+ renderer.render(output_path)
+
+ assert output_path.exists()
+ assert output_path.stat().st_size > 0
+
+ def test_render_cjk_hocr(self, resources, multi_font_manager, tmp_path):
+ """Test rendering CJK text from hOCR."""
+ hocr_path = resources / "cjk.hocr"
+ if not hocr_path.exists():
+ pytest.skip("cjk.hocr not found")
+
+ parser = HocrParser(hocr_path)
+ page = parser.parse()
+
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=300,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+
+ output_path = tmp_path / "cjk_fpdf2.pdf"
+ renderer.render(output_path)
+
+ assert output_path.exists()
+ assert output_path.stat().st_size > 0
+
+ def test_render_arabic_hocr(self, resources, multi_font_manager, tmp_path):
+ """Test rendering Arabic text from hOCR."""
+ hocr_path = resources / "arabic.hocr"
+ if not hocr_path.exists():
+ pytest.skip("arabic.hocr not found")
+
+ parser = HocrParser(hocr_path)
+ page = parser.parse()
+
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=300,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+
+ output_path = tmp_path / "arabic_fpdf2.pdf"
+ renderer.render(output_path)
+
+ assert output_path.exists()
+ assert output_path.stat().st_size > 0
+
+ def test_render_hello_world_scripts_hocr(self, resources, multi_font_manager, tmp_path):
+ """Test rendering comprehensive multilingual 'Hello!' hOCR file.
+
+ This tests all major scripts including:
+ - Latin (English, Spanish, French, German, Italian, Polish, Portuguese, Turkish)
+ - Cyrillic (Russian)
+ - Greek
+ - CJK (Chinese Simplified, Chinese Traditional, Japanese, Korean)
+ - Devanagari (Hindi)
+ - Arabic (RTL)
+ - Hebrew (RTL)
+
+ Also includes rotated baselines to exercise skew handling.
+ """
+ hocr_path = resources / "hello_world_scripts.hocr"
+ if not hocr_path.exists():
+ pytest.skip("hello_world_scripts.hocr not found")
+
+ parser = HocrParser(hocr_path)
+ page = parser.parse()
+
+ # Verify we parsed the page correctly
+ assert page.ocr_class == OcrClass.PAGE
+ assert page.bbox is not None
+ # Should have 2550x3300 at 300 DPI
+ assert page.bbox.right == 2550
+ assert page.bbox.bottom == 3300
+
+ # Test with visible text for visual inspection
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=300,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+
+ output_path = tmp_path / "hello_world_scripts_fpdf2.pdf"
+ renderer.render(output_path)
+
+ assert output_path.exists()
+ assert output_path.stat().st_size > 0
+
+ def test_render_hello_world_scripts_multipage(
+ self, resources, multi_font_manager, tmp_path
+ ):
+ """Test rendering hello_world_scripts.hocr using MultiPageRenderer.
+
+ Uses Fpdf2MultiPageRenderer to render the multilingual test file,
+ demonstrating font handling across all major writing systems.
+ """
+ hocr_path = resources / "hello_world_scripts.hocr"
+ if not hocr_path.exists():
+ pytest.skip("hello_world_scripts.hocr not found")
+
+ parser = HocrParser(hocr_path)
+ page = parser.parse()
+
+ # Build pages_data list as expected by MultiPageRenderer
+ pages_data = [(1, page, 300)] # (page_number, page_element, dpi)
+
+ renderer = Fpdf2MultiPageRenderer(
+ pages_data=pages_data,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+
+ output_path = tmp_path / "hello_world_scripts_multipage.pdf"
+ renderer.render(output_path)
+
+ assert output_path.exists()
+ assert output_path.stat().st_size > 0
diff --git a/tests/test_hocrtransform.py b/tests/test_hocrtransform.py
index 0d5d7fe1..1da66543 100644
--- a/tests/test_hocrtransform.py
+++ b/tests/test_hocrtransform.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import re
from io import StringIO
+from pathlib import Path
import pytest
from pdfminer.converter import TextConverter
@@ -15,9 +16,11 @@ from pdfminer.pdfpage import PDFPage
from pdfminer.pdfparser import PDFParser
from PIL import Image
-from ocrmypdf import hocrtransform
from ocrmypdf._exec.tesseract import generate_hocr
+from ocrmypdf.font import MultiFontManager
+from ocrmypdf.fpdf_renderer import Fpdf2PdfRenderer
from ocrmypdf.helpers import check_pdf
+from ocrmypdf.hocrtransform import HocrParser
from .conftest import check_ocrmypdf
@@ -38,6 +41,18 @@ def text_from_pdf(filename):
# pylint: disable=redefined-outer-name
+@pytest.fixture
+def font_dir():
+ """Get the font directory."""
+ return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data"
+
+
+@pytest.fixture
+def multi_font_manager(font_dir):
+ """Create a MultiFontManager for tests."""
+ return MultiFontManager(font_dir)
+
+
@pytest.fixture
def blank_hocr(tmp_path):
im = Image.new('1', (8, 8), 0)
@@ -58,23 +73,38 @@ def blank_hocr(tmp_path):
return tmp_path / 'blank.hocr'
-def test_mono_image(blank_hocr, outdir):
+def test_mono_image(blank_hocr, outdir, multi_font_manager):
im = Image.new('1', (8, 8), 0)
for n in range(8):
im.putpixel((n, n), 1)
im.save(outdir / 'mono.tif', format='TIFF')
- hocr = hocrtransform.HocrTransform(hocr_filename=str(blank_hocr), dpi=8)
- hocr.to_pdf(
- out_filename=str(outdir / 'mono.pdf'), image_filename=str(outdir / 'mono.tif')
+ # Parse hOCR file
+ parser = HocrParser(str(blank_hocr))
+ ocr_page = parser.parse()
+
+ # Use DPI from hOCR or default
+ dpi = ocr_page.dpi or 8
+
+ # Render to PDF using fpdf2
+ renderer = Fpdf2PdfRenderer(
+ page=ocr_page,
+ dpi=dpi,
+ multi_font_manager=multi_font_manager,
+ invisible_text=True,
)
- # shutil.copy(outdir / 'mono.pdf', 'mono.pdf')
- check_pdf(str(outdir / 'mono.pdf'))
+ renderer.render(outdir / 'mono.pdf')
+
+ check_pdf(outdir / 'mono.pdf')
@pytest.mark.slow
-def test_hocrtransform_matches_sandwich(resources, outdir):
- check_ocrmypdf(resources / 'ccitt.pdf', outdir / 'hocr.pdf', '--pdf-renderer=hocr')
+def test_fpdf2_matches_sandwich(resources, outdir):
+ """Test that fpdf2 renderer produces similar output to sandwich renderer."""
+ # Note: hocr renderer now redirects to fpdf2
+ check_ocrmypdf(
+ resources / 'ccitt.pdf', outdir / 'fpdf2.pdf', '--pdf-renderer=fpdf2'
+ )
check_ocrmypdf(
resources / 'ccitt.pdf', outdir / 'tess.pdf', '--pdf-renderer=sandwich'
)
@@ -86,17 +116,9 @@ def test_hocrtransform_matches_sandwich(resources, outdir):
words = s.split(' ')
return set(words)
- hocr_words = clean(text_from_pdf(outdir / 'hocr.pdf'))
+ fpdf2_words = clean(text_from_pdf(outdir / 'fpdf2.pdf'))
tess_words = clean(text_from_pdf(outdir / 'tess.pdf'))
- similarity = len(hocr_words & tess_words) / len(hocr_words | tess_words)
-
- # from pathlib import Path
-
- # Path('hocr.txt').write_text(sorted('\n'.join(hocr_words)))
- # Path('tess.txt').write_text(sorted('\n'.join(tess_words)))
- # Path('mismatch.txt').write_text(
- # '\n'.join(sorted(hocr_words ^ tess_words)), encoding='utf8'
- # )
+ similarity = len(fpdf2_words & tess_words) / len(fpdf2_words | tess_words)
assert similarity > 0.99
diff --git a/tests/test_main.py b/tests/test_main.py
index 9ad5f8ff..8428d801 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -35,7 +35,7 @@ from .conftest import (
# pylint: disable=redefined-outer-name
-RENDERERS = ['hocr', 'sandwich']
+RENDERERS = ['fpdf2', 'sandwich']
def test_quick(resources, outpdf):
@@ -435,7 +435,7 @@ def test_jbig2_passthrough(resources, outpdf):
'--output-type',
'pdf',
'--pdf-renderer',
- 'hocr',
+ 'fpdf2',
'--plugin',
'tests/plugins/tesseract_cache.py',
)
diff --git a/tests/test_multi_font_manager.py b/tests/test_multi_font_manager.py
new file mode 100644
index 00000000..0a69c0e9
--- /dev/null
+++ b/tests/test_multi_font_manager.py
@@ -0,0 +1,446 @@
+# SPDX-FileCopyrightText: 2025 James R. Barlow
+# SPDX-License-Identifier: MPL-2.0
+
+"""Unit tests for MultiFontManager and FontProvider."""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+import pytest
+
+from ocrmypdf.font import BuiltinFontProvider, FontManager, MultiFontManager
+
+
+@pytest.fixture
+def font_dir():
+ """Return path to font directory."""
+ return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data"
+
+
+@pytest.fixture
+def multi_font_manager(font_dir):
+ """Create MultiFontManager instance for testing."""
+ return MultiFontManager(font_dir)
+
+
+def has_cjk_font(manager: MultiFontManager) -> bool:
+ """Check if CJK font is available (from system)."""
+ return 'NotoSansCJK-Regular' in manager.fonts
+
+
+def has_arabic_font(manager: MultiFontManager) -> bool:
+ """Check if Arabic font is available (from system)."""
+ return 'NotoSansArabic-Regular' in manager.fonts
+
+
+def has_devanagari_font(manager: MultiFontManager) -> bool:
+ """Check if Devanagari font is available (from system)."""
+ return 'NotoSansDevanagari-Regular' in manager.fonts
+
+
+# Marker for tests that require CJK fonts
+requires_cjk = pytest.mark.skipif(
+ "not has_cjk_font(MultiFontManager())",
+ reason="CJK font not available (not installed on system)"
+)
+
+
+# --- MultiFontManager Initialization Tests ---
+
+
+def test_init_loads_builtin_fonts(multi_font_manager):
+ """Test that initialization loads all expected builtin fonts."""
+ # Only NotoSans-Regular and Occulta are bundled
+ assert 'NotoSans-Regular' in multi_font_manager.fonts
+ assert 'Occulta' in multi_font_manager.fonts
+
+ # At least 2 builtin fonts should be loaded
+ assert len(multi_font_manager.fonts) >= 2
+
+ # Arabic, Devanagari, CJK are optional (system fonts)
+
+
+def test_missing_font_directory():
+ """Test that missing font directory raises error for fallback font."""
+ with pytest.raises(FileNotFoundError):
+ MultiFontManager(Path("/nonexistent/path"))
+
+
+# --- Arabic Script Language Tests ---
+# These tests require Arabic fonts to be installed on the system
+
+
+def test_select_font_for_arabic_language(multi_font_manager):
+ """Test font selection with Arabic language hint."""
+ if not has_arabic_font(multi_font_manager):
+ pytest.skip("Arabic font not available")
+ font_manager = multi_font_manager.select_font_for_word("مرحبا", "ara")
+ assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular']
+
+
+def test_select_font_for_persian_language(multi_font_manager):
+ """Test font selection with Persian language hint."""
+ if not has_arabic_font(multi_font_manager):
+ pytest.skip("Arabic font not available")
+ font_manager = multi_font_manager.select_font_for_word("سلام", "per")
+ assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular']
+
+
+def test_select_font_for_urdu_language(multi_font_manager):
+ """Test font selection with Urdu language hint."""
+ if not has_arabic_font(multi_font_manager):
+ pytest.skip("Arabic font not available")
+ font_manager = multi_font_manager.select_font_for_word("ہیلو", "urd")
+ assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular']
+
+
+def test_farsi_language_code(multi_font_manager):
+ """Test that 'fas' (Farsi alternative code) maps to Arabic font."""
+ if not has_arabic_font(multi_font_manager):
+ pytest.skip("Arabic font not available")
+ font_manager = multi_font_manager.select_font_for_word("سلام", "fas")
+ assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular']
+
+
+# --- Devanagari Script Language Tests ---
+# These tests require Devanagari fonts to be installed on the system
+
+
+def test_select_font_for_hindi_language(multi_font_manager):
+ """Test font selection with Hindi language hint."""
+ if not has_devanagari_font(multi_font_manager):
+ pytest.skip("Devanagari font not available")
+ font_manager = multi_font_manager.select_font_for_word("नमस्ते", "hin")
+ assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular']
+
+
+def test_select_font_for_sanskrit_language(multi_font_manager):
+ """Test font selection with Sanskrit language hint."""
+ if not has_devanagari_font(multi_font_manager):
+ pytest.skip("Devanagari font not available")
+ font_manager = multi_font_manager.select_font_for_word("संस्कृतम्", "san")
+ assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular']
+
+
+def test_select_font_for_marathi_language(multi_font_manager):
+ """Test font selection with Marathi language hint."""
+ if not has_devanagari_font(multi_font_manager):
+ pytest.skip("Devanagari font not available")
+ font_manager = multi_font_manager.select_font_for_word("मराठी", "mar")
+ assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular']
+
+
+def test_select_font_for_nepali_language(multi_font_manager):
+ """Test font selection with Nepali language hint."""
+ if not has_devanagari_font(multi_font_manager):
+ pytest.skip("Devanagari font not available")
+ font_manager = multi_font_manager.select_font_for_word("नेपाली", "nep")
+ assert font_manager == multi_font_manager.fonts['NotoSansDevanagari-Regular']
+
+
+# --- CJK Language Tests ---
+# These tests require CJK fonts to be installed on the system
+
+
+def test_select_font_for_chinese_language(multi_font_manager):
+ """Test font selection with Chinese language hint (ISO 639-3)."""
+ if not has_cjk_font(multi_font_manager):
+ pytest.skip("CJK font not available")
+ font_manager = multi_font_manager.select_font_for_word("你好", "zho")
+ assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
+
+
+def test_select_font_for_chinese_generic(multi_font_manager):
+ """Test font selection with generic Chinese language code."""
+ if not has_cjk_font(multi_font_manager):
+ pytest.skip("CJK font not available")
+ font_manager = multi_font_manager.select_font_for_word("中文", "chi")
+ assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
+
+
+def test_select_font_for_chinese_simplified(multi_font_manager):
+ """Test font selection with Tesseract's chi_sim language code."""
+ if not has_cjk_font(multi_font_manager):
+ pytest.skip("CJK font not available")
+ font_manager = multi_font_manager.select_font_for_word("简体字", "chi_sim")
+ assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
+
+
+def test_select_font_for_chinese_traditional(multi_font_manager):
+ """Test font selection with Tesseract's chi_tra language code."""
+ if not has_cjk_font(multi_font_manager):
+ pytest.skip("CJK font not available")
+ font_manager = multi_font_manager.select_font_for_word("漢字", "chi_tra")
+ assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
+
+
+def test_select_font_for_japanese_language(multi_font_manager):
+ """Test font selection with Japanese language hint."""
+ if not has_cjk_font(multi_font_manager):
+ pytest.skip("CJK font not available")
+ font_manager = multi_font_manager.select_font_for_word("こんにちは", "jpn")
+ assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
+
+
+def test_select_font_for_korean_language(multi_font_manager):
+ """Test font selection with Korean language hint."""
+ if not has_cjk_font(multi_font_manager):
+ pytest.skip("CJK font not available")
+ font_manager = multi_font_manager.select_font_for_word("안녕하세요", "kor")
+ assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
+
+
+# --- Latin/English Tests ---
+
+
+def test_select_font_for_english_text(multi_font_manager):
+ """Test font selection for English text."""
+ font_manager = multi_font_manager.select_font_for_word("Hello World", "eng")
+ assert font_manager == multi_font_manager.fonts['NotoSans-Regular']
+
+
+def test_select_font_without_language_hint(multi_font_manager):
+ """Test font selection without language hint falls back to glyph checking."""
+ font_manager = multi_font_manager.select_font_for_word("Hello", None)
+ assert font_manager == multi_font_manager.fonts['NotoSans-Regular']
+
+
+# --- Fallback Behavior Tests ---
+
+
+def test_select_font_arabic_text_without_language_hint(multi_font_manager):
+ """Test that Arabic text is handled via fallback without language hint."""
+ if not has_arabic_font(multi_font_manager):
+ pytest.skip("Arabic font not available")
+ font_manager = multi_font_manager.select_font_for_word("مرحبا", None)
+ # Should get NotoSansArabic-Regular via fallback chain glyph checking
+ assert font_manager == multi_font_manager.fonts['NotoSansArabic-Regular']
+
+
+def test_devanagari_text_without_language_hint(multi_font_manager):
+ """Test that Devanagari text is handled via fallback without language hint."""
+ # NotoSans-Regular includes Devanagari glyphs, so it's selected first in fallback
+ font_manager = multi_font_manager.select_font_for_word("नमस्ते", None)
+ # Could be NotoSans-Regular or NotoSansDevanagari-Regular depending on availability
+ assert font_manager is not None
+
+
+def test_cjk_text_without_language_hint(multi_font_manager):
+ """Test that CJK text is handled via fallback without language hint."""
+ if not has_cjk_font(multi_font_manager):
+ pytest.skip("CJK font not available")
+ font_manager = multi_font_manager.select_font_for_word("你好", None)
+ assert font_manager == multi_font_manager.fonts['NotoSansCJK-Regular']
+
+
+def test_fallback_to_occulta_font(multi_font_manager):
+ """Test that unsupported characters fall back to Occulta.ttf."""
+ # Use a character unlikely to be in any standard font
+ font_manager = multi_font_manager.select_font_for_word("test", "xyz")
+ # Should return some valid font
+ assert font_manager in multi_font_manager.fonts.values()
+
+
+def test_fallback_fonts_constant(multi_font_manager):
+ """Test that FALLBACK_FONTS contains expected fonts."""
+ # Check that core fonts are in fallback list
+ assert 'NotoSans-Regular' in MultiFontManager.FALLBACK_FONTS
+ assert 'NotoSansArabic-Regular' in MultiFontManager.FALLBACK_FONTS
+ assert 'NotoSansDevanagari-Regular' in MultiFontManager.FALLBACK_FONTS
+ assert 'NotoSansCJK-Regular' in MultiFontManager.FALLBACK_FONTS
+
+ # Only NotoSans-Regular is bundled; other scripts are system fonts
+ assert 'NotoSans-Regular' in multi_font_manager.fonts
+
+
+# --- Glyph Coverage Tests ---
+
+
+def test_has_all_glyphs_for_english(multi_font_manager):
+ """Test glyph coverage checking for English text."""
+ assert multi_font_manager.has_all_glyphs('NotoSans-Regular', "Hello World")
+ assert multi_font_manager.has_all_glyphs('NotoSans-Regular', "café")
+
+
+def test_has_all_glyphs_for_arabic(multi_font_manager):
+ """Test glyph coverage checking for Arabic text."""
+ if not has_arabic_font(multi_font_manager):
+ pytest.skip("Arabic font not available")
+ assert multi_font_manager.has_all_glyphs('NotoSansArabic-Regular', "مرحبا")
+
+
+def test_has_all_glyphs_for_devanagari(multi_font_manager):
+ """Test glyph coverage checking for Devanagari text."""
+ if not has_devanagari_font(multi_font_manager):
+ pytest.skip("Devanagari font not available")
+ assert multi_font_manager.has_all_glyphs('NotoSansDevanagari-Regular', "नमस्ते")
+
+
+def test_has_all_glyphs_for_cjk(multi_font_manager):
+ """Test glyph coverage checking for CJK text."""
+ if not has_cjk_font(multi_font_manager):
+ pytest.skip("CJK font not available")
+ assert multi_font_manager.has_all_glyphs('NotoSansCJK-Regular', "你好")
+
+
+def test_empty_text_has_all_glyphs(multi_font_manager):
+ """Test that empty text returns True for glyph coverage."""
+ assert multi_font_manager.has_all_glyphs('NotoSans-Regular', "")
+
+
+def test_has_all_glyphs_missing_font(multi_font_manager):
+ """Test that has_all_glyphs returns False for non-existent font."""
+ assert not multi_font_manager.has_all_glyphs('NonExistentFont', "test")
+
+
+# --- Caching Tests ---
+
+
+def test_font_selection_caching(multi_font_manager):
+ """Test that font selection results are cached."""
+ font1 = multi_font_manager.select_font_for_word("Hello", "eng")
+
+ cache_key = ("Hello", "eng")
+ assert cache_key in multi_font_manager._selection_cache
+
+ font2 = multi_font_manager.select_font_for_word("Hello", "eng")
+ assert font1 == font2
+
+
+# --- Language Font Map Tests ---
+
+
+def test_language_font_map_coverage():
+ """Test that LANGUAGE_FONT_MAP has valid structure."""
+ # Only NotoSans-Regular is bundled now
+ # This test just verifies the structure is valid
+ for font_name in MultiFontManager.LANGUAGE_FONT_MAP.values():
+ # All font names should be valid strings
+ assert isinstance(font_name, str)
+ assert font_name.startswith('NotoSans')
+
+
+# --- get_all_fonts Tests ---
+
+
+def test_get_all_fonts(multi_font_manager):
+ """Test get_all_fonts returns all loaded fonts."""
+ all_fonts = multi_font_manager.get_all_fonts()
+ assert isinstance(all_fonts, dict)
+ # At least 2 builtin fonts should be loaded (NotoSans-Regular and Occulta)
+ assert len(all_fonts) >= 2
+ assert 'NotoSans-Regular' in all_fonts
+ assert 'Occulta' in all_fonts
+ # Arabic, Devanagari, CJK are optional (system fonts)
+
+
+# --- FontProvider Tests ---
+
+
+class MockFontProvider:
+ """Mock FontProvider for testing missing fonts."""
+
+ def __init__(
+ self, available_fonts: dict[str, FontManager], fallback: FontManager
+ ):
+ """Initialize mock font provider with given fonts."""
+ self._fonts = available_fonts
+ self._fallback = fallback
+
+ def get_font(self, font_name: str) -> FontManager | None:
+ return self._fonts.get(font_name)
+
+ def get_available_fonts(self) -> list[str]:
+ return list(self._fonts.keys())
+
+ def get_fallback_font(self) -> FontManager:
+ return self._fallback
+
+
+def test_custom_font_provider(font_dir):
+ """Test that custom FontProvider can be injected."""
+ fonts = {
+ 'NotoSans-Regular': FontManager(font_dir / 'NotoSans-Regular.ttf'),
+ 'Occulta': FontManager(font_dir / 'Occulta.ttf'),
+ }
+ provider = MockFontProvider(fonts, fonts['Occulta'])
+
+ manager = MultiFontManager(font_provider=provider)
+
+ # Should only have the fonts we provided
+ assert len(manager.fonts) == 2
+ assert 'NotoSans-Regular' in manager.fonts
+ assert 'Occulta' in manager.fonts
+
+
+def test_missing_font_uses_fallback(font_dir):
+ """Test that missing fonts gracefully fall back."""
+ fonts = {
+ 'NotoSans-Regular': FontManager(font_dir / 'NotoSans-Regular.ttf'),
+ 'Occulta': FontManager(font_dir / 'Occulta.ttf'),
+ }
+ provider = MockFontProvider(fonts, fonts['Occulta'])
+
+ manager = MultiFontManager(font_provider=provider)
+
+ # Arabic text should fall back to Occulta since NotoSansArabic is missing
+ font = manager.select_font_for_word("مرحبا", "ara")
+ assert font == fonts['Occulta']
+
+
+def test_builtin_font_provider_loads_expected_fonts(font_dir):
+ """Test BuiltinFontProvider loads all expected builtin fonts."""
+ provider = BuiltinFontProvider(font_dir)
+
+ available = provider.get_available_fonts()
+ assert 'NotoSans-Regular' in available
+ assert 'Occulta' in available
+ # Only Latin (NotoSans) and glyphless fallback (Occulta) are bundled.
+ # All other scripts (Arabic, Devanagari, CJK, etc.) are discovered
+ # from system fonts by SystemFontProvider to reduce package size.
+ assert len(available) == 2
+
+
+def test_builtin_font_provider_get_font(font_dir):
+ """Test BuiltinFontProvider.get_font returns correct fonts."""
+ provider = BuiltinFontProvider(font_dir)
+
+ font = provider.get_font('NotoSans-Regular')
+ assert font is not None
+ assert isinstance(font, FontManager)
+
+ missing = provider.get_font('NonExistent')
+ assert missing is None
+
+
+def test_builtin_font_provider_get_fallback(font_dir):
+ """Test BuiltinFontProvider.get_fallback_font returns Occulta font."""
+ provider = BuiltinFontProvider(font_dir)
+
+ fallback = provider.get_fallback_font()
+ assert fallback is not None
+ assert fallback == provider.get_font('Occulta')
+
+
+def test_builtin_font_provider_missing_font_logs_warning(tmp_path, font_dir, caplog):
+ """Test that missing expected fonts log a warning."""
+ # Create minimal font directory with only Occulta.ttf
+ (tmp_path / 'Occulta.ttf').write_bytes((font_dir / 'Occulta.ttf').read_bytes())
+
+ with caplog.at_level(logging.WARNING):
+ provider = BuiltinFontProvider(tmp_path)
+
+ # Should have logged warnings for missing fonts
+ assert 'NotoSans-Regular' in caplog.text
+ assert 'not found' in caplog.text
+
+ # But Occulta should be loaded
+ assert provider.get_fallback_font() is not None
+
+
+def test_builtin_font_provider_missing_occulta_raises(tmp_path):
+ """Test that missing Occulta.ttf raises FileNotFoundError."""
+ with pytest.raises(FileNotFoundError, match="Required fallback font"):
+ BuiltinFontProvider(tmp_path)
diff --git a/tests/test_multilingual_direct.py b/tests/test_multilingual_direct.py
new file mode 100644
index 00000000..382922d6
--- /dev/null
+++ b/tests/test_multilingual_direct.py
@@ -0,0 +1,550 @@
+#!/usr/bin/env python3
+# SPDX-FileCopyrightText: 2025 James R. Barlow
+# SPDX-License-Identifier: MPL-2.0
+
+"""Direct tests for multilingual text rendering with fpdf2 renderer.
+
+This tests the fpdf2 renderer with various language groups:
+- Latin (English, French, German with diacritics)
+- Arabic (Arabic, Persian - RTL scripts)
+- CJK (Chinese Simplified/Traditional, Japanese, Korean)
+- Devanagari (Hindi, Sanskrit)
+"""
+
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from ocrmypdf.font import MultiFontManager
+from ocrmypdf.fpdf_renderer import DebugRenderOptions, Fpdf2PdfRenderer
+from ocrmypdf.hocrtransform.hocr_parser import HocrParser
+
+RESOURCES = Path(__file__).parent / "resources"
+
+
+@pytest.fixture
+def font_dir():
+ """Return path to font directory."""
+ return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data"
+
+
+@pytest.fixture
+def multi_font_manager(font_dir):
+ """Create MultiFontManager instance for testing."""
+ return MultiFontManager(font_dir)
+
+
+# =============================================================================
+# Latin Script Tests
+# =============================================================================
+
+
+class TestLatinScript:
+ """Tests for Latin script (English, French, German, etc.)."""
+
+ @pytest.fixture
+ def latin_hocr(self):
+ """Return path to Latin HOCR test file."""
+ return RESOURCES / "latin.hocr"
+
+ def test_render_latin_basic(self, latin_hocr, multi_font_manager, tmp_path):
+ """Test rendering Latin script with various diacritics."""
+ parser = HocrParser(latin_hocr)
+ page = parser.parse()
+
+ assert page is not None
+ paras = list(page.paragraphs)
+ assert len(paras) == 3 # English, French, German
+
+ # Check languages
+ assert paras[0].language == 'eng'
+ assert paras[1].language == 'fra'
+ assert paras[2].language == 'deu'
+
+ # Render to PDF
+ output_pdf = tmp_path / "latin_output.pdf"
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=300.0,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+ renderer.render(output_pdf)
+
+ assert output_pdf.exists()
+ assert output_pdf.stat().st_size > 0
+
+ # Extract text and verify
+ text = subprocess.check_output(['pdftotext', str(output_pdf), '-'], text=True)
+
+ # English words
+ assert 'quick' in text or 'brown' in text or 'fox' in text
+
+ # French with diacritics
+ assert 'Café' in text or 'résumé' in text or 'naïve' in text
+
+ # German with umlauts and eszett
+ assert 'Größe' in text or 'Zürich' in text or 'Ärger' in text
+
+ def test_latin_font_selection(self, latin_hocr, multi_font_manager):
+ """Test that NotoSans is selected for Latin text."""
+ parser = HocrParser(latin_hocr)
+ page = parser.parse()
+
+ for line in page.lines:
+ for word in line.children:
+ if word.text:
+ font = multi_font_manager.select_font_for_word(
+ word.text, line.language
+ )
+ assert font is not None
+ # Latin text should use NotoSans-Regular
+ assert multi_font_manager.has_all_glyphs(
+ 'NotoSans-Regular', word.text
+ )
+
+
+# =============================================================================
+# Arabic Script Tests
+# =============================================================================
+
+
+class TestArabicScript:
+ """Tests for Arabic script (Arabic, Persian, etc.)."""
+
+ @pytest.fixture
+ def arabic_hocr(self):
+ """Return path to Arabic HOCR test file."""
+ return RESOURCES / "arabic.hocr"
+
+ def test_render_arabic_basic(self, arabic_hocr, multi_font_manager, tmp_path):
+ """Test rendering Arabic script text."""
+ parser = HocrParser(arabic_hocr)
+ page = parser.parse()
+
+ assert page is not None
+ paras = list(page.paragraphs)
+ assert len(paras) == 3 # Arabic paragraphs and Persian
+
+ # Render to PDF
+ output_pdf = tmp_path / "arabic_output.pdf"
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=300.0,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+ renderer.render(output_pdf)
+
+ assert output_pdf.exists()
+ assert output_pdf.stat().st_size > 0
+
+ # Extract text and verify Arabic content
+ text = subprocess.check_output(['pdftotext', str(output_pdf), '-'], text=True)
+
+ # Arabic words: مرحبا بالعالم (Hello world)
+ assert 'مرحبا' in text or 'بالعالم' in text
+ # هذا نص عربي (This is Arabic text)
+ assert 'عربي' in text or 'نص' in text
+
+ def test_arabic_font_selection(self, arabic_hocr, multi_font_manager):
+ """Test that NotoSansArabic is selected for Arabic text."""
+ parser = HocrParser(arabic_hocr)
+ page = parser.parse()
+
+ for line in page.lines:
+ for word in line.children:
+ if word.text and line.language in ('ara', 'per'):
+ font = multi_font_manager.select_font_for_word(
+ word.text, line.language
+ )
+ assert font is not None
+ # Arabic text should use NotoSansArabic
+ assert multi_font_manager.has_all_glyphs(
+ 'NotoSansArabic-Regular', word.text
+ ), f"NotoSansArabic cannot render '{word.text}'"
+
+ def test_arabic_rtl_handling(self, arabic_hocr):
+ """Test that RTL direction is correctly parsed from hOCR."""
+ parser = HocrParser(arabic_hocr)
+ page = parser.parse()
+
+ for para in page.paragraphs:
+ if para.language in ('ara', 'per'):
+ # Arabic paragraphs should have RTL direction
+ assert para.direction == 'rtl', \
+ "Arabic paragraph should have RTL direction"
+
+
+# =============================================================================
+# CJK Script Tests
+# =============================================================================
+
+
+def _cjk_font_works(multi_font_manager) -> bool:
+ """Check if CJK font is working (not corrupted)."""
+ return multi_font_manager.has_all_glyphs('NotoSansCJK-Regular', '你')
+
+
+class TestCJKScript:
+ """Tests for CJK scripts (Chinese, Japanese, Korean)."""
+
+ @pytest.fixture
+ def cjk_hocr(self):
+ """Return path to CJK HOCR test file."""
+ return RESOURCES / "cjk.hocr"
+
+ def test_render_cjk_basic(self, cjk_hocr, multi_font_manager, tmp_path):
+ """Test rendering CJK script text."""
+ if not _cjk_font_works(multi_font_manager):
+ pytest.skip("CJK font not available or corrupted")
+
+ parser = HocrParser(cjk_hocr)
+ page = parser.parse()
+
+ assert page is not None
+ paras = list(page.paragraphs)
+ assert len(paras) == 4 # Chinese Simplified, Traditional, Japanese, Korean
+
+ # Check languages
+ languages = [p.language for p in paras]
+ assert 'chi_sim' in languages
+ assert 'chi_tra' in languages
+ assert 'jpn' in languages
+ assert 'kor' in languages
+
+ # Render to PDF
+ output_pdf = tmp_path / "cjk_output.pdf"
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=300.0,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+ renderer.render(output_pdf)
+
+ assert output_pdf.exists()
+ assert output_pdf.stat().st_size > 0
+
+ # Extract text and verify CJK content
+ text = subprocess.check_output(['pdftotext', str(output_pdf), '-'], text=True)
+
+ # Chinese: 你好 世界 (Hello world)
+ assert '你好' in text or '世界' in text
+ # Japanese: こんにちは (Hello)
+ assert 'こんにちは' in text or '世界' in text
+ # Korean: 안녕하세요 (Hello)
+ assert '안녕하세요' in text or '세계' in text
+
+ def test_cjk_font_selection(self, cjk_hocr, multi_font_manager):
+ """Test that NotoSansCJK is selected for CJK text."""
+ if not _cjk_font_works(multi_font_manager):
+ pytest.skip("CJK font not available or corrupted")
+
+ parser = HocrParser(cjk_hocr)
+ page = parser.parse()
+
+ cjk_languages = {'chi_sim', 'chi_tra', 'jpn', 'kor', 'zho', 'chi'}
+
+ for line in page.lines:
+ for word in line.children:
+ if word.text and line.language in cjk_languages:
+ font = multi_font_manager.select_font_for_word(
+ word.text, line.language
+ )
+ assert font is not None
+ # CJK text should use NotoSansCJK
+ assert multi_font_manager.has_all_glyphs(
+ 'NotoSansCJK-Regular', word.text
+ ), f"NotoSansCJK cannot render '{word.text}'"
+
+
+# =============================================================================
+# Devanagari Script Tests
+# =============================================================================
+
+
+class TestDevanagariScript:
+ """Tests for Devanagari script (Hindi, Sanskrit, etc.)."""
+
+ @pytest.fixture
+ def devanagari_hocr(self):
+ """Return path to Devanagari HOCR test file."""
+ return RESOURCES / "devanagari.hocr"
+
+ def test_render_devanagari_basic(
+ self, devanagari_hocr, multi_font_manager, tmp_path
+ ):
+ """Test rendering Devanagari script text."""
+ parser = HocrParser(devanagari_hocr)
+ page = parser.parse()
+
+ assert page is not None
+ paras = list(page.paragraphs)
+ assert len(paras) == 3 # Hindi paragraphs and Sanskrit
+
+ # Render to PDF
+ output_pdf = tmp_path / "devanagari_output.pdf"
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=300.0,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+ renderer.render(output_pdf)
+
+ assert output_pdf.exists()
+ assert output_pdf.stat().st_size > 0
+
+ # Extract text and verify Devanagari content
+ text = subprocess.check_output(['pdftotext', str(output_pdf), '-'], text=True)
+
+ # Hindi: नमस्ते दुनिया (Hello world)
+ assert 'नमस्ते' in text or 'दुनिया' in text
+ # यह हिंदी पाठ है (This is Hindi text)
+ assert 'हिंदी' in text or 'पाठ' in text
+
+ def test_devanagari_font_selection(self, devanagari_hocr, multi_font_manager):
+ """Test that NotoSansDevanagari is selected for Devanagari text."""
+ parser = HocrParser(devanagari_hocr)
+ page = parser.parse()
+
+ devanagari_languages = {'hin', 'san', 'mar', 'nep'}
+
+ for line in page.lines:
+ for word in line.children:
+ if word.text and line.language in devanagari_languages:
+ font = multi_font_manager.select_font_for_word(
+ word.text, line.language
+ )
+ assert font is not None
+ # Devanagari text should use NotoSansDevanagari
+ assert multi_font_manager.has_all_glyphs(
+ 'NotoSansDevanagari-Regular', word.text
+ ), f"NotoSansDevanagari cannot render '{word.text}'"
+
+
+# =============================================================================
+# Mixed Language / Multilingual Tests
+# =============================================================================
+
+
+class TestMultilingual:
+ """Tests for mixed-language documents."""
+
+ @pytest.fixture
+ def multilingual_hocr(self):
+ """Return path to multilingual HOCR test file."""
+ return RESOURCES / "multilingual.hocr"
+
+ def test_render_multilingual_hocr_basic(
+ self, multilingual_hocr, multi_font_manager, tmp_path
+ ):
+ """Test rendering multilingual HOCR file with English and Arabic text."""
+ parser = HocrParser(multilingual_hocr)
+ page = parser.parse()
+
+ assert page is not None
+ assert len(list(page.paragraphs)) == 2 # English and Arabic paragraphs
+
+ # Check languages
+ paras = list(page.paragraphs)
+ assert paras[0].language == 'eng'
+ assert paras[1].language == 'ara'
+
+ # Render to PDF
+ output_pdf = tmp_path / "multilingual_output.pdf"
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=300.0,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+ renderer.render(output_pdf)
+
+ assert output_pdf.exists()
+ assert output_pdf.stat().st_size > 0
+
+ # Extract text from PDF
+ text = subprocess.check_output(['pdftotext', str(output_pdf), '-'], text=True)
+
+ # Verify both English and Arabic text are present
+ assert 'English' in text or 'Text' in text or 'Here' in text
+ # Arabic text: مرحبا بك
+ assert 'مرحبا' in text or 'بك' in text
+
+ def test_render_multilingual_with_debug_options(
+ self, multilingual_hocr, multi_font_manager, tmp_path
+ ):
+ """Test rendering with debug visualization enabled."""
+ parser = HocrParser(multilingual_hocr)
+ page = parser.parse()
+
+ # Render with debug options
+ output_pdf = tmp_path / "multilingual_debug.pdf"
+ debug_options = DebugRenderOptions(
+ render_baseline=True,
+ render_line_bbox=True,
+ render_word_bbox=True,
+ )
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=300.0,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ debug_render_options=debug_options,
+ )
+ renderer.render(output_pdf)
+
+ assert output_pdf.exists()
+ assert output_pdf.stat().st_size > 0
+
+ def test_multilingual_invisible_text(
+ self, multilingual_hocr, multi_font_manager, tmp_path
+ ):
+ """Test rendering with invisible text (default OCR mode)."""
+ parser = HocrParser(multilingual_hocr)
+ page = parser.parse()
+
+ # Render with invisible text (standard for OCR layer)
+ output_pdf = tmp_path / "multilingual_invisible.pdf"
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=300.0,
+ multi_font_manager=multi_font_manager,
+ invisible_text=True,
+ )
+ renderer.render(output_pdf)
+
+ assert output_pdf.exists()
+
+ # Text should still be extractable even though invisible
+ text = subprocess.check_output(['pdftotext', str(output_pdf), '-'], text=True)
+ assert len(text.strip()) > 0
+
+ def test_multilingual_font_selection(self, multilingual_hocr, multi_font_manager):
+ """Test that correct fonts are selected for each language."""
+ parser = HocrParser(multilingual_hocr)
+ page = parser.parse()
+
+ # Get all words
+ words = []
+ for line in page.lines:
+ for word in line.children:
+ if word.text:
+ words.append((word.text, line.language))
+
+ # Verify we have both English and Arabic words
+ eng_words = [w for w, lang in words if lang == 'eng']
+ ara_words = [w for w, lang in words if lang == 'ara']
+
+ assert len(eng_words) > 0, "Should have English words"
+ assert len(ara_words) > 0, "Should have Arabic words"
+
+ # Test font selection
+ for text, lang in words:
+ font_mgr = multi_font_manager.select_font_for_word(text, lang)
+ assert font_mgr is not None, f"No font selected for '{text}' ({lang})"
+
+ if lang == 'ara':
+ assert multi_font_manager.has_all_glyphs(
+ 'NotoSansArabic-Regular', text
+ ), f"NotoSansArabic cannot render '{text}'"
+
+
+# =============================================================================
+# Baseline and Structure Tests
+# =============================================================================
+
+
+class TestBaselineHandling:
+ """Tests for baseline and hOCR structure handling."""
+
+ @pytest.fixture
+ def multilingual_hocr(self):
+ """Return path to multilingual HOCR test file."""
+ return RESOURCES / "multilingual.hocr"
+
+ def test_multilingual_baseline_handling(self, multilingual_hocr):
+ """Test that baseline information is correctly parsed from hOCR."""
+ parser = HocrParser(multilingual_hocr)
+ page = parser.parse()
+
+ for line in page.lines:
+ if line.baseline:
+ # Baseline should be reasonable
+ assert -1.0 <= line.baseline.slope <= 1.0, \
+ "Baseline slope should be reasonable"
+
+
+# =============================================================================
+# Font Coverage Tests
+# =============================================================================
+
+
+class TestFontCoverage:
+ """Tests verifying font coverage for various scripts."""
+
+ def test_noto_sans_latin_coverage(self, multi_font_manager):
+ """Test NotoSans covers common Latin characters and diacritics."""
+ latin_samples = [
+ "Hello World",
+ "Café résumé naïve",
+ "Größe Zürich Ärger",
+ "ÀÁÂÃÄÅÆÇÈÉÊË",
+ "àáâãäåæçèéêë",
+ ]
+
+ for sample in latin_samples:
+ assert multi_font_manager.has_all_glyphs('NotoSans-Regular', sample), \
+ f"NotoSans should cover: {sample}"
+
+ def test_noto_sans_arabic_coverage(self, multi_font_manager):
+ """Test NotoSansArabic covers Arabic characters."""
+ arabic_samples = [
+ "مرحبا", # Hello
+ "بالعالم", # World
+ "العربية", # Arabic
+ ]
+
+ for sample in arabic_samples:
+ assert multi_font_manager.has_all_glyphs(
+ 'NotoSansArabic-Regular', sample
+ ), f"NotoSansArabic should cover: {sample}"
+
+ def test_noto_sans_devanagari_coverage(self, multi_font_manager):
+ """Test NotoSansDevanagari covers Devanagari characters."""
+ devanagari_samples = [
+ "नमस्ते", # Hello
+ "हिंदी", # Hindi
+ "संस्कृत", # Sanskrit
+ ]
+
+ for sample in devanagari_samples:
+ assert multi_font_manager.has_all_glyphs(
+ 'NotoSansDevanagari-Regular', sample
+ ), f"NotoSansDevanagari should cover: {sample}"
+
+ def test_noto_sans_cjk_coverage(self, multi_font_manager):
+ """Test NotoSansCJK covers CJK characters."""
+ if not _cjk_font_works(multi_font_manager):
+ pytest.skip("CJK font not available or corrupted")
+
+ cjk_samples = [
+ "你好", # Chinese: Hello
+ "世界", # Chinese: World
+ "こんにちは", # Japanese: Hello
+ "안녕하세요", # Korean: Hello
+ ]
+
+ for sample in cjk_samples:
+ assert multi_font_manager.has_all_glyphs('NotoSansCJK-Regular', sample), \
+ f"NotoSansCJK should cover: {sample}"
+
+
+if __name__ == "__main__":
+ # Allow running this test directly for quick iteration
+ import sys
+
+ sys.exit(pytest.main([__file__, "-v", "-s"]))
diff --git a/tests/test_page_boxes.py b/tests/test_page_boxes.py
index 9251a04d..7741c797 100644
--- a/tests/test_page_boxes.py
+++ b/tests/test_page_boxes.py
@@ -15,12 +15,12 @@ wh_rect = [0, 0, 412, 592]
neg_rect = [-100, -100, 512, 692]
mediabox_testdata = [
- ('hocr', 'pdfa', 'ccitt.pdf', None, inset_rect, wh_rect),
+ ('fpdf2', 'pdfa', 'ccitt.pdf', None, inset_rect, wh_rect),
('sandwich', 'pdfa', 'ccitt.pdf', None, inset_rect, wh_rect),
- ('hocr', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect),
+ ('fpdf2', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect),
('sandwich', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect),
(
- 'hocr',
+ 'fpdf2',
'pdfa',
'ccitt.pdf',
'--force-ocr',
@@ -28,15 +28,15 @@ mediabox_testdata = [
wh_rect,
),
(
- 'hocr',
+ 'fpdf2',
'pdf',
'ccitt.pdf',
'--force-ocr',
inset_rect,
wh_rect,
),
- ('hocr', 'pdfa', 'ccitt.pdf', '--force-ocr', neg_rect, page_rect),
- ('hocr', 'pdf', 'ccitt.pdf', '--force-ocr', neg_rect, page_rect),
+ ('fpdf2', 'pdfa', 'ccitt.pdf', '--force-ocr', neg_rect, page_rect),
+ ('fpdf2', 'pdf', 'ccitt.pdf', '--force-ocr', neg_rect, page_rect),
]
@@ -69,12 +69,12 @@ def test_media_box(
cropbox_testdata = [
- ('hocr', 'pdfa', 'ccitt.pdf', None, inset_rect, inset_rect),
+ ('fpdf2', 'pdfa', 'ccitt.pdf', None, inset_rect, inset_rect),
('sandwich', 'pdfa', 'ccitt.pdf', None, inset_rect, inset_rect),
- ('hocr', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect),
+ ('fpdf2', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect),
('sandwich', 'pdf', 'ccitt.pdf', None, inset_rect, inset_rect),
(
- 'hocr',
+ 'fpdf2',
'pdfa',
'ccitt.pdf',
'--force-ocr',
@@ -82,7 +82,7 @@ cropbox_testdata = [
inset_rect,
),
(
- 'hocr',
+ 'fpdf2',
'pdf',
'ccitt.pdf',
'--force-ocr',
diff --git a/tests/test_pdf_renderer.py b/tests/test_pdf_renderer.py
index b181d11b..1fc7c183 100644
--- a/tests/test_pdf_renderer.py
+++ b/tests/test_pdf_renderer.py
@@ -1,7 +1,7 @@
# SPDX-FileCopyrightText: 2025 James R. Barlow
# SPDX-License-Identifier: MPL-2.0
-"""Unit tests for PdfTextRenderer class."""
+"""Unit tests for Fpdf2PdfRenderer class."""
from __future__ import annotations
@@ -15,17 +15,16 @@ from pdfminer.pdfdocument import PDFDocument
from pdfminer.pdfinterp import PDFPageInterpreter, PDFResourceManager
from pdfminer.pdfpage import PDFPage
from pdfminer.pdfparser import PDFParser
-from PIL import Image
+from ocrmypdf.font import MultiFontManager
+from ocrmypdf.fpdf_renderer import DebugRenderOptions, Fpdf2PdfRenderer
from ocrmypdf.helpers import check_pdf
from ocrmypdf.hocrtransform import (
Baseline,
BoundingBox,
OcrClass,
OcrElement,
- PdfTextRenderer,
)
-from ocrmypdf.hocrtransform.pdf_renderer import DebugRenderOptions
def text_from_pdf(filename: Path) -> str:
@@ -42,6 +41,18 @@ def text_from_pdf(filename: Path) -> str:
return output_string.getvalue()
+@pytest.fixture
+def font_dir():
+ """Get the font directory."""
+ return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data"
+
+
+@pytest.fixture
+def multi_font_manager(font_dir):
+ """Create a MultiFontManager for tests."""
+ return MultiFontManager(font_dir)
+
+
def create_simple_page(
width: float = 1000,
height: float = 500,
@@ -93,90 +104,108 @@ def create_simple_page(
return page
-class TestPdfTextRendererBasic:
- """Basic PdfTextRenderer functionality tests."""
+class TestFpdf2PdfRendererBasic:
+ """Basic Fpdf2PdfRenderer functionality tests."""
- def test_render_simple_page(self, tmp_path):
+ def test_render_simple_page(self, tmp_path, multi_font_manager):
"""Test rendering a simple page with two words."""
page = create_simple_page()
output_pdf = tmp_path / "simple.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ renderer.render(output_pdf)
assert output_pdf.exists()
check_pdf(str(output_pdf))
- def test_rendered_text_extractable(self, tmp_path):
+ def test_rendered_text_extractable(self, tmp_path, multi_font_manager):
"""Test that rendered text can be extracted from the PDF."""
page = create_simple_page()
output_pdf = tmp_path / "extractable.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ renderer.render(output_pdf)
extracted_text = text_from_pdf(output_pdf)
assert "Hello" in extracted_text
assert "World" in extracted_text
- def test_invisible_text_mode(self, tmp_path):
+ def test_invisible_text_mode(self, tmp_path, multi_font_manager):
"""Test that invisible_text=True creates a valid PDF."""
page = create_simple_page()
output_pdf = tmp_path / "invisible.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf, invisible_text=True)
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=72.0,
+ multi_font_manager=multi_font_manager,
+ invisible_text=True,
+ )
+ renderer.render(output_pdf)
# Text should still be extractable even when invisible
extracted_text = text_from_pdf(output_pdf)
assert "Hello" in extracted_text
- def test_visible_text_mode(self, tmp_path):
+ def test_visible_text_mode(self, tmp_path, multi_font_manager):
"""Test that invisible_text=False creates a valid PDF with visible text."""
page = create_simple_page()
output_pdf = tmp_path / "visible.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf, invisible_text=False)
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=72.0,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ )
+ renderer.render(output_pdf)
# Text should be extractable
extracted_text = text_from_pdf(output_pdf)
assert "Hello" in extracted_text
-class TestPdfTextRendererPageSize:
+class TestFpdf2PdfRendererPageSize:
"""Test page size calculations."""
- def test_page_dimensions(self, tmp_path):
+ def test_page_dimensions(self, tmp_path, multi_font_manager):
"""Test that page dimensions are calculated correctly."""
# 1000x500 pixels at 72 dpi = 1000x500 points
page = create_simple_page(width=1000, height=500)
output_pdf = tmp_path / "dimensions.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- assert renderer.width == pytest.approx(1000.0)
- assert renderer.height == pytest.approx(500.0)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ assert renderer.coord_transform.page_width_pt == pytest.approx(1000.0)
+ assert renderer.coord_transform.page_height_pt == pytest.approx(500.0)
- renderer.render(out_filename=output_pdf)
+ renderer.render(output_pdf)
- def test_high_dpi_page(self, tmp_path):
+ def test_high_dpi_page(self, tmp_path, multi_font_manager):
"""Test page dimensions at higher DPI."""
# 720x360 pixels at 144 dpi = 360x180 points
page = create_simple_page(width=720, height=360)
output_pdf = tmp_path / "high_dpi.pdf"
- renderer = PdfTextRenderer(page=page, dpi=144.0)
- assert renderer.width == pytest.approx(360.0)
- assert renderer.height == pytest.approx(180.0)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=144.0, multi_font_manager=multi_font_manager
+ )
+ assert renderer.coord_transform.page_width_pt == pytest.approx(360.0)
+ assert renderer.coord_transform.page_height_pt == pytest.approx(180.0)
- renderer.render(out_filename=output_pdf)
+ renderer.render(output_pdf)
check_pdf(str(output_pdf))
-class TestPdfTextRendererMultiLine:
+class TestFpdf2PdfRendererMultiLine:
"""Test rendering of multi-line content."""
- def test_multiple_lines(self, tmp_path):
+ def test_multiple_lines(self, tmp_path, multi_font_manager):
"""Test rendering multiple lines of text."""
line1_words = [
OcrElement(
@@ -231,8 +260,10 @@ class TestPdfTextRendererMultiLine:
)
output_pdf = tmp_path / "multiline.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ renderer.render(output_pdf)
extracted_text = text_from_pdf(output_pdf)
assert "Line" in extracted_text
@@ -240,20 +271,22 @@ class TestPdfTextRendererMultiLine:
assert "two" in extracted_text
-class TestPdfTextRendererTextDirection:
+class TestFpdf2PdfRendererTextDirection:
"""Test rendering of different text directions."""
- def test_ltr_text(self, tmp_path):
+ def test_ltr_text(self, tmp_path, multi_font_manager):
"""Test rendering LTR text."""
page = create_simple_page()
output_pdf = tmp_path / "ltr.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ renderer.render(output_pdf)
check_pdf(str(output_pdf))
- def test_rtl_text(self, tmp_path):
+ def test_rtl_text(self, tmp_path, multi_font_manager):
"""Test rendering RTL text."""
word = OcrElement(
ocr_class=OcrClass.WORD,
@@ -281,16 +314,18 @@ class TestPdfTextRendererTextDirection:
)
output_pdf = tmp_path / "rtl.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ renderer.render(output_pdf)
check_pdf(str(output_pdf))
-class TestPdfTextRendererBaseline:
+class TestFpdf2PdfRendererBaseline:
"""Test baseline handling in rendering."""
- def test_sloped_baseline(self, tmp_path):
+ def test_sloped_baseline(self, tmp_path, multi_font_manager):
"""Test rendering with a sloped baseline."""
word = OcrElement(
ocr_class=OcrClass.WORD,
@@ -317,18 +352,20 @@ class TestPdfTextRendererBaseline:
)
output_pdf = tmp_path / "sloped.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ renderer.render(output_pdf)
check_pdf(str(output_pdf))
extracted_text = text_from_pdf(output_pdf)
assert "Sloped" in extracted_text
-class TestPdfTextRendererTextangle:
+class TestFpdf2PdfRendererTextangle:
"""Test textangle (rotation) handling in rendering."""
- def test_rotated_text(self, tmp_path):
+ def test_rotated_text(self, tmp_path, multi_font_manager):
"""Test rendering rotated text."""
word = OcrElement(
ocr_class=OcrClass.WORD,
@@ -356,32 +393,36 @@ class TestPdfTextRendererTextangle:
)
output_pdf = tmp_path / "rotated.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ renderer.render(output_pdf)
check_pdf(str(output_pdf))
extracted_text = text_from_pdf(output_pdf)
assert "Rotated" in extracted_text
-class TestPdfTextRendererWordBreaks:
- """Test word break injection."""
+class TestFpdf2PdfRendererWordBreaks:
+ """Test word rendering."""
- def test_word_breaks_english(self, tmp_path):
- """Test that word breaks are injected for English text."""
+ def test_word_breaks_english(self, tmp_path, multi_font_manager):
+ """Test that words are rendered for English text."""
page = create_simple_page()
output_pdf = tmp_path / "english.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ renderer.render(output_pdf)
extracted_text = text_from_pdf(output_pdf)
- # Words should be separated
+ # Words should be present
assert "Hello" in extracted_text
assert "World" in extracted_text
- def test_no_word_breaks_cjk(self, tmp_path):
- """Test that word breaks are not injected for CJK text."""
+ def test_cjk_text(self, tmp_path, multi_font_manager):
+ """Test rendering CJK text."""
words = [
OcrElement(
ocr_class=OcrClass.WORD,
@@ -414,40 +455,47 @@ class TestPdfTextRendererWordBreaks:
)
output_pdf = tmp_path / "chinese.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ renderer.render(output_pdf)
check_pdf(str(output_pdf))
-class TestPdfTextRendererDebugOptions:
+class TestFpdf2PdfRendererDebugOptions:
"""Test debug rendering options."""
- def test_debug_render_options_default(self):
+ def test_debug_render_options_default(self, multi_font_manager):
"""Test that debug options are disabled by default."""
page = create_simple_page()
- renderer = PdfTextRenderer(page=page, dpi=72.0)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
- assert renderer.render_options.render_paragraph_bbox is False
- assert renderer.render_options.render_baseline is False
- assert renderer.render_options.render_word_bbox is False
+ assert renderer.debug_options.render_baseline is False
+ assert renderer.debug_options.render_word_bbox is False
+ assert renderer.debug_options.render_line_bbox is False
- def test_debug_render_options_enabled(self, tmp_path):
+ def test_debug_render_options_enabled(self, tmp_path, multi_font_manager):
"""Test rendering with debug options enabled."""
page = create_simple_page()
output_pdf = tmp_path / "debug.pdf"
debug_opts = DebugRenderOptions(
- render_paragraph_bbox=True,
render_baseline=True,
render_word_bbox=True,
- render_triangle=True,
+ render_line_bbox=True,
)
- renderer = PdfTextRenderer(
- page=page, dpi=72.0, debug_render_options=debug_opts
+ renderer = Fpdf2PdfRenderer(
+ page=page,
+ dpi=72.0,
+ multi_font_manager=multi_font_manager,
+ invisible_text=False,
+ debug_render_options=debug_opts,
)
- renderer.render(out_filename=output_pdf, invisible_text=False)
+ renderer.render(output_pdf)
check_pdf(str(output_pdf))
# Text should still be extractable
@@ -455,54 +503,30 @@ class TestPdfTextRendererDebugOptions:
assert "Hello" in extracted_text
-class TestPdfTextRendererWithImage:
- """Test rendering with image overlay."""
+class TestFpdf2PdfRendererErrors:
+ """Test error handling in Fpdf2PdfRenderer."""
- def test_render_with_image(self, tmp_path):
- """Test rendering with an image overlaid on text."""
- page = create_simple_page()
- output_pdf = tmp_path / "with_image.pdf"
-
- # Create a simple test image
- image_path = tmp_path / "test.png"
- img = Image.new('RGB', (1000, 500), color='white')
- img.save(image_path)
-
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(
- out_filename=output_pdf, image_filename=image_path, invisible_text=True
- )
-
- check_pdf(str(output_pdf))
- # Text should still be extractable under the image
- extracted_text = text_from_pdf(output_pdf)
- assert "Hello" in extracted_text
-
-
-class TestPdfTextRendererErrors:
- """Test error handling in PdfTextRenderer."""
-
- def test_invalid_ocr_class(self):
+ def test_invalid_ocr_class(self, multi_font_manager):
"""Test that non-page elements are rejected."""
line = OcrElement(
ocr_class=OcrClass.LINE, bbox=BoundingBox(left=0, top=0, right=100, bottom=50)
)
with pytest.raises(ValueError, match="ocr_page"):
- PdfTextRenderer(page=line, dpi=72.0)
+ Fpdf2PdfRenderer(page=line, dpi=72.0, multi_font_manager=multi_font_manager)
- def test_page_without_bbox(self):
+ def test_page_without_bbox(self, multi_font_manager):
"""Test that pages without bbox are rejected."""
page = OcrElement(ocr_class=OcrClass.PAGE)
with pytest.raises(ValueError, match="bounding box"):
- PdfTextRenderer(page=page, dpi=72.0)
+ Fpdf2PdfRenderer(page=page, dpi=72.0, multi_font_manager=multi_font_manager)
-class TestPdfTextRendererLineTypes:
+class TestFpdf2PdfRendererLineTypes:
"""Test rendering of different line types."""
- def test_header_line(self, tmp_path):
+ def test_header_line(self, tmp_path, multi_font_manager):
"""Test rendering header lines."""
word = OcrElement(
ocr_class=OcrClass.WORD,
@@ -529,14 +553,16 @@ class TestPdfTextRendererLineTypes:
)
output_pdf = tmp_path / "header.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ renderer.render(output_pdf)
check_pdf(str(output_pdf))
extracted_text = text_from_pdf(output_pdf)
assert "Header" in extracted_text
- def test_caption_line(self, tmp_path):
+ def test_caption_line(self, tmp_path, multi_font_manager):
"""Test rendering caption lines."""
word = OcrElement(
ocr_class=OcrClass.WORD,
@@ -563,8 +589,10 @@ class TestPdfTextRendererLineTypes:
)
output_pdf = tmp_path / "caption.pdf"
- renderer = PdfTextRenderer(page=page, dpi=72.0)
- renderer.render(out_filename=output_pdf)
+ renderer = Fpdf2PdfRenderer(
+ page=page, dpi=72.0, multi_font_manager=multi_font_manager
+ )
+ renderer.render(output_pdf)
check_pdf(str(output_pdf))
extracted_text = text_from_pdf(output_pdf)
diff --git a/tests/test_preprocessing.py b/tests/test_preprocessing.py
index 62678160..498524b2 100644
--- a/tests/test_preprocessing.py
+++ b/tests/test_preprocessing.py
@@ -15,7 +15,7 @@ from ocrmypdf.pdfinfo import PdfInfo
from .conftest import check_ocrmypdf, have_unpaper, run_ocrmypdf
-RENDERERS = ['hocr', 'sandwich']
+RENDERERS = ['fpdf2', 'sandwich']
def test_deskew(resources, outdir):
@@ -79,7 +79,7 @@ def test_remove_background(resources, outdir):
@pytest.mark.parametrize(
"pdf", ['palette.pdf', 'cmyk.pdf', 'ccitt.pdf', 'jbig2.pdf', 'lichtenstein.pdf']
)
-@pytest.mark.parametrize("renderer", ['sandwich', 'hocr'])
+@pytest.mark.parametrize("renderer", ['sandwich', 'fpdf2'])
@pytest.mark.parametrize("output_type", ['pdf', 'pdfa'])
def test_exotic_image(pdf, renderer, output_type, resources, outdir):
outfile = outdir / f'test_{pdf}_{renderer}.pdf'
diff --git a/tests/test_rotation.py b/tests/test_rotation.py
index fccedc7c..0d081c0a 100644
--- a/tests/test_rotation.py
+++ b/tests/test_rotation.py
@@ -24,7 +24,7 @@ from .conftest import check_ocrmypdf, run_ocrmypdf_api
# pylintx: disable=unused-variable
-RENDERERS = ['hocr', 'sandwich']
+RENDERERS = ['fpdf2', 'sandwich']
def compare_images_monochrome(
@@ -167,7 +167,7 @@ def test_rotated_skew_timeout(resources, outpdf):
input_file,
outpdf,
'--pdf-renderer',
- 'hocr',
+ 'fpdf2',
'--deskew',
'--tesseract-timeout',
'0',
@@ -198,7 +198,7 @@ def test_rotate_deskew_ocr_timeout(resources, outdir):
'--tesseract-timeout',
'0',
'--pdf-renderer',
- 'hocr',
+ 'fpdf2',
'--rasterizer',
'ghostscript', # Use Ghostscript for consistent dimensions
)
@@ -291,7 +291,7 @@ def test_page_rotate_tag(page_rotate_angle, resources, outdir, caplog):
@pytest.mark.parametrize('page_rotate_angle', (0, 90, 180, 270))
-@pytest.mark.parametrize('renderer', ['sandwich', 'hocr'])
+@pytest.mark.parametrize('renderer', ['sandwich', 'fpdf2'])
@pytest.mark.parametrize('output_type', ['pdf', 'pdfa'])
def test_rotate_and_crop(
resources, outdir, page_rotate_angle, renderer, output_type, caplog
diff --git a/tests/test_system_font_provider.py b/tests/test_system_font_provider.py
new file mode 100644
index 00000000..0efb8304
--- /dev/null
+++ b/tests/test_system_font_provider.py
@@ -0,0 +1,337 @@
+# SPDX-FileCopyrightText: 2025 James R. Barlow
+# SPDX-License-Identifier: MPL-2.0
+
+"""Unit tests for SystemFontProvider and ChainedFontProvider."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from ocrmypdf.font import (
+ BuiltinFontProvider,
+ ChainedFontProvider,
+ FontManager,
+ SystemFontProvider,
+)
+
+
+# --- SystemFontProvider Platform Detection Tests ---
+
+
+class TestSystemFontProviderPlatform:
+ """Test platform detection in SystemFontProvider."""
+
+ def test_get_platform_linux(self):
+ """Test Linux platform detection."""
+ provider = SystemFontProvider()
+ with patch.object(sys, 'platform', 'linux'):
+ assert provider._get_platform() == 'linux'
+
+ def test_get_platform_darwin(self):
+ """Test macOS platform detection."""
+ provider = SystemFontProvider()
+ with patch.object(sys, 'platform', 'darwin'):
+ assert provider._get_platform() == 'darwin'
+
+ def test_get_platform_windows(self):
+ """Test Windows platform detection."""
+ provider = SystemFontProvider()
+ with patch.object(sys, 'platform', 'win32'):
+ assert provider._get_platform() == 'windows'
+
+ def test_get_platform_freebsd(self):
+ """Test FreeBSD platform detection."""
+ provider = SystemFontProvider()
+ with patch.object(sys, 'platform', 'freebsd13'):
+ assert provider._get_platform() == 'freebsd'
+
+
+class TestSystemFontProviderDirectories:
+ """Test font directory resolution."""
+
+ def test_linux_font_dirs(self):
+ """Test Linux font directories."""
+ provider = SystemFontProvider()
+ with patch.object(sys, 'platform', 'linux'):
+ provider._font_dirs = None # Reset cache
+ dirs = provider._get_font_dirs()
+ assert Path('/usr/share/fonts') in dirs
+ assert Path('/usr/local/share/fonts') in dirs
+
+ def test_darwin_font_dirs(self):
+ """Test macOS font directories."""
+ provider = SystemFontProvider()
+ with patch.object(sys, 'platform', 'darwin'):
+ provider._font_dirs = None # Reset cache
+ dirs = provider._get_font_dirs()
+ assert Path('/Library/Fonts') in dirs
+ assert Path('/System/Library/Fonts') in dirs
+
+ def test_windows_font_dirs_with_windir(self):
+ """Test Windows font directory from WINDIR env var."""
+ provider = SystemFontProvider()
+ with patch.object(sys, 'platform', 'win32'):
+ with patch.dict('os.environ', {'WINDIR': r'D:\Windows'}):
+ provider._font_dirs = None # Reset cache
+ dirs = provider._get_font_dirs()
+ # Check that Fonts subdir of WINDIR is included
+ # Use str comparison to avoid Path normalization issues across platforms
+ dir_strs = [str(d) for d in dirs]
+ assert any('Fonts' in d for d in dir_strs)
+
+ def test_windows_font_dirs_default(self):
+ """Test Windows font directory with default path."""
+ provider = SystemFontProvider()
+ with patch.object(sys, 'platform', 'win32'):
+ with patch.dict('os.environ', {}, clear=True):
+ provider._font_dirs = None # Reset cache
+ dirs = provider._get_font_dirs()
+ # Check that Windows\Fonts is included (default fallback)
+ dir_strs = [str(d) for d in dirs]
+ assert any('Windows' in d and 'Fonts' in d for d in dir_strs)
+
+ def test_windows_font_dirs_with_localappdata(self):
+ """Test Windows user fonts directory from LOCALAPPDATA env var."""
+ provider = SystemFontProvider()
+ with patch.object(sys, 'platform', 'win32'):
+ with patch.dict(
+ 'os.environ',
+ {'WINDIR': r'C:\Windows', 'LOCALAPPDATA': r'C:\Users\Test\AppData\Local'},
+ ):
+ provider._font_dirs = None # Reset cache
+ dirs = provider._get_font_dirs()
+ dir_strs = [str(d) for d in dirs]
+ # Should have both system and user font directories
+ assert len(dirs) == 2
+ assert any('Windows' in d and 'Fonts' in d for d in dir_strs)
+ assert any('AppData' in d and 'Local' in d and 'Fonts' in d for d in dir_strs)
+
+ def test_font_dirs_cached(self):
+ """Test that font directories are cached."""
+ provider = SystemFontProvider()
+ dirs1 = provider._get_font_dirs()
+ dirs2 = provider._get_font_dirs()
+ assert dirs1 is dirs2 # Same object, not recomputed
+
+
+class TestSystemFontProviderLazyLoading:
+ """Test lazy loading behavior."""
+
+ def test_no_scanning_on_init(self):
+ """Test that no directory scanning happens during initialization."""
+ provider = SystemFontProvider()
+ # Caches should be empty
+ assert len(provider._font_cache) == 0
+ assert len(provider._not_found) == 0
+
+ def test_get_font_unknown_name_returns_none(self):
+ """Test that unknown font names return None."""
+ provider = SystemFontProvider()
+ result = provider.get_font('UnknownFont-Regular')
+ assert result is None
+ # Unknown fonts are added to not_found to cache the negative result
+ assert 'UnknownFont-Regular' in provider._not_found
+
+ def test_negative_cache(self):
+ """Test that not-found results are cached."""
+ provider = SystemFontProvider()
+ # Mock _find_font_file to return None
+ with patch.object(provider, '_find_font_file', return_value=None):
+ result1 = provider.get_font('NotoSansCJK-Regular')
+ assert result1 is None
+ assert 'NotoSansCJK-Regular' in provider._not_found
+
+ # Second call should not call _find_font_file again
+ provider._find_font_file = MagicMock(return_value=None)
+ result2 = provider.get_font('NotoSansCJK-Regular')
+ assert result2 is None
+ provider._find_font_file.assert_not_called()
+
+ def test_positive_cache(self):
+ """Test that found fonts are cached."""
+ provider = SystemFontProvider()
+ font_dir = Path(__file__).parent.parent / "src" / "ocrmypdf" / "data"
+ font_path = font_dir / "NotoSans-Regular.ttf"
+
+ if not font_path.exists():
+ pytest.skip("Test font not available")
+
+ with patch.object(provider, '_find_font_file', return_value=font_path):
+ result1 = provider.get_font('NotoSans-Regular')
+ assert result1 is not None
+ assert 'NotoSans-Regular' in provider._font_cache
+
+ # Second call should use cache
+ provider._find_font_file = MagicMock()
+ result2 = provider.get_font('NotoSans-Regular')
+ assert result2 is result1
+ provider._find_font_file.assert_not_called()
+
+
+class TestSystemFontProviderAvailableFonts:
+ """Test get_available_fonts method."""
+
+ def test_returns_all_patterns(self):
+ """Test that get_available_fonts returns all known font patterns."""
+ provider = SystemFontProvider()
+ fonts = provider.get_available_fonts()
+ assert 'NotoSans-Regular' in fonts
+ assert 'NotoSansCJK-Regular' in fonts
+ assert 'NotoSansArabic-Regular' in fonts
+ assert 'NotoSansThai-Regular' in fonts
+
+ def test_fallback_font_raises(self):
+ """Test that get_fallback_font raises NotImplementedError."""
+ provider = SystemFontProvider()
+ with pytest.raises(NotImplementedError):
+ provider.get_fallback_font()
+
+
+# --- ChainedFontProvider Tests ---
+
+
+class TestChainedFontProvider:
+ """Test ChainedFontProvider."""
+
+ def test_requires_at_least_one_provider(self):
+ """Test that empty provider list raises error."""
+ with pytest.raises(ValueError, match="At least one provider"):
+ ChainedFontProvider([])
+
+ def test_get_font_tries_providers_in_order(self):
+ """Test that get_font tries providers in order."""
+ provider1 = MagicMock()
+ provider1.get_font.return_value = None
+
+ provider2 = MagicMock()
+ mock_font = MagicMock()
+ provider2.get_font.return_value = mock_font
+
+ chain = ChainedFontProvider([provider1, provider2])
+ result = chain.get_font('TestFont')
+
+ provider1.get_font.assert_called_once_with('TestFont')
+ provider2.get_font.assert_called_once_with('TestFont')
+ assert result is mock_font
+
+ def test_get_font_stops_on_first_match(self):
+ """Test that get_font stops after first successful match."""
+ mock_font = MagicMock()
+ provider1 = MagicMock()
+ provider1.get_font.return_value = mock_font
+
+ provider2 = MagicMock()
+
+ chain = ChainedFontProvider([provider1, provider2])
+ result = chain.get_font('TestFont')
+
+ provider1.get_font.assert_called_once()
+ provider2.get_font.assert_not_called()
+ assert result is mock_font
+
+ def test_get_font_returns_none_if_all_fail(self):
+ """Test that get_font returns None if all providers fail."""
+ provider1 = MagicMock()
+ provider1.get_font.return_value = None
+
+ provider2 = MagicMock()
+ provider2.get_font.return_value = None
+
+ chain = ChainedFontProvider([provider1, provider2])
+ result = chain.get_font('TestFont')
+
+ assert result is None
+
+ def test_get_available_fonts_combines_providers(self):
+ """Test that get_available_fonts combines all providers."""
+ provider1 = MagicMock()
+ provider1.get_available_fonts.return_value = ['Font1', 'Font2']
+
+ provider2 = MagicMock()
+ provider2.get_available_fonts.return_value = ['Font2', 'Font3']
+
+ chain = ChainedFontProvider([provider1, provider2])
+ fonts = chain.get_available_fonts()
+
+ assert fonts == ['Font1', 'Font2', 'Font3'] # Deduplicated, order preserved
+
+ def test_get_fallback_font_from_first_provider(self):
+ """Test that get_fallback_font uses first available fallback."""
+ mock_font = MagicMock()
+ provider1 = MagicMock()
+ provider1.get_fallback_font.return_value = mock_font
+
+ provider2 = MagicMock()
+
+ chain = ChainedFontProvider([provider1, provider2])
+ result = chain.get_fallback_font()
+
+ assert result is mock_font
+ provider2.get_fallback_font.assert_not_called()
+
+ def test_get_fallback_font_skips_not_implemented(self):
+ """Test that get_fallback_font skips providers that raise."""
+ provider1 = MagicMock()
+ provider1.get_fallback_font.side_effect = NotImplementedError()
+
+ mock_font = MagicMock()
+ provider2 = MagicMock()
+ provider2.get_fallback_font.return_value = mock_font
+
+ chain = ChainedFontProvider([provider1, provider2])
+ result = chain.get_fallback_font()
+
+ assert result is mock_font
+
+ def test_get_fallback_font_raises_if_none_available(self):
+ """Test that get_fallback_font raises if no provider has fallback."""
+ provider1 = MagicMock()
+ provider1.get_fallback_font.side_effect = NotImplementedError()
+
+ provider2 = MagicMock()
+ provider2.get_fallback_font.side_effect = KeyError()
+
+ chain = ChainedFontProvider([provider1, provider2])
+ with pytest.raises(RuntimeError, match="No fallback font available"):
+ chain.get_fallback_font()
+
+
+class TestChainedFontProviderIntegration:
+ """Integration tests with real providers."""
+
+ @pytest.fixture
+ def font_dir(self):
+ """Return path to font directory."""
+ return Path(__file__).parent.parent / "src" / "ocrmypdf" / "data"
+
+ def test_builtin_then_system_chain(self, font_dir):
+ """Test chaining BuiltinFontProvider with SystemFontProvider."""
+ builtin = BuiltinFontProvider(font_dir)
+ system = SystemFontProvider()
+
+ chain = ChainedFontProvider([builtin, system])
+
+ # Should find NotoSans from builtin
+ font = chain.get_font('NotoSans-Regular')
+ assert font is not None
+
+ # Should get fallback from builtin
+ fallback = chain.get_fallback_font()
+ assert fallback is not None
+
+ def test_system_fonts_extend_builtin(self, font_dir):
+ """Test that system fonts add to builtin fonts."""
+ builtin = BuiltinFontProvider(font_dir)
+ system = SystemFontProvider()
+
+ chain = ChainedFontProvider([builtin, system])
+
+ builtin_fonts = set(builtin.get_available_fonts())
+ chain_fonts = set(chain.get_available_fonts())
+
+ # Chain should have at least as many fonts as builtin
+ assert chain_fonts >= builtin_fonts
diff --git a/tests/test_tesseract.py b/tests/test_tesseract.py
index aeb06ffd..81c42c3f 100644
--- a/tests/test_tesseract.py
+++ b/tests/test_tesseract.py
@@ -49,7 +49,9 @@ def test_skip_pages_does_not_replicate(resources, basename, outdir):
def test_content_preservation(resources, outpdf):
infile = resources / 'masks.pdf'
- check_ocrmypdf(infile, outpdf, '--pdf-renderer', 'hocr', '--tesseract-timeout', '0')
+ check_ocrmypdf(
+ infile, outpdf, '--pdf-renderer', 'fpdf2', '--tesseract-timeout', '0'
+ )
info = pdfinfo.PdfInfo(outpdf)
page = info[0]