import subprocess from collections.abc import Generator from pathlib import Path import pikepdf import pytest from PIL import Image from pytest_django.fixtures import Settings from pytest_mock import MockerFixture from documents.parsers import ParseError from documents.parsers import _compute_thumbnail_dpi from documents.parsers import encode_thumbnail_webp from documents.parsers import get_default_file_extension from documents.parsers import get_default_thumbnail from documents.parsers import get_supported_file_extensions from documents.parsers import is_file_ext_supported from documents.parsers import make_thumbnail_from_pdf from documents.parsers import rasterize_pdf_page_to_png from paperless.parsers.registry import get_parser_registry from paperless.parsers.registry import reset_parser_registry from paperless.parsers.tesseract import RasterisedDocumentParser from paperless.parsers.text import TextDocumentParser from paperless.parsers.tika import TikaDocumentParser @pytest.fixture() def _tika_registry(settings: Settings) -> Generator[None, None, None]: """ Rebuild the parser registry with Tika enabled for the duration of the test, then reset on exit so other tests see the default (Tika-disabled) registry. """ settings.TIKA_ENABLED = True reset_parser_registry() yield reset_parser_registry() @pytest.mark.django_db class TestParserAvailability: @pytest.mark.parametrize( ("mime_type", "ext"), [ pytest.param("application/pdf", ".pdf", id="pdf"), pytest.param("image/png", ".png", id="png"), pytest.param("image/jpeg", ".jpg", id="jpeg"), pytest.param("image/tiff", ".tif", id="tiff"), pytest.param("image/webp", ".webp", id="webp"), ], ) def test_tesseract_parser(self, mime_type: str, ext: str) -> None: """ GIVEN: - Various mime types WHEN: - The parser class is instantiated THEN: - The Tesseract based parser is returned """ assert ext in get_supported_file_extensions() assert get_default_file_extension(mime_type) == ext assert isinstance( get_parser_registry().get_parser_for_file(mime_type, "")(), RasterisedDocumentParser, ) @pytest.mark.parametrize( ("mime_type", "ext"), [ pytest.param("text/plain", ".txt", id="plain"), pytest.param("text/csv", ".csv", id="csv"), ], ) def test_text_parser(self, mime_type: str, ext: str) -> None: """ GIVEN: - Various mime types of a text form WHEN: - The parser class is instantiated THEN: - The text based parser is returned """ assert ext in get_supported_file_extensions() assert get_default_file_extension(mime_type) == ext assert isinstance( get_parser_registry().get_parser_for_file(mime_type, "")(), TextDocumentParser, ) @pytest.mark.usefixtures("_tika_registry") @pytest.mark.parametrize( ("mime_type", "ext"), [ pytest.param( "application/vnd.oasis.opendocument.text", ".odt", id="odt", ), pytest.param("text/rtf", ".rtf", id="rtf"), pytest.param("application/msword", ".doc", id="doc"), pytest.param( "application/vnd.openxmlformats-officedocument.wordprocessingml.document", ".docx", id="docx", ), ], ) def test_tika_parser(self, mime_type: str, ext: str) -> None: """ GIVEN: - Various mime types of an office document form WHEN: - The parser class is instantiated THEN: - The Tika/Gotenberg based parser is returned """ assert ext in get_supported_file_extensions() assert get_default_file_extension(mime_type) == ext assert isinstance( get_parser_registry().get_parser_for_file(mime_type, "")(), TikaDocumentParser, ) def test_no_parser_for_mime(self) -> None: assert get_parser_registry().get_parser_for_file("text/sdgsdf", "") is None def test_default_extension(self) -> None: # Test no parser declared still returns an extension assert get_default_file_extension("application/zip") == ".zip" # Test invalid mimetype returns no extension assert get_default_file_extension("aasdasd/dgfgf") == "" def test_file_extension_support(self) -> None: assert is_file_ext_supported(".pdf") assert not is_file_ext_supported(".hsdfh") assert not is_file_ext_supported("") class TestComputeThumbnailDpi: @pytest.mark.parametrize( ("size", "expected"), [ pytest.param((612.0, 792.0), (500 * 72 / 612, 2), id="letter-width-bound"), pytest.param( (792.0, 612.0), (500 * 72 / 792, 2), id="landscape-width-bound", ), pytest.param( (612.0, 100000.0), (5000 * 72 / 100000, 2), id="tall-strip-height-bound", ), pytest.param((200.0, 300.0), (180, 2), id="small-page-width-bound"), pytest.param((72.0, 72.0), (300, 2), id="tiny-page-capped-at-300"), pytest.param( (1000000.0, 1000000.0), (500 * 72 / 1000000, 2), id="huge-page-fractional", ), pytest.param(None, (150, 1), id="unreadable-geometry-fallback"), ], ) def test_dpi_from_page_size( self, mocker: MockerFixture, tmp_path: Path, size: tuple[float, float] | None, expected: tuple[float, int], ) -> None: """ GIVEN: - A first page of the given size, or unreadable geometry WHEN: - The thumbnail DPI is computed THEN: - The expected DPI and supersample factor are returned """ mocker.patch( "paperless.parsers.utils.get_pdf_first_page_size_points", return_value=size, ) assert _compute_thumbnail_dpi(tmp_path / "doc.pdf") == pytest.approx(expected) class TestMakeThumbnailFromPdf: @pytest.fixture def work_dir(self, tmp_path: Path) -> Path: path = tmp_path / "work" path.mkdir() return path @staticmethod def _write_blank_pdf(path: Path, page_size: tuple[int, int] = (612, 792)) -> Path: pdf = pikepdf.new() pdf.add_blank_page(page_size=page_size) pdf.save(path, object_stream_mode=pikepdf.ObjectStreamMode.disable) return path @pytest.mark.parametrize( ("size", "expected_dpi", "expected_supersample"), [ pytest.param( (612.0, 792.0), 2 * 500 * 72 / 612, 2, id="known-geometry-2x", ), pytest.param(None, 150, 1, id="unreadable-geometry-plain-fallback"), ], ) def test_render_dpi_requested( self, mocker: MockerFixture, tmp_path: Path, work_dir: Path, size: tuple[float, float] | None, expected_dpi: float, expected_supersample: int, ) -> None: """ GIVEN: - Readable or unreadable page geometry WHEN: - A thumbnail is made THEN: - Rasterize and encode get the matching DPI and supersample factor """ mocker.patch( "paperless.parsers.utils.get_pdf_first_page_size_points", return_value=size, ) rasterize = mocker.patch("documents.parsers.rasterize_pdf_page_to_png") encode = mocker.patch("documents.parsers.encode_thumbnail_webp") make_thumbnail_from_pdf(tmp_path / "in.pdf", work_dir) assert rasterize.call_args.kwargs["dpi"] == pytest.approx(expected_dpi) assert encode.call_args.kwargs["supersample"] == expected_supersample @pytest.mark.parametrize( ("page_size", "expected_width"), [ pytest.param((612, 792), 500, id="letter"), pytest.param((792, 612), 500, id="landscape-letter"), pytest.param((595, 842), 500, id="a4"), pytest.param((200, 300), 500, id="small-page"), pytest.param((72, 72), 300, id="tiny-page-capped"), ], ) def test_thumbnail_width( self, tmp_path: Path, work_dir: Path, page_size: tuple[int, int], expected_width: int, ) -> None: """ GIVEN: - A PDF whose first page has the given size in points WHEN: - A thumbnail is made from it THEN: - The thumbnail has the expected width """ pdf_path = self._write_blank_pdf(tmp_path / "in.pdf", page_size) thumb = make_thumbnail_from_pdf(pdf_path, work_dir) assert thumb == work_dir / "convert.webp" with Image.open(thumb) as im: assert im.format == "WEBP" assert im.width == expected_width @classmethod def _write_pdf_without_xref(cls, path: Path) -> Path: """ Cuts off the xref and trailer, which pdftoppm cannot recover from but qpdf can. """ cls._write_blank_pdf(path) data = path.read_bytes() path.write_bytes(data[: data.rindex(b"\nxref")]) return path def test_qpdf_repair_produces_real_thumbnail( self, tmp_path: Path, work_dir: Path, ) -> None: """ GIVEN: - A PDF with its xref table and trailer cut off WHEN: - A thumbnail is made from it THEN: - The thumbnail is rendered from a qpdf repaired copy - The original file is unchanged """ pdf_path = self._write_pdf_without_xref(tmp_path / "broken.pdf") original_bytes = pdf_path.read_bytes() with pytest.raises(ParseError): rasterize_pdf_page_to_png(pdf_path, work_dir / "probe.png", dpi=50) thumb = make_thumbnail_from_pdf(pdf_path, work_dir) assert thumb == work_dir / "convert_qpdf.webp" with Image.open(thumb) as im: assert im.format == "WEBP" assert im.width == 500 assert pdf_path.read_bytes() == original_bytes @pytest.mark.parametrize( "qpdf_error", [ pytest.param(subprocess.CalledProcessError(2, "qpdf"), id="qpdf-fails"), pytest.param(None, id="repaired-still-unrenderable"), ], ) def test_double_failure_uses_default_thumbnail( self, mocker: MockerFixture, tmp_path: Path, work_dir: Path, qpdf_error: subprocess.CalledProcessError | None, ) -> None: """ GIVEN: - A PDF that cannot be rendered, even after qpdf repair WHEN: - A thumbnail is made from it THEN: - A copy of the default thumbnail is returned """ mocker.patch( "documents.parsers.rasterize_pdf_page_to_png", side_effect=ParseError("Does not compute."), ) if qpdf_error is not None: mocker.patch("documents.parsers.run_subprocess", side_effect=qpdf_error) pdf_path = self._write_blank_pdf(tmp_path / "in.pdf") thumb = make_thumbnail_from_pdf(pdf_path, work_dir) assert thumb == work_dir / "document.webp" assert thumb.read_bytes() == get_default_thumbnail().read_bytes() class TestRasterizePdfPageToPng: @staticmethod def _write_pdf( path: Path, *, crop_box: tuple[float, float, float, float] | None = None, rotate: int | None = None, ) -> Path: pdf = pikepdf.new() pdf.add_blank_page(page_size=(144, 72)) pdf.add_blank_page(page_size=(300, 300)) page = pdf.pages[0] if crop_box is not None: page.obj.CropBox = pikepdf.Array(crop_box) if rotate is not None: page.obj.Rotate = rotate pdf.save(path) return path @pytest.mark.parametrize( ("crop_box", "rotate", "expected_size"), [ pytest.param(None, None, (144, 72), id="plain"), pytest.param(None, 90, (72, 144), id="rotated-90"), pytest.param((0, 0, 72, 36), None, (72, 36), id="crop-box"), ], ) def test_renders_first_page_to_exact_path( self, tmp_path: Path, crop_box: tuple[float, float, float, float] | None, rotate: int | None, expected_size: tuple[int, int], ) -> None: """ GIVEN: - A two page PDF, first page optionally cropped or rotated WHEN: - The first page is rasterized at 72 DPI THEN: - Only out_path is written, sized to the first page's crop and rotation """ pdf_path = self._write_pdf( tmp_path / "in.pdf", crop_box=crop_box, rotate=rotate, ) out_dir = tmp_path / "out" out_dir.mkdir() out_path = out_dir / "page1.png" rasterize_pdf_page_to_png(pdf_path, out_path, dpi=72) assert list(out_dir.iterdir()) == [out_path] with Image.open(out_path) as im: assert im.format == "PNG" assert im.size == expected_size def test_failure_raises_parse_error(self, tmp_path: Path) -> None: """ GIVEN: - A file that is not a PDF WHEN: - Rasterization is attempted THEN: - A ParseError is raised """ bad = tmp_path / "bad.pdf" bad.write_bytes(b"not a pdf") with pytest.raises(ParseError): rasterize_pdf_page_to_png(bad, tmp_path / "page1.png", dpi=72) class TestEncodeThumbnailWebp: @pytest.mark.parametrize( ("mode", "color"), [ pytest.param("RGBA", (0, 0, 0, 0), id="rgba"), pytest.param("LA", (0, 0), id="la"), ], ) def test_alpha_flattened_onto_white( self, tmp_path: Path, mode: str, color: tuple[int, ...], ) -> None: """ GIVEN: - A fully transparent PNG with an alpha channel WHEN: - It is encoded as a thumbnail THEN: - The WebP output is RGB with the transparency flattened to white """ png_path = tmp_path / "in.png" Image.new(mode, (20, 10), color).save(png_path) out_path = tmp_path / "out.webp" encode_thumbnail_webp(png_path, out_path) with Image.open(out_path) as im: assert im.format == "WEBP" assert im.mode == "RGB" assert im.size == (20, 10) red, green, blue = im.getpixel((10, 5)) assert min(red, green, blue) >= 250 @pytest.mark.parametrize( ("in_size", "supersample", "expected_size"), [ pytest.param((1000, 2000), 1, (500, 1000), id="too-wide-shrunk"), pytest.param((100, 10000), 1, (50, 5000), id="too-tall-shrunk"), pytest.param((100, 200), 1, (100, 200), id="small-not-enlarged"), pytest.param((1000, 1400), 2, (500, 700), id="2x-halved"), pytest.param((1001, 1401), 2, (500, 700), id="2x-odd-rounded"), pytest.param((600, 800), 2, (300, 400), id="2x-small-not-enlarged"), pytest.param((900, 1200), 2, (450, 600), id="2x-below-clamp"), ], ) def test_size_clamped_and_downsampled( self, tmp_path: Path, in_size: tuple[int, int], supersample: int, expected_size: tuple[int, int], ) -> None: """ GIVEN: - A rendered image and its supersample factor WHEN: - It is encoded as a thumbnail with that factor THEN: - It is downsampled, fit within 500x5000 and never enlarged """ png_path = tmp_path / "in.png" Image.new("RGB", in_size, (255, 255, 255)).save(png_path) out_path = tmp_path / "out.webp" encode_thumbnail_webp(png_path, out_path, supersample=supersample) with Image.open(out_path) as im: assert im.size == expected_size @pytest.mark.parametrize( "error", [ pytest.param(OSError("broken image"), id="os-error"), pytest.param(Image.DecompressionBombError("too large"), id="bomb"), ], ) def test_decode_failure_raises_parse_error( self, tmp_path: Path, mocker: MockerFixture, error: Exception, ) -> None: """ GIVEN: - Opening the rendered image fails WHEN: - It is encoded as a thumbnail THEN: - A ParseError is raised """ png_path = tmp_path / "in.png" Image.new("RGB", (10, 10)).save(png_path) mocker.patch("PIL.Image.open", side_effect=error) with pytest.raises(ParseError): encode_thumbnail_webp(png_path, tmp_path / "out.webp")