From 31c40207a83eeee2a84d8df29c6049c6574e8ddb Mon Sep 17 00:00:00 2001 From: stumpylog <797416+stumpylog@users.noreply.github.com> Date: Thu, 15 Oct 2026 09:05:33 -0700 Subject: [PATCH] Fix: use a fractional thumbnail DPI so oversized pages render a bounded raster --- src/documents/parsers.py | 20 ++++++++--------- src/documents/tests/test_parsers.py | 33 ++++++++++++++++++++--------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/src/documents/parsers.py b/src/documents/parsers.py index d6a08ca9f..6f5129274 100644 --- a/src/documents/parsers.py +++ b/src/documents/parsers.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import math import mimetypes import shutil import subprocess @@ -89,7 +88,7 @@ def rasterize_pdf_page_to_png( in_path: Path, out_path: Path, *, - dpi: int, + dpi: float, logging_group=None, ) -> None: """ @@ -156,10 +155,15 @@ def encode_thumbnail_webp( raise ParseError(f"Unable to encode thumbnail from {png_path}") from e -def _compute_thumbnail_dpi(in_path: Path, logging_group=None) -> tuple[int, int]: +def _compute_thumbnail_dpi(in_path: Path, logging_group=None) -> tuple[float, int]: """ Returns (dpi, supersample). Unknown geometry is not supersampled, since the render size cannot be bounded. + + The DPI is left fractional rather than floored at 1, so an oversized page + still renders at about the thumbnail size instead of an unbounded raster. + pdftoppm rounds pixel dimensions up, so the render is never short of the + target; the shrink-only clamp in encode_thumbnail_webp trims any excess. """ from paperless.parsers.utils import get_pdf_first_page_size_points @@ -174,14 +178,10 @@ def _compute_thumbnail_dpi(in_path: Path, logging_group=None) -> tuple[int, int] 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 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)), + return ( + min(_THUMBNAIL_MAX_DPI, dpi_for_width, dpi_for_height), + _THUMBNAIL_SUPERSAMPLE, ) - # 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( diff --git a/src/documents/tests/test_parsers.py b/src/documents/tests/test_parsers.py index 9b8b47908..cf27994c1 100644 --- a/src/documents/tests/test_parsers.py +++ b/src/documents/tests/test_parsers.py @@ -142,15 +142,23 @@ class TestComputeThumbnailDpi: @pytest.mark.parametrize( ("size", "expected"), [ - 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((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), - (1, 1), - id="huge-page-minimum-one-unsupersampled", + (500 * 72 / 1000000, 2), + id="huge-page-fractional", ), pytest.param(None, (150, 1), id="unreadable-geometry-fallback"), ], @@ -160,7 +168,7 @@ class TestComputeThumbnailDpi: mocker: MockerFixture, tmp_path: Path, size: tuple[float, float] | None, - expected: tuple[int, int], + expected: tuple[float, int], ) -> None: """ GIVEN: @@ -174,7 +182,7 @@ class TestComputeThumbnailDpi: "paperless.parsers.utils.get_pdf_first_page_size_points", return_value=size, ) - assert _compute_thumbnail_dpi(tmp_path / "doc.pdf") == expected + assert _compute_thumbnail_dpi(tmp_path / "doc.pdf") == pytest.approx(expected) class TestMakeThumbnailFromPdf: @@ -194,7 +202,12 @@ class TestMakeThumbnailFromPdf: @pytest.mark.parametrize( ("size", "expected_dpi", "expected_supersample"), [ - pytest.param((612.0, 792.0), 118, 2, id="known-geometry-2x"), + 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"), ], ) @@ -204,7 +217,7 @@ class TestMakeThumbnailFromPdf: tmp_path: Path, work_dir: Path, size: tuple[float, float] | None, - expected_dpi: int, + expected_dpi: float, expected_supersample: int, ) -> None: """ @@ -224,7 +237,7 @@ class TestMakeThumbnailFromPdf: make_thumbnail_from_pdf(tmp_path / "in.pdf", work_dir) - assert rasterize.call_args.kwargs["dpi"] == expected_dpi + assert rasterize.call_args.kwargs["dpi"] == pytest.approx(expected_dpi) assert encode.call_args.kwargs["supersample"] == expected_supersample @pytest.mark.parametrize(