Compare commits

..
39 Commits
Author SHA1 Message Date
James R. Barlow 2261c51eff Reactivate pngquant on windows 2021-02-26 01:19:03 -08:00
James R. Barlow 5c470778a3 v11.7.0 release notes 2021-02-26 00:29:52 -08:00
James R. Barlow 4124889f36 Don't generate PDF/A-1b with object streams
Acrobat insists that PDF/A-1b should not have object streams.
Other programs like veraPDF disagree with this restriction, but
we can accommodate Acrobat so we will.

Also add more tests around this.
2021-02-26 00:23:57 -08:00
James R. Barlow a23c22b0e8 helpers: tidy check_pdf 2021-02-25 22:51:53 -08:00
James R. Barlow dd1f5f7215 pyproject: black doesn't like py39 yet 2021-02-25 16:10:20 -08:00
Dima KuznetsovandGitHub 5e2206bae7 Allow --sidecar along --pages (#735) 2021-02-19 16:55:35 -08:00
James R. Barlow 079ee86d43 pyproject: also target py39 2021-02-18 01:48:56 -08:00
James R. Barlow 3692868004 v11.6.2 release notes 2021-02-15 01:48:14 -08:00
James R. Barlow 064f935699 Fix page rotation regression
Page size fixes in commit b26749 did accounted for a "kept" rotation,
but not a corrected rotation.

Fixes #730.
2021-02-15 01:47:09 -08:00
James R. Barlow 8770fff968 tests: remove unreliable/incomplete test 2021-02-15 01:05:08 -08:00
James R. Barlow 82de78b6b0 v11.6.1 release notes 2021-02-14 01:51:26 -08:00
James R. Barlow 2a52c6dec2 optimize: skip images with unusually small dimensions
They're unlikely to be handled well by our recompressors. It seems
that JBIG2 cannot handle very small widths.

Fixes #732
2021-02-14 01:43:25 -08:00
James R. Barlow 2898879be7 docker-compose: fix typo 2021-02-14 01:43:06 -08:00
James R. Barlow a9ad805347 optimize: Remove shim for unsupported pikepdf version 2021-01-31 00:08:20 -08:00
James R. Barlow 46d0632fe2 v11.6.0 release notes 2021-01-26 01:47:49 -08:00
James R. Barlow ef1e7a814e Delinting 2021-01-26 01:45:04 -08:00
James R. Barlow 1084724937 docs: improve API docs 2021-01-26 01:40:40 -08:00
James R. Barlow ecb0109d79 docs: fix rst formatting error 2021-01-26 01:29:45 -08:00
James R. Barlow 504d5776d2 Refactor plugin manager to eliminate callback 2021-01-24 23:42:40 -08:00
James R. Barlow ee23976858 Re-sequence plugin installation 2021-01-24 23:42:28 -08:00
James R. Barlow f559316881 Insert setuptools plugins with ocrmypdf prefix 2021-01-24 23:42:09 -08:00
James R. Barlow 084610c242 Automate insertion of builtin modules 2021-01-24 23:41:50 -08:00
James R. Barlow 9ff627472b Update pre-commit 2021-01-24 23:40:59 -08:00
James R. Barlow 956310d1ec Import PageContext, PdfContext since they are referenced in pluginspec 2021-01-24 02:04:47 -08:00
James R. Barlow 1a982da442 tests: confirm that we produce pdf when optimization is off 2021-01-24 01:54:25 -08:00
James R. Barlow 4879a1f0de docs: no MS Store Python 2021-01-18 13:27:31 -08:00
James R. Barlow ce66bcc9c8 github: Ask how ocrmypdf was installed 2021-01-10 02:35:24 -08:00
James R. Barlow 1ebf3144af v11.5.0 release notes 2021-01-09 16:48:40 -08:00
James R. Barlow 7a1cccbc4e Fallback to LeptonicaErrorTrap_Redirect if ffi.callback fails
Might fix issue #709, Apple silicon support.
2021-01-09 16:47:11 -08:00
James R. Barlow ebacff1b39 tests: Fix debug logging test 2021-01-09 16:41:57 -08:00
James R. Barlow c7c447be66 Add test for configure_debug_logging
Since we can't directly test it
2021-01-09 16:02:12 -08:00
James R. Barlow 91aa175602 Consider text when determining page raster DPI
Previously if we found vectors of any sort on a page, we would bump
the DPI up to 400. We did nothing
about pages with text. As a result,
pages with a low image resolution
and printable text would have the text downgraded to image
resolution when --force-ocr was used.

We don't try to determine if the text is visible or invisible OCR text, since
that is a slower test. --redo-ocr would improve such cases anyway.
2021-01-09 16:01:49 -08:00
James R. Barlow b267494e4a Create raster PDF pages to match input page size
Previously we produced a raster image, then multiplied image width
by DPI to get the page size. However if there is rounding the
page size may not match exactly. In this modified approach we
constrain the page size to match.
2021-01-08 15:10:43 -08:00
James R. Barlow f687180ecc tests: tidy pdfinfo 2021-01-08 15:04:52 -08:00
James R. Barlow 6f4b38b103 ghostscript: tidy comments 2021-01-08 00:41:03 -08:00
James R. Barlow d32324859c v11.4.5 release notes 2021-01-06 11:42:28 -08:00
James R. Barlow 48222b87b5 Merge branch 'master' of github.com:jbarlow83/OCRmyPDF 2021-01-06 03:59:40 -08:00
62e5edc72b fix unclosed file warnings. (#710)
Co-authored-by: Jonas Winkler <jonas.winkler@jpwinkler.de>
2021-01-06 03:59:28 -08:00
James R. Barlow 2846d46bb8 Remove .coveragerc and fold into setup.cfg 2021-01-06 03:58:18 -08:00
35 changed files with 784 additions and 234 deletions
-24
View File
@@ -1,24 +0,0 @@
[paths]
source =
src
*/site-packages
[run]
branch = true
parallel = true
concurrency =
thread
multiprocessing
source =
src/ocrmypdf
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if 0:
if False:
if __name__ == .__main__.:
if TYPE_CHECKING:
+5 -1
View File
@@ -21,8 +21,12 @@ If applicable, add screenshots to help explain your problem.
**System (please complete the following information):**
- OS:
- Python version:
- Python version:
- OCRmyPDF version:
**Installation**
How did you install OCRmyPDF? Did you install it from your operating system's
package manager, or using pip?
**Additional context**
Add any other context about the problem here.
+3 -3
View File
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.1.0
rev: v3.4.0
hooks:
- id: check-case-conflict
- id: check-merge-conflict
@@ -12,11 +12,11 @@ repos:
hooks:
- id: seed-isort-config
- repo: https://github.com/pre-commit/mirrors-isort
rev: v5.0.5 # pick the isort version you'd like to use from https://github.com/pre-commit/mirrors-isort/releases
rev: v5.7.0 # pick the isort version you'd like to use from https://github.com/pre-commit/mirrors-isort/releases
hooks:
- id: isort
- repo: https://github.com/psf/black
rev: 19.10b0
rev: 20.8b1
hooks:
- id: black
language_version: python
+1 -1
View File
@@ -32,7 +32,7 @@ stages:
choco install --yes --no-progress --pre tesseract
choco install --yes --no-progress python3
choco install --yes --no-progress ghostscript
# choco install --yes --no-progress pngquant
choco install --yes --no-progress pngquant
displayName: "Install system packages"
- pwsh: |
refreshenv
+12
View File
@@ -5,6 +5,15 @@ API Reference
This page summarizes the rest of the public API. Generally speaking this
should mainly of interest to plugin developers.
ocrmypdf
========
.. autoclass:: ocrmypdf.PageContext
:members:
.. autoclass:: ocrmypdf.PdfContext
:members:
ocrmypdf.exceptions
===================
@@ -17,6 +26,9 @@ ocrmypdf.helpers
.. automodule:: ocrmypdf.helpers
:members:
:noindex: deprecated
.. autodecorator:: deprecated
ocrmypdf.hocrtransform
======================
+7 -1
View File
@@ -520,7 +520,7 @@ DLLs or other Windows patches, and may require a reboot.
You may then use ``pip`` to install ocrmypdf. (This can performed by a user or
Administrator.):
* ``pip install ocrmypdf
* ``pip install ocrmypdf``
Chocolatey automatically selects appropriate versions of these applications. If you
are installing them manually, please install 64-bit versions of all applications for
@@ -533,6 +533,12 @@ override the versions OCRmyPDF selects, you can modify the ``PATH`` environment
variable. `Follow these directions <https://www.computerhope.com/issues/ch000549.htm#dospath>`_
to change the PATH.
.. warning::
As of early 2021, users have reported problems with the Microsoft Store version of
Python affected most third party Python packages including OCRmyPDF. Please use
Python downloaded from Python.org or Chocolatey as recommended here.
Windows Subsystem for Linux
---------------------------
+21
View File
@@ -65,6 +65,27 @@ similar to ``pytest`` packages such as ``pytest-cov`` (the package) and
``ocrmypdf-`` (for the package name on PyPI) and ``ocrmypdf_`` (for the
module), just like pytest plugins.
Setuptools plugins
==================
You can also create a plugin that OCRmyPDF will always automatically load if both are
installed in the same virtual environment, using a setuptools entrypoint.
Your package's ``setup.py`` would need to contain the following, for a plugin
named ``ocrmypdf-exampleplugin``:
.. code-block:: python
# sample ./setup.py file
from setuptools import setup
setup(
name="ocrmypdf-exampleplugin",
packages=["exampleplugin"],
# the following makes a plugin available to pytest
entry_points={"ocrmypdf": ["exampleplugin = exampleplugin.pluginmodule"]},
)
Plugin requirements
===================
+55
View File
@@ -12,6 +12,61 @@ may be unreliable. Use the API to depend on precise behavior.
The public API may be useful in scripts that launch OCRmyPDF processes or that
wish to use some of its features for working with PDFs.
v11.7.0
=======
- We now support using ``--sidecar`` in conjunction with ``--pages``; these arguments
used to be mutually exclusive. (#735)
- Fixed a possible issue with PDF/A-1b generation. Acrobat complained that our PDFs use
object streams. More robust PDF/A validators like veraPDF don't consider this a
problem, but we'll honor Acrobat's objection from here on. This may increase file
size of PDF/A-1b files. PDF/A-2b files will not be affected.
v11.6.2
=======
- Fixed a regression where the wrong page orientation would be produced when using
arguments such as ``--deskew --rotate-pages`` (#730).
v11.6.1
=======
- Fixed an issue with attempting optimize unusually narrow-width images by excluding
these images from optimization (#732).
- Remove an obsolete compatibility shim for a version of pikepdf that is no longer
supported.
v11.6.0
=======
- OCRmyPDF will now automatically register plugins from the same virtual environment
with an appropriate setuptools entrypoint.
- Refactor the plugin manager to remove unnecessary complications and make plugin
registration more automatic.
- ``PageContext`` and ``PdfContext`` are now formally part of the API, as they
should have been, since they were part of ``ocrmypdf.pluginspec``.
v11.5.0
=======
- Fixed an issue where the output page size might differ by a fractional amount
due to rounding, when ``--force-ocr`` was used and the page contained objects
with multiple resolutions.
- When determining the resolution at which to rasterize a page, we now consider
printed text on the page as requiring a higher resolution. This fixes issues
with certain pages being rendered with unacceptably low resolution text, but
may increase output file sizes in some workflows where low resolution text
is acceptable.
- Added a workaround to fix an exception that occurs when trying to
``import ocrmypdf.leptonica`` on Apple ARM silicon (or potentially, other
platforms that do not permit write+executable memory).
v11.4.5
=======
- Fixed an issue where files may not be closed when the API is used.
- Improved ``setup.cfg`` with better settings for test coverage.
v11.4.4
=======
+1 -1
View File
@@ -9,7 +9,7 @@ services:
- "/media/scan:/input"
- "/mnt/scan:/output"
environment:
- OCR_OUTPUT_DIRECTORY_YEAR_MONT=0
- OCR_OUTPUT_DIRECTORY_YEAR_MONTH=0
user: "<SET TO YOUR USER ID>:<SET TO YOUR GROUP ID>"
entrypoint: python3
command: watcher.py
+29
View File
@@ -15,6 +15,8 @@ filterwarnings =
ignore:.*XMLParser.*:DeprecationWarning
markers =
slow
addopts =
-n auto
[isort]
multi_line_output=3
@@ -27,3 +29,30 @@ known_third_party = PIL,_cffi_backend,cffi,flask,img2pdf,pdfminer,pikepdf,pkg_re
[metadata]
license_file = LICENSE
[coverage:paths]
source =
src/
[coverage:run]
branch = true
parallel = true
concurrency = multiprocessing
source =
src/ocrmypdf
[coverage:report]
# Regexes for lines to exclude from consideration
exclude_lines =
# Have to re-enable the standard pragma
pragma: no cover
# Don't complain if tests don't hit defensive assertion code:
raise AssertionError
raise NotImplementedError
# Don't complain if non-runnable code isn't run:
if 0:
if False:
if __name__ == .__main__.:
if TYPE_CHECKING:
+1
View File
@@ -8,6 +8,7 @@
from pluggy import HookimplMarker as _HookimplMarker
from ocrmypdf import helpers, hocrtransform, leptonica, pdfa, pdfinfo
from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._version import PROGRAM_NAME, __version__
from ocrmypdf.api import Verbosity, configure_logging, ocr
from ocrmypdf.exceptions import (
+3 -5
View File
@@ -15,7 +15,7 @@ from os import fspath
from pathlib import Path
from shutil import which
from subprocess import PIPE, CalledProcessError
from typing import Optional, cast
from typing import Optional
from PIL import Image
@@ -56,16 +56,14 @@ def version():
def jpeg_passthrough_available() -> bool:
"""Returns True if the installed version of Ghostscript supports JPEG passthru
Prior to 9.23, Ghostscript decode and re-encoded JPEGs internally. In 9.23
Prior to 9.23, Ghostscript decoded and re-encoded JPEGs internally. In 9.23
it gained the ability to keep JPEGs unmodified. However, the 9.23
implementation was buggy and would deletes the last two bytes of images in
some cases, as reported here.
https://bugs.ghostscript.com/show_bug.cgi?id=699216
The issue was fixed for 9.24, hence that is the first version we consider
the feature available. (However, we don't use 9.24 at all, so the first
version that allows JPEG passthrough is 9.25.
the feature available. (Ghostscript 9.24 has its own problems is blacklisted.)
"""
return version() >= '9.24'
-2
View File
@@ -9,10 +9,8 @@
from contextlib import contextmanager
from io import BytesIO
from os import fspath
from pathlib import Path
from subprocess import PIPE
from tempfile import NamedTemporaryFile
from PIL import Image
+28 -4
View File
@@ -10,15 +10,22 @@ import shutil
import sys
from argparse import Namespace
from copy import copy
from io import IOBase
from pathlib import Path
from typing import Iterator
from pluggy import PluginManager
from ocrmypdf.pdfinfo import PdfInfo
from ocrmypdf.pdfinfo.info import PageInfo
class PdfContext:
"""Holds our context for a particular run of the pipeline"""
"""Holds the context for a particular run of the pipeline."""
options: Namespace #: The specified options for processing this PDF.
origin: Path #: The filename of the original input file.
pdfinfo: PdfInfo #: Detailed data for this PDF.
plugin_manager: PluginManager #: PluginManager for processing the current PDF.
def __init__(
self,
@@ -35,21 +42,33 @@ class PdfContext:
self.plugin_manager = plugin_manager
def get_path(self, name: str) -> Path:
"""Generate a ``Path`` for an intermediate file involved in processing.
The path will be in a temporary folder that is common for all processing
of this particular PDF.
"""
return self.work_folder / name
def get_page_contexts(self) -> Iterator['PageContext']:
"""Get all ``PageContext`` for this PDF."""
npages = len(self.pdfinfo)
for n in range(npages):
yield PageContext(self, n)
class PageContext:
"""Holds our context for a page
"""Holds our context for a page.
Must be pickable, so stores only intrinsic/simple data elements or those
capable of their serializing themselves via __getstate__.
capable of their serializing themselves via ``__getstate__``.
"""
options: Namespace #: The specified options for processing this PDF.
origin: Path #: The filename of the original input file.
pageno: int #: This page number (zero-based).
pageinfo: PageInfo #: Information on this page.
plugin_manager: PluginManager #: PluginManager for processing the current PDF.
def __init__(self, pdf_context: PdfContext, pageno):
self.work_folder = pdf_context.work_folder
self.origin = pdf_context.origin
@@ -59,6 +78,11 @@ class PageContext:
self.plugin_manager = pdf_context.plugin_manager
def get_path(self, name: str) -> Path:
"""Generate a ``Path`` for a file that is part of processing this page.
The path will be based in a common temporary folder and have a prefix based
on the page number.
"""
return self.work_folder / ("%06d_%s" % (self.pageno + 1, name))
def __getstate__(self):
+63 -16
View File
@@ -206,17 +206,21 @@ def validate_pdfinfo_options(context: PdfContext):
context.plugin_manager.hook.validate(pdfinfo=pdfinfo, options=options)
def _vector_page_dpi(pageinfo):
return VECTOR_PAGE_DPI if pageinfo.has_vector or pageinfo.has_text else 0.0
def get_page_dpi(pageinfo, options):
"Get the DPI when nonsquare DPI is tolerable"
xres = max(
pageinfo.dpi.x or VECTOR_PAGE_DPI,
options.oversample or 0.0,
VECTOR_PAGE_DPI if pageinfo.has_vector else 0.0,
_vector_page_dpi(pageinfo),
)
yres = max(
pageinfo.dpi.y or VECTOR_PAGE_DPI,
options.oversample or 0,
VECTOR_PAGE_DPI if pageinfo.has_vector else 0.0,
_vector_page_dpi(pageinfo),
)
return Resolution(float(xres), float(yres))
@@ -230,7 +234,7 @@ def get_page_square_dpi(pageinfo, options) -> Resolution:
max(
(xres * userunit) or VECTOR_PAGE_DPI,
(yres * userunit) or VECTOR_PAGE_DPI,
VECTOR_PAGE_DPI if pageinfo.has_vector else 0.0,
_vector_page_dpi(pageinfo),
options.oversample or 0.0,
)
)
@@ -243,7 +247,7 @@ def get_canvas_square_dpi(pageinfo, options) -> Resolution:
max(
(pageinfo.dpi.x) or VECTOR_PAGE_DPI,
(pageinfo.dpi.y) or VECTOR_PAGE_DPI,
VECTOR_PAGE_DPI if pageinfo.has_vector else 0.0,
_vector_page_dpi(pageinfo),
options.oversample or 0.0,
)
)
@@ -580,7 +584,9 @@ def create_visible_page_jpg(image: Path, page_context: PageContext) -> Path:
return output_file
def create_pdf_page_from_image(image: Path, page_context: PageContext):
def create_pdf_page_from_image(
image: Path, page_context: PageContext, orientation_correction
):
# We rasterize a square DPI version of each page because most image
# processing tools don't support rectangular DPI. Use the square DPI as it
# accurately describes the image. It would be possible to resample the image
@@ -588,16 +594,23 @@ def create_pdf_page_from_image(image: Path, page_context: PageContext):
# except that the hocr renderer does not understand non-square DPI. The
# sandwich renderer would be fine.
output_file = page_context.get_path('visible.pdf')
dpi = get_page_square_dpi(page_context.pageinfo, page_context.options)
layout_fun = img2pdf.get_fixed_dpi_layout_fun(dpi)
pageinfo = page_context.pageinfo
pagesize = 72.0 * float(pageinfo.width_inches), 72.0 * float(pageinfo.height_inches)
effective_rotation = (pageinfo.rotation - orientation_correction) % 360
if effective_rotation % 180 == 90:
pagesize = pagesize[1], pagesize[0]
# This create a single page PDF
with open(image, 'rb') as imfile, open(output_file, 'wb') as pdf:
log.debug('convert')
layout_fun = img2pdf.get_layout_fun(pagesize)
img2pdf.convert(
imfile, with_pdfrw=False, layout_fun=layout_fun, outputstream=pdf
)
log.debug('convert done')
return output_file
@@ -726,6 +739,24 @@ def should_linearize(working_file: Path, context: PdfContext):
return False
def get_pdf_save_settings(output_type: str):
if output_type == 'pdfa-1':
# Trigger recompression to ensure object streams are removed, because
# Acrobat complains about them in PDF/A-1b validation.
return dict(
preserve_pdfa=True,
compress_streams=True,
stream_decode_level=pikepdf.StreamDecodeLevel.generalized,
object_stream_mode=pikepdf.ObjectStreamMode.disable,
)
else:
return dict(
preserve_pdfa=True,
compress_streams=True,
object_stream_mode=(pikepdf.ObjectStreamMode.generate),
)
def metadata_fixup(working_file: Path, context: PdfContext):
output_file = context.get_path('metafix.pdf')
options = context.options
@@ -770,9 +801,7 @@ def metadata_fixup(working_file: Path, context: PdfContext):
pdf.save(
output_file,
compress_streams=True,
preserve_pdfa=True,
object_stream_mode=pikepdf.ObjectStreamMode.generate,
**get_pdf_save_settings(options.output_type),
linearize=( # Don't linearize if optimize() will be linearizing too
should_linearize(working_file, context)
if options.optimize == 0
@@ -786,20 +815,34 @@ def metadata_fixup(working_file: Path, context: PdfContext):
def optimize_pdf(input_file: Path, context: PdfContext):
output_file = context.get_path('optimize.pdf')
save_settings = dict(
compress_streams=True,
preserve_pdfa=True,
object_stream_mode=pikepdf.ObjectStreamMode.generate,
linearize=should_linearize(input_file, context),
**get_pdf_save_settings(context.options.output_type),
)
optimize(input_file, output_file, context, save_settings)
return output_file
def enumerate_compress_ranges(iterable):
skipped_from = None
for index, txt_file in enumerate(iterable):
index += 1
if txt_file:
if skipped_from is not None:
yield (skipped_from, index - 1), None
skipped_from = None
yield (index, index), txt_file
else:
if skipped_from is None:
skipped_from = index
if skipped_from is not None:
yield (skipped_from, index), None
def merge_sidecars(txt_files: Iterable[Optional[Path]], context: PdfContext):
output_file = context.get_path('sidecar.txt')
with open(output_file, 'w', encoding="utf-8") as stream:
for page_num, txt_file in enumerate(txt_files):
if page_num != 0:
for (frm, to), txt_file in enumerate_compress_ranges(txt_files):
if frm != 1:
stream.write('\f') # Form feed between pages
if txt_file:
with open(txt_file, 'r', encoding="utf-8") as in_:
@@ -812,7 +855,11 @@ def merge_sidecars(txt_files: Iterable[Optional[Path]], context: PdfContext):
else:
stream.write(txt)
else:
stream.write(f'[OCR skipped on page {(page_num + 1)}]')
if frm != to:
pages = f'{frm}-{to}'
else:
pages = f'{frm}'
stream.write(f'[OCR skipped on page(s) {pages}]')
return output_file
+47 -39
View File
@@ -8,13 +8,14 @@
import argparse
import importlib
import importlib.util
import pkgutil
import sys
from functools import partial
from pathlib import Path
from typing import Callable, List, Tuple, Union
from typing import List, Tuple, Union
import pluggy
import ocrmypdf.builtin_plugins
from ocrmypdf import pluginspec
from ocrmypdf.cli import get_parser, plugins_only_parser
@@ -30,62 +31,69 @@ class OcrmypdfPluginManager(pluggy.PluginManager):
"""
def __init__(
self, *args, setup_func: Callable[[pluggy.PluginManager], None], **kwargs
self,
*args,
plugins: List[Union[str, Path]],
builtins: bool = True,
**kwargs,
):
self._init_args = args
self._setup_func = setup_func
self._init_kwargs = kwargs
self.__init_args = args
self.__init_kwargs = kwargs
self.__plugins = plugins
self.__builtins = builtins
super().__init__(*args, **kwargs)
setup_func(self)
self.setup_plugins()
def __getstate__(self):
state = dict(
_init_args=self._init_args,
_setup_func=self._setup_func,
_init_kwargs=self._init_kwargs,
init_args=self.__init_args,
plugins=self.__plugins,
builtins=self.__builtins,
init_kwargs=self.__init_kwargs,
)
return state
def __setstate__(self, state):
self.__init__(
*state['_init_args'],
setup_func=state['_setup_func'],
**state['_init_kwargs'],
*state['init_args'],
plugins=state['plugins'],
builtins=state['builtins'],
**state['init_kwargs'],
)
def setup_plugins(self):
self.add_hookspecs(pluginspec)
def _setup_plugins(
pm: pluggy.PluginManager, plugins: List[Union[str, Path]], builtins: bool = True
):
pm.add_hookspecs(pluginspec)
# 1. Register builtins
if self.__builtins:
for module in pkgutil.iter_modules(ocrmypdf.builtin_plugins.__path__):
name = f'ocrmypdf.builtin_plugins.{module.name}'
module = importlib.import_module(name)
self.register(module)
all_plugins: List[Union[str, Path]] = []
if builtins:
all_plugins.extend(
[
'ocrmypdf.builtin_plugins.ghostscript',
'ocrmypdf.builtin_plugins.tesseract_ocr',
]
)
all_plugins.extend(plugins)
for name in all_plugins:
if isinstance(name, Path) or name.endswith('.py'):
# Import by filename
module_name = Path(name).stem
spec = importlib.util.spec_from_file_location(module_name, name)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
else:
# Import by dotted module name
module = importlib.import_module(name)
pm.register(module)
# 2. Register setuptools plugins
self.load_setuptools_entrypoints('ocrmypdf')
# 3. Register plugins specified on command line
for name in self.__plugins:
if isinstance(name, Path) or name.endswith('.py'):
# Import by filename
module_name = Path(name).stem
spec = importlib.util.spec_from_file_location(module_name, name)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
else:
# Import by dotted module name
module = importlib.import_module(name)
self.register(module)
def get_plugin_manager(plugins: List[Union[str, Path]], builtins=True):
pm = OcrmypdfPluginManager(
project_name='ocrmypdf',
setup_func=partial(_setup_plugins, plugins=plugins, builtins=builtins),
plugins=plugins,
builtins=builtins,
)
return pm
+7 -3
View File
@@ -204,7 +204,7 @@ def exec_page_sync(page_context: PageContext):
if filtered_image:
visible_image_out = filtered_image
pdf_page_from_image_out = create_pdf_page_from_image(
visible_image_out, page_context
visible_image_out, page_context, orientation_correction
)
if options.pdf_renderer.startswith('hocr'):
@@ -302,7 +302,7 @@ def exec_concurrent(context: PdfContext):
copy_final(pdf, options.output_file, context)
def configure_debug_logging(log_filename, prefix: str = ''):
def configure_debug_logging(log_filename: Path, prefix: str = ''):
"""
Create a debug log file at a specified location.
@@ -338,7 +338,11 @@ def run_pipeline(options, *, plugin_manager, api=False):
and not api
):
# Debug log for command line interface only with verbose output
debug_log_handler = configure_debug_logging(Path(work_folder) / "debug.log")
# See https://github.com/pytest-dev/pytest/issues/5502 for why we skip this
# when pytest is running
debug_log_handler = configure_debug_logging(
Path(work_folder) / "debug.log"
) # pragma: no cover
pikepdf_enable_mmap()
-2
View File
@@ -184,8 +184,6 @@ def check_options_ocr_behavior(options):
)
if exclusive_options >= 2:
raise BadArgsError("Choose only one of --force-ocr, --skip-text, --redo-ocr.")
if options.pages and options.sidecar:
raise BadArgsError("--pages and --sidecar are mutually exclusive")
if options.pages:
options.pages = _pages_from_ranges(options.pages)
+43 -40
View File
@@ -17,7 +17,7 @@ from functools import wraps
from io import StringIO
from math import isclose
from pathlib import Path
from typing import Any, Sequence, TypeVar
from typing import Any, Sequence
import pikepdf
@@ -63,8 +63,13 @@ class NeverRaise(Exception):
def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike):
"""
Helper function: relinks soft symbolic link if necessary
"""Create a symbolic link at ``soft_link_name``, which references ``input_file``.
Think of this as copying ``input_file`` to ``soft_link_name`` with less overhead.
Use symlinks safely. Self-linking loops are prevented. On Windows, file copy is
used since symlinks may require administrator privileges. An existing link at the
destination is removed.
"""
input_file = os.fspath(input_file)
soft_link_name = os.fspath(soft_link_name)
@@ -72,8 +77,8 @@ def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike):
# Guard against soft linking to oneself
if input_file == soft_link_name:
log.warning(
"No symbolic link made. You are using "
"the original data directory as the working directory."
"No symbolic link created. You are using the original data directory "
"as the working directory."
)
return
@@ -114,7 +119,7 @@ def is_iterable_notstr(thing: Any) -> bool:
def monotonic(L: Sequence) -> bool:
"""Does list increase monotonically?"""
"""Does this sequence increase monotonically?"""
return all(b > a for a, b in zip(L, L[1:]))
@@ -173,51 +178,49 @@ def is_file_writable(test_file: os.PathLike) -> bool:
def check_pdf(input_file: Path) -> bool:
"""Check if a PDF complies with the PDF specification.
Checks for proper formatting and proper linearization.
Checks for proper formatting and proper linearization. Uses pikepdf (which in
turn, uses QPDF) to perform the checks.
"""
pdf = None
try:
pdf = pikepdf.open(input_file)
except pikepdf.PdfError as e:
log.error(e)
return False
else:
messages = pdf.check()
for msg in messages:
if 'error' in msg.lower():
log.error(msg)
with pdf:
messages = pdf.check()
for msg in messages:
if 'error' in msg.lower():
log.error(msg)
else:
log.warning(msg)
sio = StringIO()
linearize_msgs = ''
try:
# If linearization is missing entirely, we do not complain. We do
# complain if linearization is present but incorrect.
pdf.check_linearization(sio)
except RuntimeError:
pass
except ( # Workaround for a problematic pikepdf version
getattr(pikepdf, 'ForeignObjectError')
if pikepdf.__version__ == '2.1.0'
else NeverRaise
):
pass
else:
log.warning(msg)
linearize_msgs = sio.getvalue()
if linearize_msgs:
log.warning(linearize_msgs)
sio = StringIO()
linearize_msgs = ''
try:
# If linearization is missing entirely, we do not complain. We do
# complain if linearization is present but incorrect.
pdf.check_linearization(sio)
except RuntimeError:
pass
except (
getattr(pikepdf, 'ForeignObjectError')
if pikepdf.__version__ == '2.1.0' # This version may throw wrong exception
else NeverRaise
):
pass
else:
linearize_msgs = sio.getvalue()
if linearize_msgs:
log.warning(linearize_msgs)
if not messages and not linearize_msgs:
return True
return False
finally:
if pdf:
pdf.close()
if not messages and not linearize_msgs:
return True
return False
def clamp(n, smallest, largest): # mypy doesn't understand types for this
"""Clamps the value of n to between smallest and largest."""
"""Clamps the value of ``n`` to between ``smallest`` and ``largest``."""
return max(smallest, min(n, largest))
@@ -235,7 +238,7 @@ def pikepdf_enable_mmap():
def deprecated(func):
"""Warn that function is deprecated"""
"""Warn that function is deprecated."""
@wraps(func)
def new_func(*args, **kwargs):
+9 -3
View File
@@ -31,11 +31,10 @@
import argparse
import os
import re
from collections import namedtuple
from itertools import chain
from math import atan, cos, sin
from pathlib import Path
from typing import Optional, Tuple, Union
from typing import Any, NamedTuple, Optional, Tuple, Union
from xml.etree import ElementTree
from reportlab.lib.colors import black, cyan, magenta, red
@@ -44,7 +43,14 @@ from reportlab.pdfgen.canvas import Canvas
Element = ElementTree.Element
Rect = namedtuple('Rect', ['x1', 'y1', 'x2', 'y2'])
class Rect(NamedTuple): # pylint: disable=inherit-non-class
"""A rectangle for managing PDF coordinates."""
x1: Any
y1: Any
x2: Any
y2: Any
class HocrTransformError(Exception):
+17 -15
View File
@@ -170,20 +170,6 @@ tls = threading.local()
tls.trap = None
@ffi.callback("void(char *)")
def _stderr_handler(cstr):
msg = ffi.string(cstr).decode(errors='replace')
if msg.startswith("Error"):
logger.error(msg)
elif msg.startswith("Warning"):
logger.warning(msg)
else:
logger.debug(msg)
if tls.trap is not None:
tls.trap.append(msg)
return
class _LeptonicaErrorTrap_Queue:
def __init__(self):
self.queue = deque()
@@ -213,9 +199,25 @@ class _LeptonicaErrorTrap_Queue:
try:
@ffi.callback("void(char *)")
def _stderr_handler(cstr):
msg = ffi.string(cstr).decode(errors='replace')
if msg.startswith("Error"):
logger.error(msg)
elif msg.startswith("Warning"):
logger.warning(msg)
else:
logger.debug(msg)
if tls.trap is not None:
tls.trap.append(msg)
return
lept.leptSetStderrHandler(_stderr_handler)
except ffi.error:
except (ffi.error, MemoryError):
# Pre-1.79 Leptonica does not have leptSetStderrHandler
# And some platforms, notably Apple ARM 64, do not allow the write+execute
# memory needed to set up the callback function.
_LeptonicaErrorTrap = _LeptonicaErrorTrap_Redirect
else:
# 1.79 have this new symbol
+12 -13
View File
@@ -10,11 +10,9 @@ import sys
import tempfile
from collections import defaultdict
from functools import partial
from io import BytesIO
from os import fspath
from pathlib import Path
from typing import (
Any,
Callable,
Dict,
Iterator,
@@ -49,7 +47,7 @@ DEFAULT_PNG_QUALITY = 70
Xref = NewType('Xref', int)
class XrefExt(NamedTuple):
class XrefExt(NamedTuple): # pylint: disable=inherit-non-class
xref: Xref
ext: str
@@ -78,6 +76,9 @@ def extract_image_filter(
if image.Length < 100:
log.debug(f"Skipping small image, xref {xref}")
return None
if image.Width < 8 or image.Height < 8: # Issue 732
log.debug(f"Skipping oddly sized image, xref {xref}")
return None
pim = PdfImage(image)
@@ -152,11 +153,6 @@ def extract_image_generic(
if pim.bits_per_component == 1:
return None
try:
pim.indexed # pikepdf 1.6.3 can't handle [/Indexed [/Array...]]
except NotImplementedError:
return None
if filtdp[0] == Name.DCTDecode and options.optimize >= 2:
# This is a simple heuristic derived from some training data, that has
# about a 70% chance of guessing whether the JPEG is high quality,
@@ -212,7 +208,10 @@ def extract_image_generic(
def extract_images(
pike: Pdf, root: Path, options, extract_fn: Callable[..., Optional[XrefExt]],
pike: Pdf,
root: Path,
options,
extract_fn: Callable[..., Optional[XrefExt]],
) -> Iterator[Tuple[int, XrefExt]]:
"""Extract image using extract_fn
@@ -498,7 +497,7 @@ def transcode_pngs(
@deprecated
def rewrite_png_as_g4(pike: Pdf, im_obj: Object, compdata) -> None:
def rewrite_png_as_g4(pike: Pdf, im_obj: Object, compdata) -> None: # pragma: no cover
im_obj.BitsPerComponent = 1
im_obj.Width = compdata.w
im_obj.Height = compdata.h
@@ -519,7 +518,7 @@ def rewrite_png_as_g4(pike: Pdf, im_obj: Object, compdata) -> None:
@deprecated
def rewrite_png(pike: Pdf, im_obj: Object, compdata) -> None:
def rewrite_png(pike: Pdf, im_obj: Object, compdata) -> None: # pragma: no cover
# When a PNG is inserted into a PDF, we more or less copy the IDAT section from
# the PDF and transfer the rest of the PNG headers to PDF image metadata.
# One thing we have to do is tell the PDF reader whether a predictor was used
@@ -564,10 +563,10 @@ def rewrite_png(pike: Pdf, im_obj: Object, compdata) -> None:
# ncolors == 0 means we are using a colorspace without a palette
if compdata.spp == 1:
cs = Name.DeviceGray
elif compdata.spp == 3:
cs = Name.DeviceRGB
elif compdata.spp == 4:
cs = Name.DeviceCMYK
else: # spp == 3
cs = Name.DeviceRGB
im_obj.ColorSpace = cs
im_obj.write(compdata.read(), filter=Name.FlateDecode, decode_parms=dparms)
+1 -1
View File
@@ -112,7 +112,7 @@ def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'):
def file_claims_pdfa(filename: Path):
"""Determines if the file claims to be PDF/A compliant
"""Determines if the file claims to be PDF/A compliant.
This only checks if the XMP metadata contains a PDF/A marker. It does not
do full PDF/A validation.
+1 -1
View File
@@ -15,7 +15,7 @@ from functools import partial
from math import hypot, isclose
from os import PathLike
from pathlib import Path
from typing import Any, Container, Dict, Iterator, List, Optional, Tuple, Union
from typing import Container, Iterator, Optional, Tuple, Union
from warnings import warn
import pikepdf
+12 -13
View File
@@ -77,20 +77,19 @@ def run_polling_stderr(args, *, callback, check=False, env=None, **kwargs):
args, env, process_log, text = _fix_process_args(args, env, kwargs)
assert text, "Must use text=True"
proc = Popen(args, env=env, **kwargs)
with Popen(args, env=env, **kwargs) as proc:
lines = []
while proc.poll() is None:
for msg in iter(proc.stderr.readline, ''):
if process_log.isEnabledFor(logging.DEBUG):
process_log.debug(msg.strip())
callback(msg)
lines.append(msg)
stderr = ''.join(lines)
lines = []
while proc.poll() is None:
for msg in iter(proc.stderr.readline, ''):
if process_log.isEnabledFor(logging.DEBUG):
process_log.debug(msg.strip())
callback(msg)
lines.append(msg)
stderr = ''.join(lines)
if check and proc.returncode != 0:
raise CalledProcessError(proc.returncode, args, output=None, stderr=stderr)
return CompletedProcess(args, proc.returncode, None, stderr=stderr)
if check and proc.returncode != 0:
raise CalledProcessError(proc.returncode, args, output=None, stderr=stderr)
return CompletedProcess(args, proc.returncode, None, stderr=stderr)
def _fix_process_args(args, env, kwargs):
-9
View File
@@ -137,16 +137,7 @@ def run_ocrmypdf(input_file, output_file, *args, text=True):
+ [str(input_file), str(output_file)]
)
# Tell subprocess where to find coverage.py configuration
# This has no unless except when coverage is running
# Details: https://coverage.readthedocs.io/en/coverage-5.0/subprocess.html
coverage_rc = Path(__file__).parent.parent / '.coveragerc'
env = os.environ.copy()
if coverage_rc.exists():
env['COVERAGE_PROCESS_START'] = os.fspath(coverage_rc)
elif not running_in_docker():
assert False, "could not find .coveragerc"
p = run(
p_args,
stdout=PIPE,
+112
View File
@@ -0,0 +1,112 @@
# © 2020 James R. Barlow: github.com/jbarlow83
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""Tesseract no-op/fixed rotate plugin
To quickly run tests where getting OCR output is not necessary and we want to test
the rotation pipeline.
In 'hocr' mode, create a .hocr file that specifies no text found.
In 'pdf' mode, convert the image to PDF using another program.
In orientation check mode, report 0, 90, 180, 270... based on page number.
"""
import pikepdf
from PIL import Image
from ocrmypdf import OcrEngine, OrientationConfidence, hookimpl
from ocrmypdf.helpers import page_number
HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name='ocr-system' content='tesseract 4.0.0' />
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/>
</head>
<body>
<div class='ocr_page' id='page_1' title='image "x.tif"; bbox 0 0 {0} {1}; ppageno 0'>
<div class='ocr_carea' id='block_1_1' title="bbox 0 1 {0} {1}">
<p class='ocr_par' dir='ltr' id='par_1' title="bbox 0 1 {0} {1}">
<span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}"><span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span>
</span>
</p>
</div>
</div>
</body>
</html>'''
class FixedRotateNoopOcrEngine(OcrEngine):
@staticmethod
def version():
return '4.0.0'
@staticmethod
def creator_tag(options):
tag = '-PDF' if options.pdf_renderer == 'sandwich' else ''
return f"NO-OP {tag} {FixedRotateNoopOcrEngine.version()}"
def __str__(self):
return f"NO-OP {FixedRotateNoopOcrEngine.version()}"
@staticmethod
def languages(options):
return {'eng'}
@staticmethod
def get_orientation(input_file, options):
page = page_number(input_file)
angle = ((page - 1) * 90) % 360
return OrientationConfidence(angle=angle, confidence=99.9)
@staticmethod
def generate_hocr(input_file, output_hocr, output_text, options):
with Image.open(input_file) as im, open(
output_hocr, 'w', encoding='utf-8'
) as f:
w, h = im.size
f.write(HOCR_TEMPLATE.format(str(w), str(h)))
with open(output_text, 'w') as f:
f.write('')
@staticmethod
def generate_pdf(input_file, output_pdf, output_text, options):
with Image.open(input_file) as im:
dpi = im.info['dpi']
pagesize = im.size[0] / dpi[0], im.size[1] / dpi[1]
ptsize = pagesize[0] * 72, pagesize[1] * 72
pdf = pikepdf.new()
pdf.add_blank_page(page_size=ptsize)
pdf.save(output_pdf, static_id=True)
output_text.write_text('')
@hookimpl
def get_ocr_engine():
return FixedRotateNoopOcrEngine()
+21
View File
@@ -0,0 +1,21 @@
# © 2021 James R. Barlow: github.com/jbarlow83
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import logging
import pytest
from ocrmypdf._sync import configure_debug_logging
def test_debug_logging(tmp_path):
# Just exercise the debug logger but don't validate it
# See https://github.com/pytest-dev/pytest/issues/5502 for pytest logging quirks
prefix = 'test_debug_logging'
log = logging.getLogger(prefix)
handler = configure_debug_logging(tmp_path / 'test.log', prefix)
log.info("test message")
log.removeHandler(handler)
+12
View File
@@ -173,3 +173,15 @@ def test_multiple_pngs(resources, outdir):
inim = next(iter(inpdf.pages[n].images.values()))
outim = next(iter(outpdf.pages[n].images.values()))
assert len(outim.read_raw_bytes()) < len(inim.read_raw_bytes()), n
def test_optimize_off(resources, outpdf):
check_ocrmypdf(
resources / 'trivial.pdf',
outpdf,
'--optimize=0',
'--output-type',
'pdf',
'--plugin',
'tests/plugins/tesseract_noop.py',
)
+34
View File
@@ -0,0 +1,34 @@
# © 2021 James R. Barlow: github.com/jbarlow83
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import pikepdf
import pytest
check_ocrmypdf = pytest.helpers.check_ocrmypdf
@pytest.mark.parametrize('optimize', (0, 3))
@pytest.mark.parametrize('pdfa_level', (1, 2, 3))
def test_pdfa(resources, outpdf, optimize, pdfa_level):
check_ocrmypdf(
resources / 'francais.pdf',
outpdf,
'--plugin',
'tests/plugins/tesseract_noop.py',
f'--output-type=pdfa-{pdfa_level}',
f'--optimize={optimize}',
)
if pdfa_level in (2, 3):
# PDF/A-2 allows ObjStm
assert b'/ObjStm' in outpdf.read_bytes()
elif pdfa_level == 1:
# PDF/A-1 might allow ObjStm, but Acrobat does not approve it, so
# we don't use it
assert b'/ObjStm' not in outpdf.read_bytes()
with pikepdf.open(outpdf) as pdf:
with pdf.open_metadata() as m:
assert m.pdfa_status == f'{pdfa_level}B'
+25 -23
View File
@@ -4,14 +4,15 @@
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
import pickle
from io import BytesIO
from math import isclose
import img2pdf
import pikepdf
import pytest
from PIL import Image
from reportlab.lib.units import inch
from reportlab.pdfgen.canvas import Canvas
from ocrmypdf import pdfinfo
@@ -19,17 +20,15 @@ from ocrmypdf.exceptions import InputFileError
from ocrmypdf.pdfinfo import Colorspace, Encoding
from ocrmypdf.pdfinfo.layout import PDFPage
run_ocrmypdf_api = pytest.helpers.run_ocrmypdf_api
# pylint: disable=protected-access
def test_single_page_text(outdir):
filename = outdir / 'text.pdf'
pdf = Canvas(str(filename), pagesize=(8 * 72, 6 * 72))
pdf = Canvas(str(filename), pagesize=(8 * inch, 6 * inch))
text = pdf.beginText()
text.setFont('Helvetica', 12)
text.setTextOrigin(1 * 72, 3 * 72)
text.setTextOrigin(1 * inch, 3 * inch)
text.textLine(
"Methink'st thou art a general offence and every" " man should beat thee."
)
@@ -46,25 +45,32 @@ def test_single_page_text(outdir):
assert len(page.images) == 0
def test_single_page_image(outdir):
filename = outdir / 'image-mono.pdf'
im_tmp = outdir / 'tmp.png'
@pytest.fixture(scope='session')
def eight_by_eight():
im = Image.new('1', (8, 8), 0)
for n in range(8):
im.putpixel((n, n), 1)
im.save(str(im_tmp), format='PNG')
return im
def test_single_page_image(eight_by_eight, outpdf):
im = eight_by_eight
bio = BytesIO()
im.save(bio, format='PNG')
bio.seek(0)
imgsize = ((img2pdf.ImgSize.dpi, 8), (img2pdf.ImgSize.dpi, 8))
layout_fun = img2pdf.get_layout_fun(None, imgsize, None, None, None)
im_bytes = im_tmp.read_bytes()
pdf_bytes = img2pdf.convert(
im_bytes, producer="img2pdf", with_pdfrw=False, layout_fun=layout_fun
)
filename.write_bytes(pdf_bytes)
info = pdfinfo.PdfInfo(filename)
with outpdf.open('wb') as f:
img2pdf.convert(
bio,
producer="img2pdf",
with_pdfrw=False,
layout_fun=layout_fun,
outputstream=f,
)
info = pdfinfo.PdfInfo(outpdf)
assert len(info) == 1
page = info[0]
@@ -81,16 +87,12 @@ def test_single_page_image(outdir):
assert isclose(pdfimage.dpi.y, 8)
def test_single_page_inline_image(outdir):
def test_single_page_inline_image(eight_by_eight, outdir):
filename = outdir / 'image-mono-inline.pdf'
pdf = Canvas(str(filename), pagesize=(8 * 72, 6 * 72))
im = Image.new('1', (8, 8), 0)
for n in range(8):
im.putpixel((n, n), 1)
# Draw image in a 72x72 pt or 1"x1" area
pdf.drawInlineImage(im, 0, 0, width=72, height=72)
pdf.drawInlineImage(eight_by_eight, 0, 0, width=72, height=72)
pdf.showPage()
pdf.save()
+150
View File
@@ -0,0 +1,150 @@
# © 2021 James R. Barlow: github.com/jbarlow83
#
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
from unittest.mock import Mock
import pytest
from PIL import Image
from reportlab.lib.units import inch
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen.canvas import Canvas
from ocrmypdf import _pipeline, pdfinfo
from ocrmypdf.helpers import Resolution
@pytest.fixture(scope='session')
def rgb_image():
im = Image.new('RGB', (8, 8))
im.putpixel((4, 4), (255, 0, 0))
im.putpixel((5, 5), (0, 255, 0))
im.putpixel((6, 6), (0, 0, 255))
return ImageReader(im)
DUMMY_OVERSAMPLE_RESOLUTION = Resolution(42.0, 42.0)
VECTOR_RESOLUTION = Resolution(_pipeline.VECTOR_PAGE_DPI, _pipeline.VECTOR_PAGE_DPI)
@pytest.mark.parametrize(
'image, text, vector, result',
[
(False, False, False, VECTOR_RESOLUTION),
(False, True, False, VECTOR_RESOLUTION),
(True, False, False, DUMMY_OVERSAMPLE_RESOLUTION),
(True, True, False, VECTOR_RESOLUTION),
(False, False, True, VECTOR_RESOLUTION),
(False, True, True, VECTOR_RESOLUTION),
(True, False, True, VECTOR_RESOLUTION),
(True, True, True, VECTOR_RESOLUTION),
],
)
def test_dpi_needed(image, text, vector, result, rgb_image, outdir):
c = Canvas(str(outdir / 'dpi.pdf'), pagesize=(5 * inch, 5 * inch))
if image:
c.drawImage(rgb_image, 1 * inch, 1 * inch, width=1 * inch, height=1 * inch)
if text:
c.drawString(1 * inch, 4 * inch, "Actual text")
if vector:
c.ellipse(3 * inch, 3 * inch, 4 * inch, 4 * inch)
c.showPage()
c.save()
mock = Mock()
mock.oversample = DUMMY_OVERSAMPLE_RESOLUTION[0]
pi = pdfinfo.PdfInfo(outdir / 'dpi.pdf')
assert _pipeline.get_canvas_square_dpi(pi[0], mock) == result
assert _pipeline.get_page_square_dpi(pi[0], mock) == result
@pytest.mark.parametrize(
# Name for nicer -v output
'name,input,output',
(
(
'empty_input',
# Input:
(),
# Output:
(),
),
(
'no_values',
# Input:
('', '', '', '', ''),
# Output:
(
((1, 5), None),
),
),
(
'no_empty_values',
# Input:
('v', 'w', 'x', 'y', 'z'),
# Output:
(
((1, 1), 'v'),
((2, 2), 'w'),
((3, 3), 'x'),
((4, 4), 'y'),
((5, 5), 'z'),
),
),
(
'skip_head',
# Input:
('', '', 'x', 'y', 'z'),
# Output:
(
((1, 2), None),
((3, 3), 'x'),
((4, 4), 'y'),
((5, 5), 'z'),
),
),
(
'skip_tail',
# Input:
('x', 'y', 'z', '', ''),
# Output:
(
((1, 1), 'x'),
((2, 2), 'y'),
((3, 3), 'z'),
((4, 5), None),
),
),
(
'range_in_middle',
# Input:
('x', '', '', '', 'y'),
# Output:
(
((1, 1), 'x'),
((2, 4), None),
((5, 5), 'y'),
),
),
(
'range_in_middle_2',
# Input:
('x', '', '', 'y', '', '', '', 'z'),
# Output:
(
((1, 1), 'x'),
((2, 3), None),
((4, 4), 'y'),
((5, 7), None),
((8, 8), 'z'),
),
),
),
)
def test_enumerate_compress_ranges(name, input, output):
assert output == tuple(_pipeline.enumerate_compress_ranges(input))
+50 -11
View File
@@ -6,15 +6,17 @@
from io import BytesIO
from math import cos, pi, sin
from os import fspath
import img2pdf
import pikepdf
import pytest
from PIL import Image
from reportlab.pdfgen.canvas import Canvas
from ocrmypdf import leptonica
from ocrmypdf._exec import ghostscript, tesseract
from ocrmypdf._exec import ghostscript
from ocrmypdf._plugin_manager import get_plugin_manager
from ocrmypdf.helpers import Resolution
from ocrmypdf.pdfinfo import PdfInfo
@@ -249,16 +251,6 @@ def test_rotate_page_level(image_angle, page_angle, resources, outdir):
assert check_monochrome_correlation(outdir, reference, 1, out, 1) > 0.2
def test_tesseract_orientation(resources, tmp_path):
pix = leptonica.Pix.open(resources / 'crom.png')
pix_rotated = pix.rotate_orth(2) # 180 degrees clockwise
pix_rotated.write_implied_format(tmp_path / '000001.png')
tesseract.get_orientation( # Test results of this are unreliable
tmp_path / '000001.png', engine_mode='3', timeout=10
)
def test_rasterize_rotates(resources, tmp_path):
pm = get_plugin_manager([])
@@ -287,3 +279,50 @@ def test_rasterize_rotates(resources, tmp_path):
filter_vector=False,
)
assert Image.open(img).size == (151, 123), "Image not rotated"
def test_simulated_scan(outdir):
canvas = Canvas(
fspath(outdir / 'fakescan.pdf'),
pagesize=(209.8, 297.6),
)
page_vars = [(2, 36, 250), (91, 170, 240), (179, 190, 36), (271, 36, 36)]
for n, page_var in enumerate(page_vars):
text = canvas.beginText()
text.setFont('Helvetica', 20)
angle, x, y = page_var
cos_a, sin_a = cos(angle / 180.0 * pi), sin(angle / 180.0 * pi)
text.setTextTransform(cos_a, -sin_a, sin_a, cos_a, x, y)
text.textOut(f'Page {n + 1}')
canvas.drawText(text)
canvas.showPage()
canvas.save()
check_ocrmypdf(
outdir / 'fakescan.pdf',
outdir / 'out.pdf',
'--force-ocr',
'--deskew',
'--rotate-pages',
'--plugin',
'tests/plugins/tesseract_debug_rotate.py',
)
with pikepdf.open(outdir / 'out.pdf') as pdf:
assert (
pdf.pages[1].MediaBox[2] > pdf.pages[1].MediaBox[3]
), "Wrong orientation: not landscape"
assert (
pdf.pages[3].MediaBox[2] > pdf.pages[3].MediaBox[3]
), "Wrong orientation: Not landscape"
assert (
pdf.pages[0].MediaBox[2] < pdf.pages[0].MediaBox[3]
), "Wrong orientation: Not portrait"
assert (
pdf.pages[2].MediaBox[2] < pdf.pages[2].MediaBox[3]
), "Wrong orientation: Not portrait"
+2 -1
View File
@@ -45,7 +45,8 @@ def test_skip_pages_does_not_replicate(resources, basename, outdir):
assert len(page.images) == 1, "skipped page was replicated"
for n, info_out_n in enumerate(info):
assert info_out_n.width_inches == info_in[n].width_inches
assert info_out_n.width_inches == info_in[n].width_inches, "output resized"
assert info_out_n.height_inches == info_in[n].height_inches, "output resized"
def test_content_preservation(resources, outpdf):
-2
View File
@@ -90,8 +90,6 @@ def test_mutex_options():
vd.check_options_ocr_behavior(make_opts(redo_ocr=True, skip_text=True))
with pytest.raises(BadArgsError):
vd.check_options_ocr_behavior(make_opts(redo_ocr=True, force_ocr=True))
with pytest.raises(BadArgsError):
vd.check_options_ocr_behavior(make_opts(pages='1-3', sidecar='file.txt'))
def test_optimizing(caplog):