From 78981641f02286c2e042f06e0b03e93665e1de58 Mon Sep 17 00:00:00 2001 From: "James R. Barlow" Date: Mon, 12 Jun 2023 00:45:24 -0700 Subject: [PATCH] Introduce new functions to calculate DPI exceptions --- src/ocrmypdf/helpers.py | 16 +++++++-- src/ocrmypdf/pdfinfo/info.py | 65 +++++++++++++++++++++++++++++++++--- 2 files changed, 74 insertions(+), 7 deletions(-) diff --git a/src/ocrmypdf/helpers.py b/src/ocrmypdf/helpers.py index 8a8e0c40..180e5dd1 100644 --- a/src/ocrmypdf/helpers.py +++ b/src/ocrmypdf/helpers.py @@ -15,6 +15,7 @@ from contextlib import suppress from io import StringIO from math import isclose, isfinite from pathlib import Path +from statistics import harmonic_mean from typing import Any, Generic, Sequence, SupportsFloat, SupportsRound, TypeVar import img2pdf @@ -81,6 +82,17 @@ class Resolution(Generic[T]): return isfinite(self.x) and isfinite(self.y) return True + @property + def mean(self) -> float: + """Return the harmonic mean of x and y. + + The harmonic mean is used because it is the correct mean to use for + averaging rates, such as pixels per inch. If a calculation requires a single + value instead of a pair of values, the harmonic mean is the correct value to + use. + """ + return harmonic_mean([self.x, self.y]) + def take_max( self, vals: Iterable[Any], yvals: Iterable[Any] | None = None ) -> Resolution: @@ -103,11 +115,11 @@ class Resolution(Generic[T]): def __str__(self): """Return a string representation of the resolution.""" - return f"{self.x:f}x{self.y:f}" + return f"{self.x:f}×{self.y:f}" def __repr__(self): # pragma: no cover """Return a repr() of the resolution.""" - return f"Resolution({self.x}x{self.y} dpi)" + return f"Resolution({self.x}×{self.y} dpi)" def __eq__(self, other): """Return True if the resolution is equal to another resolution.""" diff --git a/src/ocrmypdf/pdfinfo/info.py b/src/ocrmypdf/pdfinfo/info.py index 4122317b..73cc07ee 100644 --- a/src/ocrmypdf/pdfinfo/info.py +++ b/src/ocrmypdf/pdfinfo/info.py @@ -420,12 +420,12 @@ class ImageInfo: return self._type @property - def width(self): + def width(self) -> int: """Width of the image in pixels.""" return self._width @property - def height(self): + def height(self) -> int: """Height of the image in pixels.""" return self._height @@ -458,17 +458,24 @@ class ImageInfo: return self.dpi.is_finite and self.width >= 0 and self.height >= 0 @property - def dpi(self): + def dpi(self) -> Resolution: """Dots per inch of the image. Calculated based on where and how the image is drawn in the PDF. """ return _get_dpi(self._shorthand, (self._width, self._height)) + @property + def printed_area(self) -> float: + """Physical area of the image in square inches.""" + if not self.renderable: + return 0.0 + return float(self.width * self.dpi.w * self.height * self.dpi.h) + def __repr__(self): """Return a string representation of the image.""" return ( - f"" ) @@ -747,12 +754,22 @@ def _pdf_pageinfo_concurrent( return pages +class PageResolutionInfo(NamedTuple): + """Information about the resolution of a page.""" + + average_to_max_dpi_ratio: float + """The average DPI of the page divided by the maximum DPI of the page.""" + + area_ratio: float + """The maximum DPI area of the page divided by the total drawn area.""" + + class PageInfo: """Information about type of contents on each page in a PDF.""" _has_text: bool | None _has_vector: bool | None - _images: list[ImageInfo] + _images: list[ImageInfo] = [] def __init__( self, @@ -939,6 +956,44 @@ class PageInfo: else: return '1.5' + def image_dpi_ratios(self) -> PageResolutionInfo | None: + """Return ratios useful for detecting high DPI images. + + This is useful to detect pages with a small proportion of high-resolution + content that is forcing us to use a high DPI for the whole page. The ratio + is weighted by the area of each image. If images overlap, the overlapped + area counts. + + Vector graphics and text are ignored. + + A ratio of 1.0 means that all images are the same DPI. + A large ratio indicates high DPI content. + A ratio of less than 1.0 is not possible. + + Returns None if there is no meaningful DPI for the page. + """ + image_dpis = [image.dpi.hypot for image in self._images if image.renderable] + image_areas = [image.printed_area for image in self._images if image.renderable] + total_drawn_area = sum(image_areas) + if total_drawn_area == 0: + return None + + weights = [area / total_drawn_area for area in image_areas] + # Calculate harmonic mean of DPIs weighted by area + # When the minimum version is Python 3.10, change this to + # statistics.harmonic_mean with the weights parameter + # rather than doing it manually. + weighted_dpi = sum(weights) / sum( + weight / dpi for weight, dpi in zip(weights, image_dpis) + ) + max_dpi = max(image_dpis) + dpi_average_max_ratio = weighted_dpi / max_dpi + + arg_max_dpi = image_dpis.index(max_dpi) + max_area_ratio = image_areas[arg_max_dpi] / total_drawn_area + + return PageResolutionInfo(dpi_average_max_ratio, max_area_ratio) + def __repr__(self): """Return string representation.""" return (