Compare commits

..
Author SHA1 Message Date
James R. Barlow a371655052 v14.4.0 docs and release notes 2023-08-12 01:55:00 -07:00
James R. Barlow a6ce35b13a Add argument to override digital signatures 2023-08-12 01:31:36 -07:00
James R. Barlow 45added738 Implement digital signature detection 2023-08-12 01:20:20 -07:00
James R. Barlow 6e20439c91 Remove oddball log.error/raise Exception() pattern
Instead present error message in exception.
2023-08-12 01:02:42 -07:00
James R. Barlow 72e056436c Add simple signature detection 2023-08-12 00:59:54 -07:00
James R. Barlow e02ba19097 Modernize pdfinfo usage of pikepdf to Name.X 2023-08-12 00:54:45 -07:00
James R. Barlow d3b858f994 Improve progress bar presentation/alignment 2023-08-11 01:57:25 -07:00
James R. Barlow 19045c4f21 Replace coloredlogs and tqdm with rich 2023-08-11 01:47:42 -07:00
James R. Barlow f4d89fe6cc Fix typo 2023-08-11 01:47:42 -07:00
Trenton HandGitHub ab85c0f5a9 Enables creation of a release and uploading the build assets to it (#1132) 2023-08-11 01:37:38 -07:00
James R. Barlow 32693b683d Revert "Add Python 3.12 prerelease to build matrix"
This reverts commit b5dc276ba1.

Would require building pikepdf from source on Python 3.12, which is
not worth the effort at this point.
2023-08-01 01:11:15 -07:00
James R. Barlow b5dc276ba1 Add Python 3.12 prerelease to build matrix 2023-08-01 00:32:11 -07:00
17 changed files with 252 additions and 73 deletions
+18 -4
View File
@@ -17,16 +17,32 @@ FROM base as builder
# Note we need leptonica here to build jbig2 # Note we need leptonica here to build jbig2
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential autoconf automake libtool \
libleptonica-dev \
zlib1g-dev \
python3-dev \ python3-dev \
python3-distutils \ python3-distutils \
libffi-dev \
ca-certificates \ ca-certificates \
curl \ curl \
git \ git \
libcairo2-dev \ libcairo2-dev \
pkg-config pkg-config
# Get the latest pip # Get the latest pip (Ubuntu version doesn't support manylinux2010)
RUN curl https://bootstrap.pypa.io/get-pip.py | python3 RUN \
curl https://bootstrap.pypa.io/get-pip.py | python3
# Compile and install jbig2
# Needs libleptonica-dev, zlib1g-dev
RUN \
mkdir jbig2 \
&& curl -L https://github.com/agl/jbig2enc/archive/ea6a40a.tar.gz | \
tar xz -C jbig2 --strip-components=1 \
&& cd jbig2 \
&& ./autogen.sh && ./configure && make && make install \
&& cd .. \
&& rm -rf jbig2
COPY . /app COPY . /app
@@ -40,13 +56,11 @@ FROM base
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
software-properties-common gpg-agent software-properties-common gpg-agent
RUN add-apt-repository -y ppa:alex-p/tesseract-ocr-devel RUN add-apt-repository -y ppa:alex-p/tesseract-ocr-devel
RUN add-apt-repository -y ppa:alex-p/jbig2enc
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
ghostscript \ ghostscript \
fonts-droid-fallback \ fonts-droid-fallback \
jbig2dec \ jbig2dec \
jbig2 \
img2pdf \ img2pdf \
libsm6 libxext6 libxrender-dev \ libsm6 libxext6 libxrender-dev \
pngquant \ pngquant \
+26
View File
@@ -255,6 +255,32 @@ jobs:
password: ${{ secrets.TOKEN_PYPI }} password: ${{ secrets.TOKEN_PYPI }}
# repository_url: https://test.pypi.org/legacy/ # repository_url: https://test.pypi.org/legacy/
create_release:
name: Create GitHub release
needs: [wheel_sdist_linux, test_linux, test_macos, test_windows]
runs-on: ubuntu-latest
if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags/v')
permissions:
# Required to create a release
contents: write
steps:
- uses: actions/download-artifact@v3
with:
name: artifact
path: dist
- name: Create Release
id: create-release
uses: shogo82148/actions-create-release@v1
- name: Upload Assets
uses: shogo82148/actions-upload-release-asset@v1
with:
upload_url: ${{ steps.create-release.outputs.upload_url }}
asset_path: |
./dist/*.whl
./dist/*.tar.gz
docker: docker:
name: Build Docker images name: Build Docker images
needs: [wheel_sdist_linux, test_linux, test_macos, test_windows] needs: [wheel_sdist_linux, test_linux, test_macos, test_windows]
+14
View File
@@ -372,3 +372,17 @@ Some users may consider enabling lossy JBIG2. See: :ref:`jbig2-lossy`.
Image processing and PDF/A conversion can also introduce lossy transformations Image processing and PDF/A conversion can also introduce lossy transformations
to your PDF images, even when ``--optimize 1`` is in use. to your PDF images, even when ``--optimize 1`` is in use.
Digitally signed PDFs
=====================
OCRmyPDF cannot preserve digital signatures in PDFs and also add to OCR to them.
By default, it will refuse to modify a signed PDF regardless of other settings. You can
override this behavior with ``--invalidate-digital-signatures``; as the name suggests,
any digital signatures will be invalidated.
OCRmyPDF cannot open documents that are encrypted with a digital certificate.
Versions of OCRmyPDF prior to 14.4.0 would invalidate existing digital signatures
without warning.
-8
View File
@@ -24,14 +24,6 @@ On macOS, Homebrew packages jbig2enc and OCRmyPDF includes it by
default. The Docker image for OCRmyPDF also builds its own JBIG2 encoder default. The Docker image for OCRmyPDF also builds its own JBIG2 encoder
from source. from source.
On Ubuntu, you can install the JBIG2 encoder using the following PPA:
.. code-block:: bash
sudo add-apt-repository ppa:alex-p/jbig2enc
sudo apt update
sudo apt install jbig2enc
For all other Linux, you must build a JBIG2 encoder from source: For all other Linux, you must build a JBIG2 encoder from source:
.. code-block:: bash .. code-block:: bash
+13
View File
@@ -28,6 +28,19 @@ tagged yet.
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg .. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
v14.4.0
=======
- Digitally signed PDFs are now detected. If the PDF is signed, OCRmyPDF will
refuse to modify it. Previously, only encrypted PDFs were detected, not
those that were signed but not encrypted. :issue:`1040`
- In addition, `--invalidate-digital-signatures` can be used to override the
above behavior and modify the PDF anyway. :issue:`1040`
- tqdm progress bars replaced with "rich" progress bars. The rich library is
a new dependency. Certain APIs that used tqdm are now deprecated and will
be removed in the next major release.
- Improved integration with GitHub Releases. Thanks to @stumpylog.
v14.3.0 v14.3.0
======= =======
+1
View File
@@ -31,6 +31,7 @@ __ocrmypdf_arguments()
--force-ocr (OCR documents that already have printable text) --force-ocr (OCR documents that already have printable text)
--skip-text (skip OCR on any pages that already contain text) --skip-text (skip OCR on any pages that already contain text)
--redo-ocr (redo OCR on any pages that seem to have OCR already) --redo-ocr (redo OCR on any pages that seem to have OCR already)
--invalidate-digital-signatures (remove digital signatures from PDF)
--skip-big (skip OCR on pages larger than this many MPixels) --skip-big (skip OCR on pages larger than this many MPixels)
--optimize (select optimization level) --optimize (select optimization level)
--jpeg-quality (JPEG quality [0..100]) --jpeg-quality (JPEG quality [0..100])
+1
View File
@@ -16,6 +16,7 @@ complete -c ocrmypdf -l remove-vectors -d "don't send vector objects to OCR"
complete -c ocrmypdf -s f -l force-ocr -d "OCR documents that already have printable text" complete -c ocrmypdf -s f -l force-ocr -d "OCR documents that already have printable text"
complete -c ocrmypdf -s s -l skip-ocr -d "skip OCR on pages that text, otherwise try OCR" complete -c ocrmypdf -s s -l skip-ocr -d "skip OCR on pages that text, otherwise try OCR"
complete -c ocrmypdf -l redo-ocr -d "redo OCR on any pages that seem to have OCR already" complete -c ocrmypdf -l redo-ocr -d "redo OCR on any pages that seem to have OCR already"
complete -c ocrmypdf -l invalidate-digital-signatures -d "invalidate digital signatures and allow OCR to proceed"
complete -c ocrmypdf -s k -l keep-temporary-files -d "keep temporary files (debug)" complete -c ocrmypdf -s k -l keep-temporary-files -d "keep temporary files (debug)"
+1 -1
View File
@@ -17,7 +17,6 @@ license = {text = "MPL-2.0"}
requires-python = ">=3.8" requires-python = ">=3.8"
dependencies = [ dependencies = [
"Pillow>=8.2.0", "Pillow>=8.2.0",
"coloredlogs>=14.0",
"deprecation>=2.1.0", "deprecation>=2.1.0",
"img2pdf>=0.3.0", # pure Python "img2pdf>=0.3.0", # pure Python
"packaging>=20", "packaging>=20",
@@ -25,6 +24,7 @@ dependencies = [
"pikepdf>=5.0.1", "pikepdf>=5.0.1",
"pluggy>=0.13.0", "pluggy>=0.13.0",
"reportlab>=3.5.66", "reportlab>=3.5.66",
"rich>=13",
"tqdm>=4", "tqdm>=4",
"importlib-resources>=5;python_version<'3.9'", # until Python 3.9 "importlib-resources>=5;python_version<'3.9'", # until Python 3.9
"typing-extensions>=4;python_version<'3.10'", "typing-extensions>=4;python_version<'3.10'",
+67
View File
@@ -8,6 +8,17 @@ from __future__ import annotations
import logging import logging
from contextlib import suppress from contextlib import suppress
from rich.console import Console
from rich.logging import RichHandler
from rich.progress import (
BarColumn,
MofNCompleteColumn,
Progress,
TaskProgressColumn,
TextColumn,
TimeRemainingColumn,
)
from rich.table import Column
from tqdm import tqdm from tqdm import tqdm
@@ -41,3 +52,59 @@ class TqdmConsole:
def flush(self): def flush(self):
with suppress(AttributeError): with suppress(AttributeError):
self.file.flush() self.file.flush()
class RichLoggingHandler(RichHandler):
def __init__(self, console: Console, **kwargs):
super().__init__(
console=console, show_level=False, show_time=False, markup=True, **kwargs
)
class RichTqdmProgressAdapter:
"""Adapt tqdm API to rich progress bar."""
def __init__(
self,
*,
console: Console,
desc: str,
total: float | None = None,
unit: str | None = None,
unit_scale: float | None = 1.0,
disable: bool = False,
**kwargs,
):
self.progress = Progress(
TextColumn(
"[progress.description]{task.description}",
table_column=Column(min_width=20),
),
BarColumn(),
TaskProgressColumn(),
MofNCompleteColumn(),
TimeRemainingColumn(),
console=console,
auto_refresh=True,
redirect_stderr=True,
redirect_stdout=False,
disable=disable,
**kwargs,
)
self.unit_scale = unit_scale
self.progress_bar = self.progress.add_task(
desc, total=total * self.unit_scale, unit=unit
)
def __enter__(self):
self.progress.start()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.progress.refresh()
self.progress.stop()
return False
def update(self, value=None):
advance = self.unit_scale if value is None else value
self.progress.update(self.progress_bar, advance=advance)
+17 -14
View File
@@ -27,6 +27,7 @@ from ocrmypdf._jobcontext import PageContext, PdfContext
from ocrmypdf._version import PROGRAM_NAME from ocrmypdf._version import PROGRAM_NAME
from ocrmypdf._version import __version__ as VERSION from ocrmypdf._version import __version__ as VERSION
from ocrmypdf.exceptions import ( from ocrmypdf.exceptions import (
DigitalSignatureError,
DpiError, DpiError,
EncryptedPdfError, EncryptedPdfError,
InputFileError, InputFileError,
@@ -80,34 +81,32 @@ def triage_image_file(input_file: Path, output_file: Path, options) -> None:
if im.info['dpi'] <= (96, 96) and not options.image_dpi: if im.info['dpi'] <= (96, 96) and not options.image_dpi:
log.info("Image size: (%d, %d)", *im.size) log.info("Image size: (%d, %d)", *im.size)
log.info("Image resolution: (%d, %d)", *im.info['dpi']) log.info("Image resolution: (%d, %d)", *im.info['dpi'])
log.error( raise DpiError(
"Input file is an image, but the resolution (DPI) is " "Input file is an image, but the resolution (DPI) is "
"not credible. Estimate the resolution at which the " "not credible. Estimate the resolution at which the "
"image was scanned and specify it using --image-dpi." "image was scanned and specify it using --image-dpi."
) )
raise DpiError()
elif not options.image_dpi: elif not options.image_dpi:
log.info("Image size: (%d, %d)", *im.size) log.info("Image size: (%d, %d)", *im.size)
log.error( raise DpiError(
"Input file is an image, but has no resolution (DPI) " "Input file is an image, but has no resolution (DPI) "
"in its metadata. Estimate the resolution at which " "in its metadata. Estimate the resolution at which "
"image was scanned and specify it using --image-dpi." "image was scanned and specify it using --image-dpi."
) )
raise DpiError()
if im.mode in ('RGBA', 'LA'): if im.mode in ('RGBA', 'LA'):
log.error( raise UnsupportedImageFormatError(
"The input image has an alpha channel. Remove the alpha " "The input image has an alpha channel. Remove the alpha "
"channel first." "channel first."
) )
raise UnsupportedImageFormatError()
if 'iccprofile' not in im.info: if 'iccprofile' not in im.info:
if im.mode == 'RGB': if im.mode == 'RGB':
log.info("Input image has no ICC profile, assuming sRGB") log.info("Input image has no ICC profile, assuming sRGB")
elif im.mode == 'CMYK': elif im.mode == 'CMYK':
log.error("Input CMYK image has no ICC profile, not usable") raise UnsupportedImageFormatError(
raise UnsupportedImageFormatError() "Input CMYK image has no ICC profile, not usable"
)
try: try:
log.info("Image seems valid. Try converting to PDF...") log.info("Image seems valid. Try converting to PDF...")
@@ -125,7 +124,6 @@ def triage_image_file(input_file: Path, output_file: Path, options) -> None:
) )
log.info("Successfully converted to PDF, processing...") log.info("Successfully converted to PDF, processing...")
except img2pdf.ImageOpenError as e: except img2pdf.ImageOpenError as e:
log.error(e)
raise UnsupportedImageFormatError() from e raise UnsupportedImageFormatError() from e
@@ -195,18 +193,21 @@ def validate_pdfinfo_options(context: PdfContext) -> None:
options = context.options options = context.options
if pdfinfo.needs_rendering: if pdfinfo.needs_rendering:
log.error( raise InputFileError(
"This PDF contains dynamic XFA forms created by Adobe LiveCycle " "This PDF contains dynamic XFA forms created by Adobe LiveCycle "
"Designer and can only be read by Adobe Acrobat or Adobe Reader." "Designer and can only be read by Adobe Acrobat or Adobe Reader."
) )
raise InputFileError() if pdfinfo.has_signature:
if options.invalidate_digital_signatures:
log.warning("All digital signatures will be invalidated")
else:
raise DigitalSignatureError()
if pdfinfo.has_acroform: if pdfinfo.has_acroform:
if options.redo_ocr: if options.redo_ocr:
log.error( raise InputFileError(
"This PDF has a user fillable form. --redo-ocr is not " "This PDF has a user fillable form. --redo-ocr is not "
"currently possible on such files." "currently possible on such files."
) )
raise InputFileError()
else: else:
log.warning( log.warning(
"This PDF has a fillable form. " "This PDF has a fillable form. "
@@ -853,7 +854,9 @@ def metadata_fixup(working_file: Path, context: PdfContext) -> Path:
with pikepdf.open(context.origin) as original, pikepdf.open(working_file) as pdf: with pikepdf.open(context.origin) as original, pikepdf.open(working_file) as pdf:
docinfo = get_docinfo(original, context) docinfo = get_docinfo(original, context)
with pdf.open_metadata() as meta_pdf: with pdf.open_metadata() as meta_pdf:
meta_pdf.load_from_docinfo(docinfo, delete_missing=False, raise_failure=False) meta_pdf.load_from_docinfo(
docinfo, delete_missing=False, raise_failure=False
)
# If xmp:CreateDate is missing, set it to the modify date to # If xmp:CreateDate is missing, set it to the modify date to
# ensure consistency with Ghostscript. # ensure consistency with Ghostscript.
if 'xmp:CreateDate' not in meta_pdf: if 'xmp:CreateDate' not in meta_pdf:
-10
View File
@@ -15,9 +15,6 @@ from pathlib import Path
from typing import AnyStr, BinaryIO, Iterable, Union from typing import AnyStr, BinaryIO, Iterable, Union
from warnings import warn from warnings import warn
import coloredlogs
from humanfriendly.terminal import enable_ansi_support
from ocrmypdf._logging import PageNumberFilter, TqdmConsole from ocrmypdf._logging import PageNumberFilter, TqdmConsole
from ocrmypdf._plugin_manager import get_plugin_manager from ocrmypdf._plugin_manager import get_plugin_manager
from ocrmypdf._sync import run_pipeline from ocrmypdf._sync import run_pipeline
@@ -112,14 +109,7 @@ def configure_logging(
else: else:
fmt = '%(pageno)s%(message)s' fmt = '%(pageno)s%(message)s'
use_colors = progress_bar_friendly
formatter = None formatter = None
if use_colors:
use_colors = enable_ansi_support()
if use_colors:
use_colors = coloredlogs.terminal_supports_colors()
if use_colors:
formatter = coloredlogs.ColoredFormatter(fmt=fmt)
if not formatter: if not formatter:
formatter = logging.Formatter(fmt=fmt) formatter = logging.Formatter(fmt=fmt)
+11 -4
View File
@@ -16,10 +16,10 @@ from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_compl
from contextlib import suppress from contextlib import suppress
from typing import Callable, Iterable, Type, Union from typing import Callable, Iterable, Type, Union
from tqdm import tqdm from rich.console import Console as RichConsole
from ocrmypdf import Executor, hookimpl from ocrmypdf import Executor, hookimpl
from ocrmypdf._logging import TqdmConsole from ocrmypdf._logging import RichLoggingHandler, RichTqdmProgressAdapter
from ocrmypdf.exceptions import InputFileError from ocrmypdf.exceptions import InputFileError
from ocrmypdf.helpers import remove_all_log_handlers from ocrmypdf.helpers import remove_all_log_handlers
@@ -168,13 +168,20 @@ def get_executor(progressbar_class):
return StandardExecutor(pbar_class=progressbar_class) return StandardExecutor(pbar_class=progressbar_class)
RICH_CONSOLE = RichConsole(stderr=True)
@hookimpl @hookimpl
def get_progressbar_class(): def get_progressbar_class():
"""Return the default progress bar class.""" """Return the default progress bar class."""
return tqdm
def partial_RichTqdmProgressAdapter(*args, **kwargs):
return RichTqdmProgressAdapter(*args, **kwargs, console=RICH_CONSOLE)
return partial_RichTqdmProgressAdapter
@hookimpl @hookimpl
def get_logging_console(): def get_logging_console():
"""Return the default logging console handler.""" """Return the default logging console handler."""
return logging.StreamHandler(stream=TqdmConsole(sys.stderr)) return RichLoggingHandler(console=RICH_CONSOLE)
+7
View File
@@ -359,6 +359,13 @@ Online documentation is located at:
help="Skip OCR on pages larger than the specified amount of megapixels, " help="Skip OCR on pages larger than the specified amount of megapixels, "
"but include skipped pages in final output", "but include skipped pages in final output",
) )
ocrsettings.add_argument(
'--invalidate-digital-signatures',
action='store_true',
help="Normally, OCRmyPDF will refuse to OCR a PDF that has a digital "
"signature. This option allows OCR to proceed, but the digital signature "
"will be invalidated.",
)
advanced = parser.add_argument_group( advanced = parser.add_argument_group(
"Advanced", "Advanced options to control OCRmyPDF" "Advanced", "Advanced options to control OCRmyPDF"
+12
View File
@@ -108,6 +108,18 @@ class EncryptedPdfError(ExitCodeException):
) )
class DigitalSignatureError(ExitCodeException):
"""PDF has a digital signature."""
exit_code = ExitCode.input_file
message = dedent(
"""\
Input PDF has a digital signature. OCR would alter the document,
invalidating the signature.
"""
)
class TesseractConfigError(ExitCodeException): class TesseractConfigError(ExitCodeException):
"""Tesseract config can't be parsed.""" """Tesseract config can't be parsed."""
+1 -1
View File
@@ -12,7 +12,7 @@ worker communicates only with the main process.
This is not without drawbacks. If the tasks are not "even" in size, which cannot This is not without drawbacks. If the tasks are not "even" in size, which cannot
be guaranteed, some workers may end up with too much work while others are idle. be guaranteed, some workers may end up with too much work while others are idle.
It is less efficient than the standard implementation, so not th edefault. It is less efficient than the standard implementation, so not the default.
""" """
from __future__ import annotations from __future__ import annotations
+38 -28
View File
@@ -21,7 +21,9 @@ from typing import Container, Iterable, Iterator, Mapping, NamedTuple, Sequence,
from warnings import warn from warnings import warn
from pikepdf import ( from pikepdf import (
Name,
Object, Object,
Page,
Pdf, Pdf,
PdfImage, PdfImage,
PdfInlineImage, PdfInlineImage,
@@ -491,15 +493,15 @@ def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
since the object does not know its own name. since the object does not know its own name.
""" """
if '/Resources' not in container: if Name.Resources not in container:
return return
resources = container['/Resources'] resources = container[Name.Resources]
if '/XObject' not in resources: if Name.XObject not in resources:
return return
for key, candidate in resources['/XObject'].items(): for key, candidate in resources[Name.XObject].items():
if candidate is None or '/Subtype' not in candidate: if candidate is None or Name.Subtype not in candidate:
continue continue
if candidate['/Subtype'] == '/Image': if candidate[Name.Subtype] == Name.Image:
pdfimage = candidate pdfimage = candidate
yield (pdfimage, key) yield (pdfimage, key)
@@ -535,15 +537,15 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
The container may be a page, or a parent Form XObject. The container may be a page, or a parent Form XObject.
""" """
if '/Resources' not in container: if Name.Resources not in container:
return return
resources = container['/Resources'] resources = container[Name.Resources]
if '/XObject' not in resources: if Name.XObject not in resources:
return return
xobjs = resources['/XObject'].as_dict() xobjs = resources[Name.XObject].as_dict()
for xobj in xobjs: for xobj in xobjs:
candidate = xobjs[xobj] candidate = xobjs[xobj]
if candidate is None or candidate['/Subtype'] != '/Form': if candidate is None or candidate[Name.Subtype] != Name.Form:
continue continue
form_xobject = candidate form_xobject = candidate
@@ -581,16 +583,19 @@ def _process_content_streams(
downsampling. downsampling.
""" """
if container.get('/Type') == '/Page' and '/Contents' in container: if container.get(Name.Type) == Name.Page and Name.Contents in container:
initial_shorthand = shorthand or UNIT_SQUARE initial_shorthand = shorthand or UNIT_SQUARE
elif container.get('/Type') == '/XObject' and container['/Subtype'] == '/Form': elif (
container.get(Name.Type) == Name.XObject
and container[Name.Subtype] == Name.Form
):
# Set the CTM to the state it was when the "Do" operator was # Set the CTM to the state it was when the "Do" operator was
# encountered that is drawing this instance of the Form XObject # encountered that is drawing this instance of the Form XObject
ctm = PdfMatrix(shorthand) if shorthand else PdfMatrix.identity() ctm = PdfMatrix(shorthand) if shorthand else PdfMatrix.identity()
# A Form XObject may provide its own matrix to map form space into # A Form XObject may provide its own matrix to map form space into
# user space. Get this if one exists # user space. Get this if one exists
form_shorthand = container.get('/Matrix', PdfMatrix.identity()) form_shorthand = container.get(Name.Matrix, PdfMatrix.identity())
form_matrix = PdfMatrix(form_shorthand) form_matrix = PdfMatrix(form_shorthand)
# Concatenate form matrix with CTM to ensure CTM is correct for # Concatenate form matrix with CTM to ensure CTM is correct for
@@ -771,7 +776,7 @@ class PageInfo:
check_pages: Container[int], check_pages: Container[int],
detailed_analysis: bool, detailed_analysis: bool,
): ):
page = pdf.pages[pageno] page: Page = pdf.pages[pageno]
mediabox = [Decimal(d) for d in page.MediaBox.as_list()] mediabox = [Decimal(d) for d in page.MediaBox.as_list()]
width_pt = mediabox[2] - mediabox[0] width_pt = mediabox[2] - mediabox[0]
height_pt = mediabox[3] - mediabox[1] height_pt = mediabox[3] - mediabox[1]
@@ -779,7 +784,7 @@ class PageInfo:
check_this_page = pageno in check_pages check_this_page = pageno in check_pages
if check_this_page and detailed_analysis: if check_this_page and detailed_analysis:
pscript5_mode = str(pdf.docinfo.get('/Creator')).startswith('PScript5') pscript5_mode = str(pdf.docinfo.get(Name.Creator)).startswith('PScript5')
miner = get_page_analysis(infile, pageno, pscript5_mode) miner = get_page_analysis(infile, pageno, pscript5_mode)
self._textboxes = list(simplify_textboxes(miner, get_text_boxes)) self._textboxes = list(simplify_textboxes(miner, get_text_boxes))
bboxes = (box.bbox for box in self._textboxes) bboxes = (box.bbox for box in self._textboxes)
@@ -789,17 +794,13 @@ class PageInfo:
self._textboxes = [] self._textboxes = []
self._has_text = None # i.e. "no information" self._has_text = None # i.e. "no information"
userunit = page.get('/UserUnit', Decimal(1.0)) userunit = page.get(Name.UserUnit, Decimal(1.0))
if not isinstance(userunit, Decimal): if not isinstance(userunit, Decimal):
userunit = Decimal(userunit) userunit = Decimal(userunit)
self._userunit = userunit self._userunit = userunit
self._width_inches = width_pt * userunit / Decimal(72.0) self._width_inches = width_pt * userunit / Decimal(72.0)
self._height_inches = height_pt * userunit / Decimal(72.0) self._height_inches = height_pt * userunit / Decimal(72.0)
self._rotate = int(getattr(page.obj, 'Rotate', 0))
try:
self._rotate = int(page['/Rotate'])
except KeyError:
self._rotate = 0
userunit_shorthand = (userunit, 0, 0, userunit, 0, 0) userunit_shorthand = (userunit, 0, 0, userunit, 0, 0)
@@ -953,6 +954,10 @@ DEFAULT_EXECUTOR = SerialExecutor()
class PdfInfo: class PdfInfo:
"""Get summary information about a PDF.""" """Get summary information about a PDF."""
_has_acroform: bool = False
_has_signature: bool = False
_needs_rendering: bool = False
def __init__( def __init__(
self, self,
infile, infile,
@@ -980,13 +985,13 @@ class PdfInfo:
check_pages=check_pages, check_pages=check_pages,
detailed_analysis=detailed_analysis, detailed_analysis=detailed_analysis,
) )
self._needs_rendering = pdf.Root.get('/NeedsRendering', False) self._needs_rendering = pdf.Root.get(Name.NeedsRendering, False)
self._has_acroform = False if Name.AcroForm in pdf.Root:
if '/AcroForm' in pdf.Root: if len(pdf.Root.AcroForm.get(Name.Fields, [])) > 0:
if len(pdf.Root.AcroForm.get('/Fields', [])) > 0:
self._has_acroform = True self._has_acroform = True
elif '/XFA' in pdf.Root.AcroForm: elif Name.XFA in pdf.Root.AcroForm:
self._has_acroform = True self._has_acroform = True
self._has_signature = bool(pdf.Root.AcroForm.get(Name.SigFlags, 0) & 1)
@property @property
def pages(self) -> Sequence[PageInfo | None]: def pages(self) -> Sequence[PageInfo | None]:
@@ -1006,9 +1011,14 @@ class PdfInfo:
@property @property
def has_acroform(self) -> bool: def has_acroform(self) -> bool:
"""Return True if any page has an AcroForm.""" """Return True if the document catalog has an AcroForm."""
return self._has_acroform return self._has_acroform
@property
def has_signature(self) -> bool:
"""Return True if the document annotations has a digital signature."""
return self._has_signature
@property @property
def filename(self) -> str | Path: def filename(self) -> str | Path:
"""Return filename of PDF.""" """Return filename of PDF."""
+25 -3
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import logging import logging
import pikepdf
import pytest import pytest
import ocrmypdf import ocrmypdf
@@ -19,10 +20,11 @@ def acroform(resources):
return resources / 'acroform.pdf' return resources / 'acroform.pdf'
def test_acroform_and_redo(acroform, caplog, no_outpdf): def test_acroform_and_redo(acroform, no_outpdf):
with pytest.raises(ocrmypdf.exceptions.InputFileError): with pytest.raises(
ocrmypdf.exceptions.InputFileError, match='--redo-ocr is not currently possible'
):
check_ocrmypdf(acroform, no_outpdf, '--redo-ocr') check_ocrmypdf(acroform, no_outpdf, '--redo-ocr')
assert '--redo-ocr is not currently possible' in caplog.text
def test_acroform_message(acroform, caplog, outpdf): def test_acroform_message(acroform, caplog, outpdf):
@@ -30,3 +32,23 @@ def test_acroform_message(acroform, caplog, outpdf):
check_ocrmypdf(acroform, outpdf, '--plugin', 'tests/plugins/tesseract_noop.py') check_ocrmypdf(acroform, outpdf, '--plugin', 'tests/plugins/tesseract_noop.py')
assert 'fillable form' in caplog.text assert 'fillable form' in caplog.text
assert '--force-ocr' in caplog.text assert '--force-ocr' in caplog.text
@pytest.fixture
def digitally_signed(acroform, outdir):
out = outdir / 'acroform_signed.pdf'
with pikepdf.open(acroform) as pdf:
pdf.Root.AcroForm.SigFlags = 3
pdf.save(out)
yield out
def test_digital_signature(digitally_signed, no_outpdf):
with pytest.raises(ocrmypdf.exceptions.DigitalSignatureError):
check_ocrmypdf(digitally_signed, no_outpdf)
def test_digital_signature_invalidate(digitally_signed, no_outpdf):
check_ocrmypdf(
digitally_signed, no_outpdf, '--force-ocr', '--invalidate-digital-signatures'
)