Compare commits

...
12 Commits
Author SHA1 Message Date
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
14 changed files with 217 additions and 100 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:
+21
View File
@@ -12,6 +12,27 @@ 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.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
=======
+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:
+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'
+15 -6
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,
)
)
@@ -588,12 +592,17 @@ 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)
if pageinfo.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
)
+6 -2
View File
@@ -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()
+18 -15
View File
@@ -13,6 +13,7 @@
import argparse
import logging
import os
import platform
import sys
import threading
import warnings
@@ -170,20 +171,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 +200,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
+2 -2
View File
@@ -498,7 +498,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 +519,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
+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,
+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)
+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()
+63
View File
@@ -0,0 +1,63 @@
# © 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
+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):