diff --git a/src/documents/parsers.py b/src/documents/parsers.py index a0002cc5d..d6a08ca9f 100644 --- a/src/documents/parsers.py +++ b/src/documents/parsers.py @@ -79,8 +79,10 @@ _THUMBNAIL_MAX_WIDTH = 500 _THUMBNAIL_MAX_HEIGHT = 5000 # Used only when the page geometry cannot be read _THUMBNAIL_FALLBACK_DPI = 150 -# Matches the density the thumbnail was previously rendered at before scaling +# Applied before supersampling, so tiny pages are not enlarged _THUMBNAIL_MAX_DPI = 300 +# Rendering at a multiple and downsampling keeps text crisper +_THUMBNAIL_SUPERSAMPLE = 2 def rasterize_pdf_page_to_png( @@ -123,9 +125,11 @@ def rasterize_pdf_page_to_png( def encode_thumbnail_webp( png_path: Path, out_path: Path, + *, + supersample: int = 1, ) -> None: """ - Flattens alpha onto white, shrinks to fit and saves as WebP. + Flattens alpha onto white, undoes supersampling, shrinks to fit and saves as WebP. """ from PIL import Image @@ -137,16 +141,25 @@ def encode_thumbnail_webp( else: flattened = im.convert("RGB") + if supersample > 1: + flattened = flattened.resize( + ( + max(1, round(flattened.width / supersample)), + max(1, round(flattened.height / supersample)), + ), + Image.Resampling.LANCZOS, + ) + flattened.thumbnail((_THUMBNAIL_MAX_WIDTH, _THUMBNAIL_MAX_HEIGHT)) flattened.save(out_path, format="WEBP") except (OSError, Image.DecompressionBombError) as e: raise ParseError(f"Unable to encode thumbnail from {png_path}") from e -def _compute_thumbnail_dpi(in_path: Path, logging_group=None) -> int: +def _compute_thumbnail_dpi(in_path: Path, logging_group=None) -> tuple[int, int]: """ - Computes the DPI which renders the first page of the PDF at or just above - the thumbnail size in one pass, never above 300. + Returns (dpi, supersample). Unknown geometry is not supersampled, since + the render size cannot be bounded. """ from paperless.parsers.utils import get_pdf_first_page_size_points @@ -156,17 +169,19 @@ def _compute_thumbnail_dpi(in_path: Path, logging_group=None) -> int: "Could not read PDF page size, using fallback DPI", extra={"group": logging_group}, ) - return _THUMBNAIL_FALLBACK_DPI + return _THUMBNAIL_FALLBACK_DPI, 1 width_pts, height_pts = size dpi_for_width = _THUMBNAIL_MAX_WIDTH * 72 / width_pts dpi_for_height = _THUMBNAIL_MAX_HEIGHT * 72 / height_pts - # Round up so the render is never a few pixels short of the target; the - # shrink-only clamp in encode_thumbnail_webp trims the excess. - return max( + # Round up so the downsampled render is never a few pixels short of the + # target; the shrink-only clamp in encode_thumbnail_webp trims the excess. + dpi = max( 1, math.ceil(min(_THUMBNAIL_MAX_DPI, dpi_for_width, dpi_for_height)), ) + # At the 1 DPI floor the page is already oversized, so do not supersample + return dpi, 1 if dpi == 1 else _THUMBNAIL_SUPERSAMPLE def _render_pdf_thumbnail( @@ -175,14 +190,14 @@ def _render_pdf_thumbnail( out_path: Path, logging_group=None, ) -> None: - dpi = _compute_thumbnail_dpi(in_path, logging_group=logging_group) + dpi, supersample = _compute_thumbnail_dpi(in_path, logging_group=logging_group) rasterize_pdf_page_to_png( in_path, png_path, - dpi=dpi, + dpi=dpi * supersample, logging_group=logging_group, ) - encode_thumbnail_webp(png_path, out_path) + encode_thumbnail_webp(png_path, out_path, supersample=supersample) def _repair_pdf_with_qpdf(in_path: Path, out_path: Path) -> None: diff --git a/src/documents/tests/test_parsers.py b/src/documents/tests/test_parsers.py index 5ece78225..9b8b47908 100644 --- a/src/documents/tests/test_parsers.py +++ b/src/documents/tests/test_parsers.py @@ -142,13 +142,17 @@ class TestComputeThumbnailDpi: @pytest.mark.parametrize( ("size", "expected"), [ - pytest.param((612.0, 792.0), 59, id="letter-width-bound"), - pytest.param((792.0, 612.0), 46, id="landscape-rounded-up"), - pytest.param((612.0, 100000.0), 4, id="tall-strip-height-bound"), - pytest.param((200.0, 300.0), 180, id="small-page-width-bound"), - pytest.param((72.0, 72.0), 300, id="tiny-page-capped-at-300"), - pytest.param((1000000.0, 1000000.0), 1, id="huge-page-minimum-one"), - pytest.param(None, 150, id="unreadable-geometry-fallback"), + pytest.param((612.0, 792.0), (59, 2), id="letter-width-bound"), + pytest.param((792.0, 612.0), (46, 2), id="landscape-rounded-up"), + pytest.param((612.0, 100000.0), (4, 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), + (1, 1), + id="huge-page-minimum-one-unsupersampled", + ), + pytest.param(None, (150, 1), id="unreadable-geometry-fallback"), ], ) def test_dpi_from_page_size( @@ -156,7 +160,7 @@ class TestComputeThumbnailDpi: mocker: MockerFixture, tmp_path: Path, size: tuple[float, float] | None, - expected: int, + expected: tuple[int, int], ) -> None: """ GIVEN: @@ -164,7 +168,7 @@ class TestComputeThumbnailDpi: WHEN: - The thumbnail DPI is computed THEN: - - The expected DPI is returned + - The expected DPI and supersample factor are returned """ mocker.patch( "paperless.parsers.utils.get_pdf_first_page_size_points", @@ -187,6 +191,42 @@ class TestMakeThumbnailFromPdf: 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), 118, 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: int, + 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"] == expected_dpi + assert encode.call_args.kwargs["supersample"] == expected_supersample + @pytest.mark.parametrize( ("page_size", "expected_width"), [ @@ -405,32 +445,37 @@ class TestEncodeThumbnailWebp: assert min(red, green, blue) >= 250 @pytest.mark.parametrize( - ("in_size", "expected_size"), + ("in_size", "supersample", "expected_size"), [ - pytest.param((1000, 2000), (500, 1000), id="too-wide-shrunk"), - pytest.param((100, 10000), (50, 5000), id="too-tall-shrunk"), - pytest.param((100, 200), (100, 200), id="small-not-enlarged"), + 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 + - A rendered image and its supersample factor WHEN: - - It is encoded as a thumbnail + - It is encoded as a thumbnail with that factor THEN: - - It is fit within 500x5000 and never enlarged + - 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) + encode_thumbnail_webp(png_path, out_path, supersample=supersample) with Image.open(out_path) as im: assert im.size == expected_size