From 4c5927a45f2e677ab00dcf3e18f4a2cb7bc859da Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:09:59 -0700 Subject: [PATCH] Chore: Supersample the PDF thumbnail render 2x Render the PDF page at twice the target size and downsample, with edge case handling for small and odd sized pages, simplified helpers and tests, and trimmed comments. Co-Authored-By: Claude Sonnet 5.5 --- src/documents/parsers.py | 39 +++++++++----- src/documents/tests/test_parsers.py | 79 ++++++++++++++++++++++------- 2 files changed, 89 insertions(+), 29 deletions(-) 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