""" Focused tests for RasterisedDocumentParser.parse() mode behaviour. These tests mock ``ocrmypdf.ocr`` so they run without a real Tesseract/OCRmyPDF installation and execute quickly. The intent is to verify the *control flow* introduced by the ``produce_archive`` flag and the ``OCR_MODE=auto/off`` logic, not to test OCRmyPDF itself. Fixtures are pulled from conftest.py in this package. """ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING import pytest from paperless.models import ModeChoices if TYPE_CHECKING: from pytest_mock import MockerFixture from paperless.parsers.tesseract import RasterisedDocumentParser # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- _LONG_TEXT = "This is a test document with enough text. " * 5 # >50 chars _SHORT_TEXT = "Hi." # <50 chars def _make_extract_text(text: str | None): """Return a side_effect function for ``extract_text`` that returns *text*.""" def _extract(sidecar_file, pdf_file): return text return _extract # --------------------------------------------------------------------------- # AUTO mode — PDF with sufficient text layer # --------------------------------------------------------------------------- class TestAutoModeWithText: """AUTO mode, original PDF has detectable text (>50 chars).""" def test_auto_text_no_archive_skips_ocrmypdf( self, mocker: MockerFixture, tesseract_parser: RasterisedDocumentParser, simple_digital_pdf_file: Path, ) -> None: """ GIVEN: - AUTO mode, produce_archive=False - PDF with text > VALID_TEXT_LENGTH WHEN: - parse() is called THEN: - ocrmypdf.ocr is NOT called (early return path) - archive_path remains None - text is set from the original """ # Patch extract_text to return long text (simulating detectable text layer) mocker.patch.object( tesseract_parser, "extract_text", return_value=_LONG_TEXT, ) mock_ocr = mocker.patch("ocrmypdf.ocr") tesseract_parser.settings.mode = ModeChoices.AUTO tesseract_parser.parse( simple_digital_pdf_file, "application/pdf", produce_archive=False, ) mock_ocr.assert_not_called() assert tesseract_parser.archive_path is None assert tesseract_parser.get_text() == _LONG_TEXT def test_auto_text_with_archive_calls_ocrmypdf_skip_text( self, mocker: MockerFixture, tesseract_parser: RasterisedDocumentParser, simple_digital_pdf_file: Path, ) -> None: """ GIVEN: - AUTO mode, produce_archive=True - PDF with text > VALID_TEXT_LENGTH WHEN: - parse() is called THEN: - ocrmypdf.ocr IS called with skip_text=True - archive_path is set """ mocker.patch.object( tesseract_parser, "extract_text", return_value=_LONG_TEXT, ) mock_ocr = mocker.patch("ocrmypdf.ocr") tesseract_parser.settings.mode = ModeChoices.AUTO tesseract_parser.parse( simple_digital_pdf_file, "application/pdf", produce_archive=True, ) mock_ocr.assert_called_once() call_kwargs = mock_ocr.call_args.kwargs assert call_kwargs.get("skip_text") is True assert "force_ocr" not in call_kwargs assert "redo_ocr" not in call_kwargs assert tesseract_parser.archive_path is not None # --------------------------------------------------------------------------- # AUTO mode — PDF without text layer (or too short) # --------------------------------------------------------------------------- class TestAutoModeNoText: """AUTO mode, original PDF has no detectable text (<= 50 chars).""" def test_auto_no_text_with_archive_calls_ocrmypdf_no_extra_flag( self, mocker: MockerFixture, tesseract_parser: RasterisedDocumentParser, multi_page_images_pdf_file: Path, ) -> None: """ GIVEN: - AUTO mode, produce_archive=True - PDF with no text (or text <= VALID_TEXT_LENGTH) WHEN: - parse() is called THEN: - ocrmypdf.ocr IS called WITHOUT skip_text/force_ocr/redo_ocr - archive_path is set (since produce_archive=True) """ # Return "no text" for the original; return real text for archive extract_call_count = 0 def _extract_side(sidecar_file, pdf_file): nonlocal extract_call_count extract_call_count += 1 if extract_call_count == 1: return None # original has no text return _LONG_TEXT # text from archive after OCR mocker.patch.object(tesseract_parser, "extract_text", side_effect=_extract_side) mock_ocr = mocker.patch("ocrmypdf.ocr") tesseract_parser.settings.mode = ModeChoices.AUTO tesseract_parser.parse( multi_page_images_pdf_file, "application/pdf", produce_archive=True, ) mock_ocr.assert_called_once() call_kwargs = mock_ocr.call_args.kwargs assert "skip_text" not in call_kwargs assert "force_ocr" not in call_kwargs assert "redo_ocr" not in call_kwargs assert tesseract_parser.archive_path is not None def test_auto_no_text_no_archive_calls_ocrmypdf( self, mocker: MockerFixture, tesseract_parser: RasterisedDocumentParser, multi_page_images_pdf_file: Path, ) -> None: """ GIVEN: - AUTO mode, produce_archive=False - PDF with no text WHEN: - parse() is called THEN: - ocrmypdf.ocr IS called (no early return since no text detected) - archive_path is NOT set (produce_archive=False) """ extract_call_count = 0 def _extract_side(sidecar_file, pdf_file): nonlocal extract_call_count extract_call_count += 1 if extract_call_count == 1: return None return _LONG_TEXT mocker.patch.object(tesseract_parser, "extract_text", side_effect=_extract_side) mock_ocr = mocker.patch("ocrmypdf.ocr") tesseract_parser.settings.mode = ModeChoices.AUTO tesseract_parser.parse( multi_page_images_pdf_file, "application/pdf", produce_archive=False, ) mock_ocr.assert_called_once() assert tesseract_parser.archive_path is None # --------------------------------------------------------------------------- # OFF mode — PDF # --------------------------------------------------------------------------- class TestOffModePdf: """OCR_MODE=off, document is a PDF.""" def test_off_no_archive_returns_pdftotext( self, mocker: MockerFixture, tesseract_parser: RasterisedDocumentParser, simple_digital_pdf_file: Path, ) -> None: """ GIVEN: - OFF mode, produce_archive=False - PDF with text WHEN: - parse() is called THEN: - ocrmypdf.ocr is NOT called - archive_path is None - text comes from pdftotext (extract_text) """ mocker.patch.object( tesseract_parser, "extract_text", return_value=_LONG_TEXT, ) mock_ocr = mocker.patch("ocrmypdf.ocr") tesseract_parser.settings.mode = ModeChoices.OFF tesseract_parser.parse( simple_digital_pdf_file, "application/pdf", produce_archive=False, ) mock_ocr.assert_not_called() assert tesseract_parser.archive_path is None assert tesseract_parser.get_text() == _LONG_TEXT def test_off_with_archive_uses_ghostscript_not_ocr( self, mocker: MockerFixture, tesseract_parser: RasterisedDocumentParser, simple_digital_pdf_file: Path, ) -> None: """ GIVEN: - OFF mode, produce_archive=True - PDF document WHEN: - parse() is called THEN: - ocrmypdf.ocr is NOT called - Ghostscript generate_pdfa IS called (PDF/A conversion without OCR) - archive_path is set - text comes from pdftotext, not OCR """ mocker.patch.object( tesseract_parser, "extract_text", return_value=_LONG_TEXT, ) mock_ocr = mocker.patch("ocrmypdf.ocr") mock_gs = mocker.patch( "ocrmypdf._exec.ghostscript.generate_pdfa", ) mocker.patch("ocrmypdf.pdfa.generate_pdfa_ps") tesseract_parser.settings.mode = ModeChoices.OFF tesseract_parser.parse( simple_digital_pdf_file, "application/pdf", produce_archive=True, ) mock_ocr.assert_not_called() mock_gs.assert_called_once() assert tesseract_parser.archive_path is not None assert tesseract_parser.get_text() == _LONG_TEXT # --------------------------------------------------------------------------- # OFF mode — image # --------------------------------------------------------------------------- class TestOffModeImage: """OCR_MODE=off, document is an image (PNG).""" def test_off_image_no_archive_no_ocrmypdf( self, mocker: MockerFixture, tesseract_parser: RasterisedDocumentParser, simple_png_file: Path, ) -> None: """ GIVEN: - OFF mode, produce_archive=False - Image document (PNG) WHEN: - parse() is called THEN: - ocrmypdf.ocr is NOT called - archive_path is None - text is empty string (images have no text layer) """ mock_ocr = mocker.patch("ocrmypdf.ocr") tesseract_parser.settings.mode = ModeChoices.OFF tesseract_parser.parse(simple_png_file, "image/png", produce_archive=False) mock_ocr.assert_not_called() assert tesseract_parser.archive_path is None assert tesseract_parser.get_text() == "" def test_off_image_with_archive_uses_img2pdf_path( self, mocker: MockerFixture, tesseract_parser: RasterisedDocumentParser, simple_png_file: Path, ) -> None: """ GIVEN: - OFF mode, produce_archive=True - Image document (PNG) WHEN: - parse() is called THEN: - _convert_image_to_pdfa() is called instead of ocrmypdf.ocr - archive_path is set to the returned path - text is empty string """ fake_archive = Path("/tmp/fake-archive.pdf") mock_convert = mocker.patch.object( tesseract_parser, "_convert_image_to_pdfa", return_value=fake_archive, ) mock_ocr = mocker.patch("ocrmypdf.ocr") tesseract_parser.settings.mode = ModeChoices.OFF tesseract_parser.parse(simple_png_file, "image/png", produce_archive=True) mock_convert.assert_called_once_with(simple_png_file) mock_ocr.assert_not_called() assert tesseract_parser.archive_path == fake_archive assert tesseract_parser.get_text() == "" # --------------------------------------------------------------------------- # produce_archive=False never sets archive_path for FORCE / REDO / AUTO modes # --------------------------------------------------------------------------- class TestProduceArchiveFalse: """Verify produce_archive=False never results in an archive regardless of mode.""" @pytest.mark.parametrize("mode", ["force", "redo"]) def test_produce_archive_false_force_redo_modes( self, mode: str, mocker: MockerFixture, tesseract_parser: RasterisedDocumentParser, multi_page_images_pdf_file: Path, ) -> None: """ GIVEN: - FORCE or REDO mode, produce_archive=False - Any PDF WHEN: - parse() is called (ocrmypdf mocked to succeed) THEN: - archive_path is NOT set even though ocrmypdf ran """ mocker.patch.object( tesseract_parser, "extract_text", return_value=_LONG_TEXT, ) mocker.patch("ocrmypdf.ocr") tesseract_parser.settings.mode = mode tesseract_parser.parse( multi_page_images_pdf_file, "application/pdf", produce_archive=False, ) assert tesseract_parser.archive_path is None assert tesseract_parser.get_text() is not None def test_produce_archive_false_auto_with_text( self, mocker: MockerFixture, tesseract_parser: RasterisedDocumentParser, simple_digital_pdf_file: Path, ) -> None: """ GIVEN: - AUTO mode, produce_archive=False - PDF with text > VALID_TEXT_LENGTH WHEN: - parse() is called THEN: - ocrmypdf is skipped entirely (early return) - archive_path is None """ mocker.patch.object( tesseract_parser, "extract_text", return_value=_LONG_TEXT, ) mock_ocr = mocker.patch("ocrmypdf.ocr") tesseract_parser.settings.mode = ModeChoices.AUTO tesseract_parser.parse( simple_digital_pdf_file, "application/pdf", produce_archive=False, ) mock_ocr.assert_not_called() assert tesseract_parser.archive_path is None