Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b0e149809 | ||
|
|
b7ce5b0d7d | ||
|
|
ffd6a64ce9 | ||
|
|
5727f1e081 | ||
|
|
b75a7eca2a | ||
|
|
2b01676434 | ||
|
|
e11c386c58 | ||
|
|
9346d1f970 | ||
|
|
012cbef865 | ||
|
|
0687568e1b | ||
|
|
3086cfc3d9 | ||
|
|
91a14660b3 | ||
|
|
539f0ee0ce | ||
|
|
7172817cd6 | ||
|
|
d9cc759142 | ||
|
|
364799fc3e | ||
|
|
f4c211fa2d | ||
|
|
113a6b45bd | ||
|
|
e9419d2c40 | ||
|
|
fb006ef39f | ||
|
|
890b994403 | ||
|
|
01bbf7d144 | ||
|
|
468de5324a | ||
|
|
072db75fa3 | ||
|
|
8519b3f625 | ||
|
|
dd7c4f3eaa | ||
|
|
c8e6f20f8d | ||
|
|
10530a8698 | ||
|
|
207866abf5 | ||
|
|
3829af16fb | ||
|
|
24db31b4c5 |
@@ -0,0 +1,83 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
FROM alpine:3.18 as base
|
||||||
|
|
||||||
|
ENV LANG=C.UTF-8
|
||||||
|
ENV TZ=UTC
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
python3 \
|
||||||
|
zlib
|
||||||
|
|
||||||
|
FROM base as builder
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ca-certificates \
|
||||||
|
git \
|
||||||
|
python3-dev \
|
||||||
|
py3-pip
|
||||||
|
|
||||||
|
# On arm64, we need to build cffi from source.
|
||||||
|
ARG TARGETPLATFORM
|
||||||
|
|
||||||
|
RUN if [ "${TARGETPLATFORM}" == "linux/arm64" ]; then \
|
||||||
|
apk add --no-cache \
|
||||||
|
build-base \
|
||||||
|
autoconf \
|
||||||
|
automake \
|
||||||
|
libtool \
|
||||||
|
zlib-dev \
|
||||||
|
libffi-dev \
|
||||||
|
cairo-dev \
|
||||||
|
pkgconfig \
|
||||||
|
; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN python3 -m venv .venv
|
||||||
|
|
||||||
|
RUN source .venv/bin/activate \
|
||||||
|
&& python3 -m pip install --no-cache-dir --upgrade pip \
|
||||||
|
&& python3 -m pip install --no-cache-dir wheel \
|
||||||
|
&& python3 -m pip install --no-cache-dir .[test,webservice,watcher]
|
||||||
|
|
||||||
|
FROM base
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
ghostscript \
|
||||||
|
jbig2dec \
|
||||||
|
jbig2enc \
|
||||||
|
pngquant \
|
||||||
|
tesseract-ocr \
|
||||||
|
tesseract-ocr-data-chi_sim \
|
||||||
|
tesseract-ocr-data-deu \
|
||||||
|
tesseract-ocr-data-eng \
|
||||||
|
tesseract-ocr-data-fra \
|
||||||
|
tesseract-ocr-data-osd \
|
||||||
|
tesseract-ocr-data-por \
|
||||||
|
tesseract-ocr-data-spa \
|
||||||
|
ttf-droid \
|
||||||
|
unpaper \
|
||||||
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /usr/local/lib/ /usr/local/lib/
|
||||||
|
COPY --from=builder /usr/local/bin/ /usr/local/bin/
|
||||||
|
|
||||||
|
COPY --from=builder /app/.venv/ /app/.venv/
|
||||||
|
|
||||||
|
COPY --from=builder /app/misc/webservice.py /app/
|
||||||
|
COPY --from=builder /app/misc/watcher.py /app/
|
||||||
|
|
||||||
|
# Copy minimal project files to get the test suite.
|
||||||
|
COPY --from=builder /app/pyproject.toml /app/README.md /app/
|
||||||
|
COPY --from=builder /app/tests /app/tests
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:${PATH}"
|
||||||
|
|
||||||
|
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
||||||
+65
-10
@@ -28,11 +28,14 @@ jobs:
|
|||||||
python: "3.10"
|
python: "3.10"
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
python: "3.11"
|
python: "3.11"
|
||||||
#- os: ubuntu-latest
|
|
||||||
# python: "pypy3.9"
|
|
||||||
- os: ubuntu-22.04
|
- os: ubuntu-22.04
|
||||||
python: "3.9"
|
python: "3.9"
|
||||||
tesseract5: true
|
tesseract5: true
|
||||||
|
- os: ubuntu-latest
|
||||||
|
python: "3.12-dev"
|
||||||
|
tesseract5: true
|
||||||
|
#- os: ubuntu-latest
|
||||||
|
# python: "pypy3.9"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
OS: ${{ matrix.os }}
|
OS: ${{ matrix.os }}
|
||||||
@@ -44,9 +47,10 @@ jobs:
|
|||||||
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v4
|
||||||
name: Install Python
|
name: Setup Python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install Tesseract 5
|
- name: Install Tesseract 5
|
||||||
if: matrix.tesseract5
|
if: matrix.tesseract5
|
||||||
@@ -109,7 +113,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [macos-latest]
|
os: [macos-latest]
|
||||||
python: ["3.10", "3.11"]
|
python: ["3.10", "3.11", "3.12-dev"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
OS: ${{ matrix.os }}
|
OS: ${{ matrix.os }}
|
||||||
@@ -133,9 +137,10 @@ jobs:
|
|||||||
tesseract
|
tesseract
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v4
|
||||||
name: Install Python
|
name: Setup Python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
@@ -165,7 +170,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [windows-latest]
|
os: [windows-latest]
|
||||||
python: ["3.10", "3.11"]
|
python: ["3.10", "3.11", "3.12-dev"]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
OS: ${{ matrix.os }}
|
OS: ${{ matrix.os }}
|
||||||
@@ -177,9 +182,10 @@ jobs:
|
|||||||
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v4
|
||||||
name: Install Python
|
name: Setup Python
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install system packages
|
- name: Install system packages
|
||||||
run: |
|
run: |
|
||||||
@@ -210,9 +216,10 @@ jobs:
|
|||||||
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v4
|
||||||
name: Install Python
|
name: Setup Python
|
||||||
with:
|
with:
|
||||||
python-version: "3.9"
|
python-version: "3.9"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
- name: Make wheels and sdist
|
- name: Make wheels and sdist
|
||||||
run: |
|
run: |
|
||||||
@@ -268,8 +275,8 @@ jobs:
|
|||||||
./dist/*.whl
|
./dist/*.whl
|
||||||
./dist/*.tar.gz
|
./dist/*.tar.gz
|
||||||
|
|
||||||
docker:
|
docker_ubuntu:
|
||||||
name: Build Docker images
|
name: Build Ubuntu-based Docker image
|
||||||
needs: [wheel_sdist_linux, test_linux, test_macos, test_windows]
|
needs: [wheel_sdist_linux, test_linux, test_macos, test_windows]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
@@ -313,4 +320,52 @@ jobs:
|
|||||||
--push \
|
--push \
|
||||||
--platform linux/arm64/v8,linux/amd64 \
|
--platform linux/arm64/v8,linux/amd64 \
|
||||||
--tag "${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}" \
|
--tag "${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}" \
|
||||||
|
--tag "${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}-ubuntu:${DOCKER_IMAGE_TAG}" \
|
||||||
--file .docker/Dockerfile .
|
--file .docker/Dockerfile .
|
||||||
|
|
||||||
|
docker_alpine:
|
||||||
|
name: Build Alpine-based Docker images
|
||||||
|
needs: [wheel_sdist_linux, test_linux, test_macos, test_windows]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
|
steps:
|
||||||
|
- name: Set image tag to release or branch
|
||||||
|
run: echo "DOCKER_IMAGE_TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: If main, set to latest
|
||||||
|
run: echo 'DOCKER_IMAGE_TAG=latest' >> $GITHUB_ENV
|
||||||
|
if: env.DOCKER_IMAGE_TAG == 'main'
|
||||||
|
|
||||||
|
- name: Set Docker Hub repository to username
|
||||||
|
run: echo "DOCKER_REPOSITORY=jbarlow83" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Set image name
|
||||||
|
run: echo "DOCKER_IMAGE_NAME=ocrmypdf-alpine" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
||||||
|
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: jbarlow83
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Print image tag
|
||||||
|
run: echo "Building image ${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
docker buildx build \
|
||||||
|
--push \
|
||||||
|
--platform linux/amd64 \
|
||||||
|
--tag "${DOCKER_REPOSITORY}/${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}" \
|
||||||
|
--file .docker/Dockerfile.alpine .
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ ocrmypdf # it's a scriptable command line program
|
|||||||
- Distributes work across all available CPU cores
|
- Distributes work across all available CPU cores
|
||||||
- Uses [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) engine to recognize more than [100 languages](https://github.com/tesseract-ocr/tessdata)
|
- Uses [Tesseract OCR](https://github.com/tesseract-ocr/tesseract) engine to recognize more than [100 languages](https://github.com/tesseract-ocr/tessdata)
|
||||||
- Keeps your private data private.
|
- Keeps your private data private.
|
||||||
- Scales properly to handle files with thousands of pages
|
- Scales properly to handle files with thousands of pages.
|
||||||
- Battle-tested on millions of PDFs
|
- Battle-tested on millions of PDFs.
|
||||||
|
|
||||||
<img src="misc/screencast/demo.svg" alt="Demo of OCRmyPDF in a terminal session">
|
<img src="misc/screencast/demo.svg" alt="Demo of OCRmyPDF in a terminal session">
|
||||||
|
|
||||||
|
|||||||
+26
-6
@@ -117,14 +117,14 @@ exceed a certain number of megapixels with ``--skip-big``. (A 300 DPI,
|
|||||||
OCR for huge images
|
OCR for huge images
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
Separate from these settings, Tesseract has internal limits on the size
|
Tesseract has internal limits on the size
|
||||||
of images it will process. If you issue
|
of images it will process. If you issue
|
||||||
``--tesseract-downsample-large-images``, OCRmyPDF will downsample images
|
``--tesseract-downsample-large-images``, OCRmyPDF will downsample images
|
||||||
to fit Tesseract limits. (The limits are usually entered only for scanned
|
to fit Tesseract limits. (The limits are usually entered only for scanned
|
||||||
images of oversized media, such as large maps or blueprints exceeding
|
images of oversized media, such as large maps or blueprints exceeding
|
||||||
110 cm or 43 inches in either dimension, and at high DPI.)
|
110 cm or 43 inches in either dimension, and at high DPI.)
|
||||||
|
|
||||||
``--tesseract-downsample-above`` adjusts the threshold at which images
|
``--tesseract-downsample-above Npixels`` adjusts the threshold at which images
|
||||||
will be downsampled. By default, only images that exceed any of Tesseract's
|
will be downsampled. By default, only images that exceed any of Tesseract's
|
||||||
internal limits are downsampled.
|
internal limits are downsampled.
|
||||||
|
|
||||||
@@ -195,10 +195,10 @@ In each case OCRmyPDF will search the ``PATH`` environment variable to
|
|||||||
locate the binaries. By modifying the ``PATH`` environment variable, you
|
locate the binaries. By modifying the ``PATH`` environment variable, you
|
||||||
can override the binaries that OCRmyPDF uses.
|
can override the binaries that OCRmyPDF uses.
|
||||||
|
|
||||||
Changing tesseract configuration variables
|
Changing Tesseract configuration variables
|
||||||
------------------------------------------
|
------------------------------------------
|
||||||
|
|
||||||
You can override tesseract's default `control
|
You can override Tesseract's default `control
|
||||||
parameters <https://tesseract-ocr.github.io/tessdoc/tess3/ControlParams.html>`__
|
parameters <https://tesseract-ocr.github.io/tessdoc/tess3/ControlParams.html>`__
|
||||||
with a configuration file.
|
with a configuration file.
|
||||||
|
|
||||||
@@ -273,7 +273,7 @@ Unlike ``sandwich`` this renderer is implemented within OCRmyPDF; anyone
|
|||||||
looking to customize how OCR is presented should look here. A major
|
looking to customize how OCR is presented should look here. A major
|
||||||
disadvantage of this renderer is it not capable of correctly handling
|
disadvantage of this renderer is it not capable of correctly handling
|
||||||
text outside the Latin alphabet (specifically, it supports the ISO 8859-1
|
text outside the Latin alphabet (specifically, it supports the ISO 8859-1
|
||||||
character). Pull requests to improve the situation are welcome.
|
character set). Pull requests to improve the situation are welcome.
|
||||||
|
|
||||||
Currently, this renderer has the best compatibility with Mozilla's
|
Currently, this renderer has the best compatibility with Mozilla's
|
||||||
PDF.js viewer.
|
PDF.js viewer.
|
||||||
@@ -286,11 +286,31 @@ Rendering and rasterizing options
|
|||||||
.. versionadded:: 14.3.0
|
.. versionadded:: 14.3.0
|
||||||
|
|
||||||
The ``--continue-on-soft-render-error`` option allows OCRmyPDF to
|
The ``--continue-on-soft-render-error`` option allows OCRmyPDF to
|
||||||
proceed if a page cannot be rasterized rendered. This is useful if you are
|
proceed if a page cannot be rasterized/rendered. This is useful if you are
|
||||||
trying to get the best possible OCR from a PDF that is not well-formed,
|
trying to get the best possible OCR from a PDF that is not well-formed,
|
||||||
and you are willing to accept some pages that may not visually match the
|
and you are willing to accept some pages that may not visually match the
|
||||||
input, and that may not OCR well.
|
input, and that may not OCR well.
|
||||||
|
|
||||||
|
Color conversion strategy
|
||||||
|
=========================
|
||||||
|
|
||||||
|
.. versionadded:: 15.0.0
|
||||||
|
|
||||||
|
OCRmyPDF uses Ghostscript to convert PDF to PDF/A. In some cases, this
|
||||||
|
conversion requires color conversion. The default strategy is to convert
|
||||||
|
using the ``LeaveColorUnchanged`` strategy, which preserves the original
|
||||||
|
color space wherever possible (some rare color spaces might still be
|
||||||
|
converted).
|
||||||
|
|
||||||
|
Usually document scanners produce PDFs in the sRGB color space, and do
|
||||||
|
not need to be converted, so the default strategy is appropriate.
|
||||||
|
|
||||||
|
Suppose that you have a document that was prepared for professional
|
||||||
|
printing in a Separation or CMYK color space, and text was converted to
|
||||||
|
curves. In this case, you may want to use a different color conversion
|
||||||
|
strategy. The ``--color-conversion-strategy`` option allows you to select a
|
||||||
|
different strategy, such as ``RGB``.
|
||||||
|
|
||||||
Return code policy
|
Return code policy
|
||||||
==================
|
==================
|
||||||
|
|
||||||
|
|||||||
+24
-8
@@ -39,9 +39,7 @@ Parent process requirements
|
|||||||
The :func:`ocrmypdf.ocr` function runs OCRmyPDF similar to command line
|
The :func:`ocrmypdf.ocr` function runs OCRmyPDF similar to command line
|
||||||
execution. To do this, it will:
|
execution. To do this, it will:
|
||||||
|
|
||||||
- create a monitoring thread
|
- create worker processes or threads
|
||||||
- create worker processes (on Linux, forking itself; on Windows and macOS, by
|
|
||||||
spawning)
|
|
||||||
- manage the signal flags of its worker processes
|
- manage the signal flags of its worker processes
|
||||||
- execute other subprocesses (forking and executing other programs)
|
- execute other subprocesses (forking and executing other programs)
|
||||||
|
|
||||||
@@ -54,7 +52,19 @@ processes.
|
|||||||
|
|
||||||
Creating a child process to call :func:`ocrmypdf.ocr()` is suggested. That
|
Creating a child process to call :func:`ocrmypdf.ocr()` is suggested. That
|
||||||
way your application will survive and remain interactive even if
|
way your application will survive and remain interactive even if
|
||||||
OCRmyPDF fails for any reason.
|
OCRmyPDF fails for any reason. For example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from multiprocessing import Process
|
||||||
|
|
||||||
|
def ocrmypdf_process():
|
||||||
|
ocrmypdf.ocr('input.pdf', 'output.pdf')
|
||||||
|
|
||||||
|
def call_ocrmypdf_from_my_app():
|
||||||
|
p = Process(target=ocrmypdf_process)
|
||||||
|
p.start()
|
||||||
|
p.join()
|
||||||
|
|
||||||
Programs that call :func:`ocrmypdf.ocr()` should also install a SIGBUS signal
|
Programs that call :func:`ocrmypdf.ocr()` should also install a SIGBUS signal
|
||||||
handler (except on Windows), to raise an exception if access to a memory
|
handler (except on Windows), to raise an exception if access to a memory
|
||||||
@@ -89,12 +99,21 @@ your use case.
|
|||||||
Progress monitoring
|
Progress monitoring
|
||||||
-------------------
|
-------------------
|
||||||
|
|
||||||
OCRmyPDF uses the ``tqdm`` package to implement its progress bars.
|
OCRmyPDF uses the ``rich`` package to implement its progress bars.
|
||||||
:func:`ocrmypdf.configure_logging` will set up logging output to
|
:func:`ocrmypdf.configure_logging` will set up logging output to
|
||||||
``sys.stderr`` in a way that is compatible with the display of the
|
``sys.stderr`` in a way that is compatible with the display of the
|
||||||
progress bar. Use ``ocrmypdf.ocr(...progress_bar=False)`` to disable
|
progress bar. Use ``ocrmypdf.ocr(...progress_bar=False)`` to disable
|
||||||
the progress bar.
|
the progress bar.
|
||||||
|
|
||||||
|
Standard output
|
||||||
|
---------------
|
||||||
|
|
||||||
|
OCRmyPDF is strict about not writing to standard output so that
|
||||||
|
users can safely use it in a pipeline and produce a valid output
|
||||||
|
file. A caller application will have to ensure it does not write to
|
||||||
|
standard output either, if it wants to be compatible with this
|
||||||
|
behavior and support piping to a file.
|
||||||
|
|
||||||
Exceptions
|
Exceptions
|
||||||
----------
|
----------
|
||||||
|
|
||||||
@@ -104,9 +123,6 @@ exceptions, some exceptions related to multiprocessing, and
|
|||||||
handler. OCRmyPDF will clean up its temporary files and worker processes
|
handler. OCRmyPDF will clean up its temporary files and worker processes
|
||||||
automatically when an exception occurs.
|
automatically when an exception occurs.
|
||||||
|
|
||||||
Programs that call OCRmyPDF should consider trapping KeyboardInterrupt
|
|
||||||
so that they allow OCR to terminate with the whole program terminating.
|
|
||||||
|
|
||||||
When OCRmyPDF succeeds conditionally, it returns an integer exit code.
|
When OCRmyPDF succeeds conditionally, it returns an integer exit code.
|
||||||
|
|
||||||
Reference
|
Reference
|
||||||
|
|||||||
+57
-26
@@ -8,14 +8,13 @@
|
|||||||
OCRmyPDF Docker image
|
OCRmyPDF Docker image
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
OCRmyPDF is also available in a Docker image that packages recent
|
OCRmyPDF is also available in Docker images that packages recent
|
||||||
versions of all dependencies.
|
versions of all dependencies.
|
||||||
|
|
||||||
For users who already have Docker installed this may be an easy and
|
For users who already have Docker installed this may be an easy and
|
||||||
convenient option. However, it is less performant than a system
|
convenient option. However, it is less performant than a system
|
||||||
installation and may require Docker engine configuration.
|
installation and may require Docker engine configuration. OCRmyPDF
|
||||||
|
needs a generous amount of RAM, CPU cores, temporary storage
|
||||||
OCRmyPDF needs a generous amount of RAM, CPU cores, temporary storage
|
|
||||||
space, whether running in a Docker container or on its own. It may be
|
space, whether running in a Docker container or on its own. It may be
|
||||||
necessary to ensure the container is provisioned with additional
|
necessary to ensure the container is provisioned with additional
|
||||||
resources.
|
resources.
|
||||||
@@ -35,28 +34,37 @@ execute the image:
|
|||||||
|
|
||||||
docker run hello-world
|
docker run hello-world
|
||||||
|
|
||||||
The recommended OCRmyPDF Docker image is currently named ``ocrmypdf``:
|
.. list-table:: Docker images
|
||||||
|
:width: 30 20 50
|
||||||
|
:header-rows: 1
|
||||||
|
|
||||||
|
* - Image
|
||||||
|
- Architecture
|
||||||
|
- Description
|
||||||
|
* - ``jbarlow83/ocrmypdf-alpine``
|
||||||
|
- x86_64 only
|
||||||
|
- Recommended image, based on Alpine Linux.
|
||||||
|
* - ``jbarlow83/ocrmypdf-ubuntu``
|
||||||
|
- x86_64 and arm64
|
||||||
|
- Alternate image, based on Ubuntu. When the Alpine image is considered
|
||||||
|
stable and available for arm64, this image will be deprecated.
|
||||||
|
* - ``jbarlow83/ocrmypdf``
|
||||||
|
- x86_64 and arm64
|
||||||
|
- Currently an alias for ocrmypdf-ubuntu. When the Alpine image is
|
||||||
|
considered stable and available for arm64, this name point to the
|
||||||
|
Alpine image. If you don't about the difference between Alpine and
|
||||||
|
Ubuntu, use this image.
|
||||||
|
|
||||||
|
To install:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
docker pull jbarlow83/ocrmypdf
|
docker pull jbarlow83/ocrmypdf-alpine
|
||||||
|
|
||||||
|
The ``ocrmypdf`` image is also available, but is deprecated and will be removed
|
||||||
|
in the future.
|
||||||
|
|
||||||
OCRmyPDF will use all available CPU cores. By default, the VirtualBox
|
OCRmyPDF will use all available CPU cores. See the Docker documentation for
|
||||||
machine instance on Windows and macOS has only a single CPU core
|
|
||||||
enabled. Use the VirtualBox Manager to determine the name of your Docker
|
|
||||||
engine host, and then follow these optional steps to enable multiple
|
|
||||||
CPUs:
|
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
# Optional step for Mac OS X users
|
|
||||||
docker-machine stop "yourVM"
|
|
||||||
VBoxManage modifyvm "yourVM" --cpus 2 # or whatever number of core is desired
|
|
||||||
docker-machine start "yourVM"
|
|
||||||
eval $(docker-machine env "yourVM")
|
|
||||||
|
|
||||||
See the Docker documentation for
|
|
||||||
`adjusting memory and CPU on other platforms <https://docs.docker.com/config/containers/resource_constraints/>`__.
|
`adjusting memory and CPU on other platforms <https://docs.docker.com/config/containers/resource_constraints/>`__.
|
||||||
|
|
||||||
Using the Docker image on the command line
|
Using the Docker image on the command line
|
||||||
@@ -66,6 +74,8 @@ Using the Docker image on the command line
|
|||||||
container is ephemeral – it runs for one OCR job and terminates, just like a
|
container is ephemeral – it runs for one OCR job and terminates, just like a
|
||||||
command line program. We are using Docker to deliver an application (as opposed
|
command line program. We are using Docker to deliver an application (as opposed
|
||||||
to the more conventional case, where a Docker container runs as a server).
|
to the more conventional case, where a Docker container runs as a server).
|
||||||
|
For that reason we usually use the ``--rm`` argument to delete the container
|
||||||
|
when it exits.
|
||||||
|
|
||||||
To start a Docker container (instance of the image):
|
To start a Docker container (instance of the image):
|
||||||
|
|
||||||
@@ -132,17 +142,35 @@ You can then add new data with either a Dockerfile:
|
|||||||
|
|
||||||
.. code-block:: dockerfile
|
.. code-block:: dockerfile
|
||||||
|
|
||||||
FROM jbarlow83/ocrmypdf
|
FROM jbarlow83/ocrmypdf:{TAG}
|
||||||
|
|
||||||
# Example: add a tessdata_best file
|
# Example: add a tessdata_best file
|
||||||
COPY chi_tra_vert.traineddata /usr/share/tesseract-ocr/<data version>/tessdata/
|
COPY chi_tra_vert.traineddata /usr/share/tesseract-ocr/<data version>/tessdata/
|
||||||
|
|
||||||
|
When creating your own image, you should always pin a specific version of the
|
||||||
|
OCRmyPDF Docker image. This ensures that your image will not break when a new
|
||||||
|
version of OCRmyPDF is released.
|
||||||
|
|
||||||
Alternately, you can copy training data into a Docker container as follows:
|
Alternately, you can copy training data into a Docker container as follows:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
docker cp mycustomtraining.traineddata name_of_container:/usr/share/tesseract-ocr/<tesseract version>/tessdata/
|
docker cp mycustomtraining.traineddata name_of_container:/usr/share/tesseract-ocr/<tesseract version>/tessdata/
|
||||||
|
|
||||||
|
Extending the Docker image
|
||||||
|
==========================
|
||||||
|
|
||||||
|
You can extend the Docker image with your own customizations, similar to the way
|
||||||
|
it is extended to add language packs.
|
||||||
|
|
||||||
|
Note that the Docker image is subject to change at any time. For example, the base
|
||||||
|
image may be updated to a newer version of Ubuntu or Debian. Such changes will be
|
||||||
|
noted in the release notes but might occur at minor versions releases, unless the
|
||||||
|
way a "casual" user of the Docker image is affected.
|
||||||
|
|
||||||
|
If you extend the Docker image, you should pin a specific version of the OCRmyPDF
|
||||||
|
Docker image.
|
||||||
|
|
||||||
Executing the test suite
|
Executing the test suite
|
||||||
========================
|
========================
|
||||||
|
|
||||||
@@ -150,16 +178,16 @@ The OCRmyPDF test suite is installed with image. To run it:
|
|||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
docker run --entrypoint python3 jbarlow83/ocrmypdf -m pytest
|
docker run --rm --entrypoint python jbarlow83/ocrmypdf -m pytest
|
||||||
|
|
||||||
Accessing the shell
|
Accessing the shell
|
||||||
===================
|
===================
|
||||||
|
|
||||||
To use the bash shell in the Docker image:
|
To use the shell in the Docker image:
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
docker run -it --entrypoint bash jbarlow83/ocrmypdf
|
docker run -it --entrypoint sh jbarlow83/ocrmypdf
|
||||||
|
|
||||||
Using the OCRmyPDF web service wrapper
|
Using the OCRmyPDF web service wrapper
|
||||||
======================================
|
======================================
|
||||||
@@ -169,7 +197,10 @@ service. The webservice may be launched as follows:
|
|||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
docker run --entrypoint python3 -p 5000:5000 jbarlow83/ocrmypdf webservice.py
|
docker run --entrypoint python -p 5000:5000 jbarlow83/ocrmypdf webservice.py
|
||||||
|
|
||||||
|
We omit the ``--rm`` parameter so that the container will not be
|
||||||
|
automatically deleted when it exits.
|
||||||
|
|
||||||
This will configure the machine to listen on port 5000. On Linux machines
|
This will configure the machine to listen on port 5000. On Linux machines
|
||||||
this is port 5000 of localhost. On macOS or Windows machines running
|
this is port 5000 of localhost. On macOS or Windows machines running
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ files, allowing them to be searched.
|
|||||||
|
|
||||||
PDF is the best format for storing and exchanging scanned documents.
|
PDF is the best format for storing and exchanging scanned documents.
|
||||||
Unfortunately, PDFs can be difficult to modify. OCRmyPDF makes it easy to apply
|
Unfortunately, PDFs can be difficult to modify. OCRmyPDF makes it easy to apply
|
||||||
image processing and OCR to existing PDFs.
|
image processing and OCR (recognized, searchable text) to existing PDFs.
|
||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
|
|||||||
+47
-32
@@ -72,7 +72,7 @@ Debian and Ubuntu 20.04 or newer
|
|||||||
| |ubu-2004| |ubu-2204| |
|
| |ubu-2004| |ubu-2204| |
|
||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
|
|
||||||
Users of Debian 11, or Ubuntu 20.04 LTS, or newer may simply
|
Users of Debian or Ubuntu may simply
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
@@ -99,11 +99,11 @@ For full details on version availability for your platform, check the
|
|||||||
Fedora
|
Fedora
|
||||||
------
|
------
|
||||||
|
|
||||||
.. |fedora-35| image:: https://repology.org/badge/version-for-repo/fedora_35/ocrmypdf.svg
|
.. |fedora-37| image:: https://repology.org/badge/version-for-repo/fedora_37/ocrmypdf.svg
|
||||||
:alt: Fedora 35
|
:alt: Fedora 37
|
||||||
|
|
||||||
.. |fedora-36| image:: https://repology.org/badge/version-for-repo/fedora_36/ocrmypdf.svg
|
.. |fedora-38| image:: https://repology.org/badge/version-for-repo/fedora_38/ocrmypdf.svg
|
||||||
:alt: Fedora 36
|
:alt: Fedora 38
|
||||||
|
|
||||||
.. |fedora-rawhide| image:: https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
|
.. |fedora-rawhide| image:: https://repology.org/badge/version-for-repo/fedora_rawhide/ocrmypdf.svg
|
||||||
:alt: Fedore Rawhide
|
:alt: Fedore Rawhide
|
||||||
@@ -113,10 +113,10 @@ Fedora
|
|||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
| |latest| |
|
| |latest| |
|
||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
| |fedora-35| |fedora-36| |fedora-rawhide| |
|
| |fedora-37| |fedora-38| |fedora-rawhide| |
|
||||||
+-----------------------------------------------+
|
+-----------------------------------------------+
|
||||||
|
|
||||||
Users of Fedora 29 or later may simply
|
Users of Fedora may simply
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
@@ -155,7 +155,7 @@ user, follow these steps:
|
|||||||
|
|
||||||
If you get the message ``WARNING: The script ocrmypdf is installed in
|
If you get the message ``WARNING: The script ocrmypdf is installed in
|
||||||
'/home/$USER/.local/bin' which is not on PATH.``, you may need to re-login
|
'/home/$USER/.local/bin' which is not on PATH.``, you may need to re-login
|
||||||
or open a new shell, or manually add this to your user's PATH.
|
or open a new shell, or manually adjust your PATH.
|
||||||
|
|
||||||
To add JBIG2 encoding, see :ref:`jbig2`.
|
To add JBIG2 encoding, see :ref:`jbig2`.
|
||||||
|
|
||||||
@@ -382,29 +382,33 @@ Native Windows
|
|||||||
|
|
||||||
You must install the following for Windows:
|
You must install the following for Windows:
|
||||||
|
|
||||||
* Python 3.9 (64-bit) or later
|
* Python 64-bit
|
||||||
* Tesseract 4.1.1 (64-bit) or later
|
* Tesseract 64-bit
|
||||||
* Ghostscript 9.50 (64-bit) or later
|
* Ghostscript 64-bit
|
||||||
|
|
||||||
Using the `Chocolatey <https://chocolatey.org/>`_ package manager, install the
|
Using the `winget <https://docs.microsoft.com/en-us/windows/package-manager/winget/>`_
|
||||||
following when running in an Administrator command prompt:
|
package manager:
|
||||||
|
|
||||||
|
* ``winget install -e --id Python.Python.3.11``
|
||||||
|
* ``winget install -e --id UB-Mannheim.TesseractOCR``
|
||||||
|
* ``winget install -e --id ArtifexSoftware.GhostScript``
|
||||||
|
|
||||||
|
|
||||||
|
(Or alternately, using the `Chocolatey <https://chocolatey.org/>`_ package manager, install
|
||||||
|
the following when running in an Administrator command prompt):
|
||||||
|
|
||||||
* ``choco install python3``
|
* ``choco install python3``
|
||||||
* ``choco install --pre tesseract``
|
* ``choco install --pre tesseract``
|
||||||
* ``choco install ghostscript``
|
* ``choco install ghostscript``
|
||||||
* ``choco install pngquant`` (optional)
|
* ``choco install pngquant`` (optional)
|
||||||
|
|
||||||
The commands above will install Python 3.x (latest version), Tesseract, Ghostscript
|
Either set of commands will install the required software. At the mmoment there is no
|
||||||
and pngquant. Chocolatey may also need to install the Windows Visual C++ Runtime
|
single command to install Windows.
|
||||||
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
|
You may then use ``pip`` to install ocrmypdf. (This can performed by a user or
|
||||||
Administrator.):
|
Administrator.):
|
||||||
|
|
||||||
* ``pip install ocrmypdf``
|
* ``python3 -m pip install ocrmypdf``
|
||||||
|
|
||||||
Chocolatey automatically selects appropriate versions of these applications. Please make sure
|
|
||||||
you are installing the 64-bit versions.
|
|
||||||
|
|
||||||
OCRmyPDF will check the Windows Registry and standard locations in your Program Files
|
OCRmyPDF will check the Windows Registry and standard locations in your Program Files
|
||||||
for third party software it needs (specifically, Tesseract and Ghostscript). To
|
for third party software it needs (specifically, Tesseract and Ghostscript). To
|
||||||
@@ -416,12 +420,12 @@ to change the PATH.
|
|||||||
|
|
||||||
As of early 2021, users have reported problems with the Microsoft Store version of
|
As of early 2021, users have reported problems with the Microsoft Store version of
|
||||||
Python and OCRmyPDF. These issues affect many other third party Python packages.
|
Python and OCRmyPDF. These issues affect many other third party Python packages.
|
||||||
Please download Python from Python.org or Chocolatey instead, and do not use the
|
Please download Python from Python.org or a package manager instead of the
|
||||||
Microsoft Store version.
|
Microsoft Store version.
|
||||||
|
|
||||||
.. warning::
|
.. warning::
|
||||||
|
|
||||||
32-bit Windows might work, but is not supported.
|
32-bit Windows is not supported.
|
||||||
|
|
||||||
Windows Subsystem for Linux
|
Windows Subsystem for Linux
|
||||||
---------------------------
|
---------------------------
|
||||||
@@ -448,7 +452,7 @@ Cygwin64
|
|||||||
|
|
||||||
First install the the following prerequisite Cygwin packages using ``setup-x86_64.exe``::
|
First install the the following prerequisite Cygwin packages using ``setup-x86_64.exe``::
|
||||||
|
|
||||||
python38 (or later)
|
python39 (or later)
|
||||||
python3?-devel
|
python3?-devel
|
||||||
python3?-pip
|
python3?-pip
|
||||||
python3?-lxml
|
python3?-lxml
|
||||||
@@ -457,7 +461,7 @@ First install the the following prerequisite Cygwin packages using ``setup-x86_6
|
|||||||
(where 3? means match the version of python3 you installed)
|
(where 3? means match the version of python3 you installed)
|
||||||
|
|
||||||
gcc-g++
|
gcc-g++
|
||||||
ghostscript (<=9.50 or >=9.52-2 see note below)
|
ghostscript
|
||||||
libexempi3
|
libexempi3
|
||||||
libexempi-devel
|
libexempi-devel
|
||||||
libffi6
|
libffi6
|
||||||
@@ -468,13 +472,6 @@ First install the the following prerequisite Cygwin packages using ``setup-x86_6
|
|||||||
tesseract-ocr
|
tesseract-ocr
|
||||||
tesseract-ocr-devel
|
tesseract-ocr-devel
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
The Cygwin package for Ghostscript in versions 9.52 and
|
|
||||||
9.52-1 contained a bug that caused an exception to occur when
|
|
||||||
ocrmypdf invoked gs. Make sure you have either 9.50 (or earlier)
|
|
||||||
or 9.52-2 (or later).
|
|
||||||
|
|
||||||
Then open a Cygwin terminal (i.e. ``mintty``), run the following commands. Note
|
Then open a Cygwin terminal (i.e. ``mintty``), run the following commands. Note
|
||||||
that if you are using the version of ``pip`` that was installed with the Cygwin
|
that if you are using the version of ``pip`` that was installed with the Cygwin
|
||||||
Python package, the command name will be ``pip3``. If you have since updated
|
Python package, the command name will be ``pip3``. If you have since updated
|
||||||
@@ -554,12 +551,15 @@ manager. ``pip`` cannot provide them.
|
|||||||
The following versions are required:
|
The following versions are required:
|
||||||
|
|
||||||
- Python 3.9 or newer
|
- Python 3.9 or newer
|
||||||
- Ghostscript 9.50 or newer
|
- Ghostscript 9.55 or newer
|
||||||
- Tesseract 4.1.1 or newer
|
- Tesseract 4.1.1 or newer
|
||||||
- jbig2enc 0.29 or newer
|
- jbig2enc 0.29 or newer
|
||||||
- pngquant 2.5 or newer
|
- pngquant 2.5 or newer
|
||||||
- unpaper 6.1
|
- unpaper 6.1
|
||||||
|
|
||||||
|
We recommend 64-bit versions of all software. (32-bit versions are not
|
||||||
|
supported, although on Linux, they may still work.)
|
||||||
|
|
||||||
jbig2enc, pngquant, and unpaper are optional. If missing certain
|
jbig2enc, pngquant, and unpaper are optional. If missing certain
|
||||||
features are disabled. OCRmyPDF will discover them as soon as they are
|
features are disabled. OCRmyPDF will discover them as soon as they are
|
||||||
available.
|
available.
|
||||||
@@ -665,3 +665,18 @@ To manually install the ``bash`` completion, copy
|
|||||||
To manually install the ``fish`` completion, copy
|
To manually install the ``fish`` completion, copy
|
||||||
``misc/completion/ocrmypdf.fish`` to
|
``misc/completion/ocrmypdf.fish`` to
|
||||||
``~/.config/fish/completions/ocrmypdf.fish``.
|
``~/.config/fish/completions/ocrmypdf.fish``.
|
||||||
|
|
||||||
|
Note on 32-bit support
|
||||||
|
======================
|
||||||
|
|
||||||
|
Many Python libraries no longer 32-bit binary wheels for Linux. This
|
||||||
|
includes many of the libraries that OCRmyPDF depends on, such as
|
||||||
|
Pillow. The easiest way to express this to end users is to say we don't
|
||||||
|
support 32-bit Linux.
|
||||||
|
|
||||||
|
However, if your Linux distribution still supports 32-bit binaries, you
|
||||||
|
can still install and use OCRmyPDF. A warning message will appear.
|
||||||
|
In practice, OCRmyPDF may need more than 32-bit memory space to run when
|
||||||
|
large documents are processed, so there are practical limitations to what
|
||||||
|
users can accomplish with it. Still, for the common use case of an 32-bit
|
||||||
|
ARM NAS or Raspberry Pi processing small documents, it should work.
|
||||||
@@ -59,5 +59,9 @@ To turn on JBIG2 lossy mode, add the argument ``--jbig2-lossy``.
|
|||||||
also required. Also, a JBIG2 encoder must be installed as described in
|
also required. Also, a JBIG2 encoder must be installed as described in
|
||||||
the previous section.
|
the previous section.
|
||||||
|
|
||||||
|
You can adjust the threshold for JBIG2 compression with the
|
||||||
|
``--jbig2-threshold``. The default is 0.85, meaning that if two symbols
|
||||||
|
are 85% similar, they will be compressed together.
|
||||||
|
|
||||||
*Due to an oversight, ocrmypdf v7.0 and v7.1 used lossy mode by
|
*Due to an oversight, ocrmypdf v7.0 and v7.1 used lossy mode by
|
||||||
default.*
|
default.*
|
||||||
|
|||||||
@@ -56,4 +56,12 @@ improve OCRmyPDF's compression.
|
|||||||
Command line completions
|
Command line completions
|
||||||
------------------------
|
------------------------
|
||||||
|
|
||||||
Please ensure that command line completions are installed.
|
Please ensure that command line completions are installed, as described in the
|
||||||
|
installation documentation.
|
||||||
|
|
||||||
|
32-bit Linux support
|
||||||
|
--------------------
|
||||||
|
|
||||||
|
If you maintain a Linux distribution that supports 32-bit x86 or ARM, OCRmyPDF
|
||||||
|
should continue to work as long as all of its dependencies continue to be
|
||||||
|
available in 32-bit form. Please note we do not test on 32-bit platforms.
|
||||||
+2
-2
@@ -45,8 +45,8 @@ Optimizations that always occurs
|
|||||||
================================
|
================================
|
||||||
|
|
||||||
OCRmyPDF will automatically replace obsolete or inferior compression schemes
|
OCRmyPDF will automatically replace obsolete or inferior compression schemes
|
||||||
such as RLE or LZW with superior schemes such as Deflate and converting
|
such as RLE or LZW with superior schemes such as Deflate, and convert
|
||||||
monochrome images to CCITT G4. Since this is harmless it always occurs and there
|
monochrome images to CCITT G4. Since this is lossless, it always occurs and there
|
||||||
is no way to disable it. Other non-image compressed objects are compressed as
|
is no way to disable it. Other non-image compressed objects are compressed as
|
||||||
well.
|
well.
|
||||||
|
|
||||||
|
|||||||
+52
-12
@@ -28,31 +28,71 @@ tagged yet.
|
|||||||
|
|
||||||
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
||||||
|
|
||||||
|
v15.2.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Added a Docker image based on Alpine Linux. This image is smaller than the
|
||||||
|
Ubuntu-based image and may be useful in some situations. Currently hosted at
|
||||||
|
jbarlow83/ocrmypdf-alpine. Currently not available in ARM flavor.
|
||||||
|
- The Ubuntu Docker is now aliased to jbarlow83/ocrmypdf-ubuntu.
|
||||||
|
- Updated Docker documentation.
|
||||||
|
|
||||||
|
v15.1.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- We now require Pillow 10.0.1, due a serious security vulnerability in all earlier
|
||||||
|
versions of that dependency. The vulnerability concerns WebP images and could
|
||||||
|
be triggered in OCRmyPDF when creating a PDF from a malicious WebP image.
|
||||||
|
- Added some keyword arguments to ``ocrmypdf.ocr`` that were previously accepted
|
||||||
|
but undocumented.
|
||||||
|
- Documentation updates and typing improvements.
|
||||||
|
|
||||||
|
v15.0.2
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Added Python 3.12 to test matrix.
|
||||||
|
- Updated documentation for notes on Python 3.12, 32-bit support and some new
|
||||||
|
features in v15.
|
||||||
|
|
||||||
|
v15.0.1
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Wheels Python tag changed to py39.
|
||||||
|
- Marked as a expected fail a test that fails on recent Ghostscript versions.
|
||||||
|
- Clarified documentation and release notes around the extent of 32-bit support.
|
||||||
|
- Updated installation documentation to changes in v15.
|
||||||
|
|
||||||
v15.0.0
|
v15.0.0
|
||||||
=======
|
=======
|
||||||
|
|
||||||
- Dropped support for Python 3.8.
|
- Dropped support for Python 3.8.
|
||||||
- Dropped support many older dependencies - see ``pyproject.toml`` for details.
|
- Dropped support some older dependencies, specifically ``coloredlogs`` and
|
||||||
Generally speaking, Ubuntu 22.04 is our baseline system.
|
``tqdm`` in favor of rich - see ``pyproject.toml`` for details.
|
||||||
- Dropped support 32-bit Windows and Linux. You must use a 64-bit operating system,
|
Generally speaking, Ubuntu 22.04 is our new baseline system.
|
||||||
and 64-bit versions of Python, Tesseract and Ghostscript to use OCRmyPDF. Many of
|
- Tightened version requirements for some dependencies.
|
||||||
our dependencies are dropping 32-bit support (e.g. Pillow), and we are following
|
- Dropped support for 32-bit Linux wheels. We strongly recommend a 64-bit operating
|
||||||
suit.
|
system, and 64-bit versions of Python, Tesseract and Ghostscript to use OCRmyPDF.
|
||||||
|
Many of our dependencies are dropping 32-bit builds (e.g. Pillow), and we are
|
||||||
|
following suit. (Maintainers may still build 32-bit versions from source.)
|
||||||
- Changed to trusted release for PyPI publishing.
|
- Changed to trusted release for PyPI publishing.
|
||||||
- pikepdf memory mapping is enabled again for improved performance, now an issue
|
- pikepdf memory mapping is enabled again for improved performance, now that an
|
||||||
with pikepdf has been fixed.
|
issue with feature in pikepdf is fixed.
|
||||||
- ``ocrmypdf.helpers.calculate_downsample`` previously had two variants, one
|
- ``ocrmypdf.helpers.calculate_downsample`` previously had two variants, one
|
||||||
that took a ``PIL.Image`` and one that took a ``tuple[int, int]``. The latter
|
that took a ``PIL.Image`` and one that took a ``tuple[int, int]``. The latter
|
||||||
was removed.
|
was removed.
|
||||||
- The snap version of ocrmypdf is now based on Ubuntu core22.
|
- The snap version of ocrmypdf is now based on Ubuntu core22.
|
||||||
- We now account situations where a small portion of an image on a page reports a
|
- We now account for situations where a small portion of an image on a page is drawn
|
||||||
high DPI (resolution). Previously, the entire page would be rasterized at the
|
at high DPI (resolution). Previously, the entire page would be rasterized at the
|
||||||
highest resolution, which caused performance problems. Now, the page is rasterized
|
highest resolution of any feature, which caused performance problems. Now,
|
||||||
|
the page is rasterized
|
||||||
at a resolution based on the average DPI of the page, weighted by the area that
|
at a resolution based on the average DPI of the page, weighted by the area that
|
||||||
each feature occupies. Typically, small areas of high resolution in PDFs are
|
each feature occupies. Typically, small areas of high resolution in PDFs are
|
||||||
errors or quirks from the repeated use of assets and high resolution is not
|
errors or quirks from the repeated use of assets and high resolution is not
|
||||||
beneficial. :issue:`1010,1104,1004,1079,1010`
|
beneficial. :issue:`1010,1104,1004,1079,1010`
|
||||||
- Ghostscript color conversion strategy is now configurable. :issue:`1143`
|
- Ghostscript color conversion strategy is now configurable using
|
||||||
|
``--color-conversion-strategy``. :issue:`1143`
|
||||||
|
- JBIG2 threshold for optimization is now configurable using
|
||||||
|
``--jbig2-threshold``. :issue:`1133`
|
||||||
|
|
||||||
v14.4.0
|
v14.4.0
|
||||||
=======
|
=======
|
||||||
|
|||||||
+4
-4
@@ -12,7 +12,7 @@ readme = "README.md"
|
|||||||
license = { text = "MPL-2.0" }
|
license = { text = "MPL-2.0" }
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"Pillow>=9.0.1",
|
"Pillow>=10.0.1",
|
||||||
"deprecation>=2.1.0",
|
"deprecation>=2.1.0",
|
||||||
"img2pdf>=0.4.4",
|
"img2pdf>=0.4.4",
|
||||||
"packaging>=20",
|
"packaging>=20",
|
||||||
@@ -77,11 +77,11 @@ namespaces = false
|
|||||||
[tool.setuptools_scm]
|
[tool.setuptools_scm]
|
||||||
|
|
||||||
[tool.distutils.bdist_wheel]
|
[tool.distutils.bdist_wheel]
|
||||||
python-tag = "py38"
|
python-tag = "py39"
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
target-version = ["py38", "py39", "py310", "py311"]
|
target-version = ["py39", "py310", "py311"]
|
||||||
skip-string-normalization = true
|
skip-string-normalization = true
|
||||||
include = '\.pyi?$'
|
include = '\.pyi?$'
|
||||||
exclude = '''
|
exclude = '''
|
||||||
@@ -157,7 +157,7 @@ select = [
|
|||||||
"I001", # isort
|
"I001", # isort
|
||||||
"UP", # pyupgrade
|
"UP", # pyupgrade
|
||||||
]
|
]
|
||||||
target-version = "py38"
|
target-version = "py39"
|
||||||
|
|
||||||
[tool.ruff.isort]
|
[tool.ruff.isort]
|
||||||
known-first-party = ["ocrmypdf"]
|
known-first-party = ["ocrmypdf"]
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import threading
|
import threading
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Callable, Iterable
|
from collections.abc import Iterable
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
def _task_noop(*_args, **_kwargs):
|
def _task_noop(*_args, **_kwargs):
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from os import fspath
|
from os import fspath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -21,14 +20,6 @@ from ocrmypdf.exceptions import SubprocessOutputError
|
|||||||
from ocrmypdf.helpers import Resolution
|
from ocrmypdf.helpers import Resolution
|
||||||
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
|
from ocrmypdf.subprocess import get_version, run, run_polling_stderr
|
||||||
|
|
||||||
# Remove this workaround when we require Pillow >= 10
|
|
||||||
try:
|
|
||||||
Transpose = Image.Transpose # type: ignore
|
|
||||||
except AttributeError:
|
|
||||||
# Pillow 9 shim
|
|
||||||
Transpose = Image # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
COLOR_CONVERSION_STRATEGIES = frozenset(
|
COLOR_CONVERSION_STRATEGIES = frozenset(
|
||||||
[
|
[
|
||||||
'CMYK',
|
'CMYK',
|
||||||
@@ -138,11 +129,11 @@ def rasterize_pdf(
|
|||||||
# rotation is a clockwise angle and Image.ROTATE_* is
|
# rotation is a clockwise angle and Image.ROTATE_* is
|
||||||
# counterclockwise so this cancels out the rotation
|
# counterclockwise so this cancels out the rotation
|
||||||
if rotation == 90:
|
if rotation == 90:
|
||||||
im = im.transpose(Transpose.ROTATE_90)
|
im = im.transpose(Image.Transpose.ROTATE_90)
|
||||||
elif rotation == 180:
|
elif rotation == 180:
|
||||||
im = im.transpose(Transpose.ROTATE_180)
|
im = im.transpose(Image.Transpose.ROTATE_180)
|
||||||
elif rotation == 270:
|
elif rotation == 270:
|
||||||
im = im.transpose(Transpose.ROTATE_270)
|
im = im.transpose(Image.Transpose.ROTATE_270)
|
||||||
if rotation % 180 == 90:
|
if rotation % 180 == 90:
|
||||||
page_dpi = page_dpi.flip_axis()
|
page_dpi = page_dpi.flip_axis()
|
||||||
im.save(fspath(output_file), dpi=page_dpi)
|
im.save(fspath(output_file), dpi=page_dpi)
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE, STDOUT
|
from subprocess import PIPE, STDOUT
|
||||||
from typing import Iterator, Union
|
from typing import Union
|
||||||
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
|
from collections.abc import Iterator
|
||||||
from copy import copy
|
from copy import copy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator
|
|
||||||
|
|
||||||
from pluggy import PluginManager
|
from pluggy import PluginManager
|
||||||
|
|
||||||
|
|||||||
+85
-53
@@ -10,11 +10,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Iterable, Iterator, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import copyfileobj
|
from shutil import copyfileobj
|
||||||
from typing import Any, BinaryIO, Iterable, Iterator, Sequence, cast
|
from typing import Any, BinaryIO, TypeVar, cast
|
||||||
|
|
||||||
import img2pdf
|
import img2pdf
|
||||||
import pikepdf
|
import pikepdf
|
||||||
@@ -40,13 +41,7 @@ from ocrmypdf.pdfa import generate_pdfa_ps
|
|||||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, PageInfo, PdfInfo
|
from ocrmypdf.pdfinfo import Colorspace, Encoding, PageInfo, PdfInfo
|
||||||
from ocrmypdf.pluginspec import OrientationConfidence
|
from ocrmypdf.pluginspec import OrientationConfidence
|
||||||
|
|
||||||
# Remove this workaround when we require Pillow >= 10
|
T = TypeVar("T")
|
||||||
try:
|
|
||||||
BICUBIC = Image.Resampling.BICUBIC # type: ignore
|
|
||||||
except AttributeError: # pragma: no cover
|
|
||||||
# Pillow 9 shim
|
|
||||||
BICUBIC = Image.BICUBIC # type: ignore
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
VECTOR_PAGE_DPI = 400
|
VECTOR_PAGE_DPI = 400
|
||||||
@@ -145,6 +140,7 @@ def _pdf_guess_version(input_file: Path, search_window=1024) -> str:
|
|||||||
def triage(
|
def triage(
|
||||||
original_filename: str, input_file: Path, output_file: Path, options
|
original_filename: str, input_file: Path, output_file: Path, options
|
||||||
) -> Path:
|
) -> Path:
|
||||||
|
"""Triage the input file. We can handle PDFs and images."""
|
||||||
try:
|
try:
|
||||||
if _pdf_guess_version(input_file):
|
if _pdf_guess_version(input_file):
|
||||||
if options.image_dpi:
|
if options.image_dpi:
|
||||||
@@ -173,6 +169,7 @@ def get_pdfinfo(
|
|||||||
max_workers: int | None = None,
|
max_workers: int | None = None,
|
||||||
check_pages=None,
|
check_pages=None,
|
||||||
) -> PdfInfo:
|
) -> PdfInfo:
|
||||||
|
"""Get the PDF info."""
|
||||||
try:
|
try:
|
||||||
return PdfInfo(
|
return PdfInfo(
|
||||||
input_file,
|
input_file,
|
||||||
@@ -189,6 +186,7 @@ def get_pdfinfo(
|
|||||||
|
|
||||||
|
|
||||||
def validate_pdfinfo_options(context: PdfContext) -> None:
|
def validate_pdfinfo_options(context: PdfContext) -> None:
|
||||||
|
"""Validate the PDF info options."""
|
||||||
pdfinfo = context.pdfinfo
|
pdfinfo = context.pdfinfo
|
||||||
options = context.options
|
options = context.options
|
||||||
|
|
||||||
@@ -434,6 +432,7 @@ def get_orientation_correction(preview: Path, page_context: PageContext) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def calculate_image_dpi(page_context: PageContext) -> Resolution:
|
def calculate_image_dpi(page_context: PageContext) -> Resolution:
|
||||||
|
"""Calculate the DPI for the page image."""
|
||||||
pageinfo = page_context.pageinfo
|
pageinfo = page_context.pageinfo
|
||||||
dpi_profile = pageinfo.page_dpi_profile()
|
dpi_profile = pageinfo.page_dpi_profile()
|
||||||
if dpi_profile and dpi_profile.average_to_max_dpi_ratio < 0.8:
|
if dpi_profile and dpi_profile.average_to_max_dpi_ratio < 0.8:
|
||||||
@@ -532,6 +531,7 @@ def rasterize(
|
|||||||
|
|
||||||
|
|
||||||
def preprocess_remove_background(input_file: Path, page_context: PageContext) -> Path:
|
def preprocess_remove_background(input_file: Path, page_context: PageContext) -> Path:
|
||||||
|
"""Remove the background from the input image (temporarily disabled)."""
|
||||||
if any(image.bpc > 1 for image in page_context.pageinfo.images):
|
if any(image.bpc > 1 for image in page_context.pageinfo.images):
|
||||||
raise NotImplementedError("--remove-background is temporarily not implemented")
|
raise NotImplementedError("--remove-background is temporarily not implemented")
|
||||||
# output_file = page_context.get_path('pp_rm_bg.png')
|
# output_file = page_context.get_path('pp_rm_bg.png')
|
||||||
@@ -562,7 +562,7 @@ def preprocess_deskew(input_file: Path, page_context: PageContext) -> Path:
|
|||||||
# resampling if image is mode '1' or 'P'
|
# resampling if image is mode '1' or 'P'
|
||||||
deskewed = im.rotate(
|
deskewed = im.rotate(
|
||||||
deskew_angle_degrees,
|
deskew_angle_degrees,
|
||||||
resample=BICUBIC,
|
resample=Image.Resampling.BICUBIC,
|
||||||
fillcolor=ImageColor.getcolor('white', mode=im.mode), # type: ignore
|
fillcolor=ImageColor.getcolor('white', mode=im.mode), # type: ignore
|
||||||
)
|
)
|
||||||
deskewed.save(output_file, dpi=dpi)
|
deskewed.save(output_file, dpi=dpi)
|
||||||
@@ -571,6 +571,7 @@ def preprocess_deskew(input_file: Path, page_context: PageContext) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def preprocess_clean(input_file: Path, page_context: PageContext) -> Path:
|
def preprocess_clean(input_file: Path, page_context: PageContext) -> Path:
|
||||||
|
"""Clean the input image using unpaper."""
|
||||||
output_file = page_context.get_path('pp_clean.png')
|
output_file = page_context.get_path('pp_clean.png')
|
||||||
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
|
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
|
||||||
return unpaper.clean(
|
return unpaper.clean(
|
||||||
@@ -631,6 +632,7 @@ def create_ocr_image(image: Path, page_context: PageContext) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path, Path]:
|
def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path, Path]:
|
||||||
|
"""Run the OCR engine and generate hOCR output."""
|
||||||
hocr_out = page_context.get_path('ocr_hocr.hocr')
|
hocr_out = page_context.get_path('ocr_hocr.hocr')
|
||||||
hocr_text_out = page_context.get_path('ocr_hocr.txt')
|
hocr_text_out = page_context.get_path('ocr_hocr.txt')
|
||||||
options = page_context.options
|
options = page_context.options
|
||||||
@@ -662,6 +664,10 @@ def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def create_visible_page_jpg(image: Path, page_context: PageContext) -> Path:
|
def create_visible_page_jpg(image: Path, page_context: PageContext) -> Path:
|
||||||
|
"""Create a visible page image in JPEG format.
|
||||||
|
|
||||||
|
This is intended to be used when all images on the page were originally JPEGs.
|
||||||
|
"""
|
||||||
output_file = page_context.get_path('visible.jpg')
|
output_file = page_context.get_path('visible.jpg')
|
||||||
with Image.open(image) as im:
|
with Image.open(image) as im:
|
||||||
# At this point the image should be a .png, but deskew, unpaper
|
# At this point the image should be a .png, but deskew, unpaper
|
||||||
@@ -683,6 +689,7 @@ def create_visible_page_jpg(image: Path, page_context: PageContext) -> Path:
|
|||||||
def create_pdf_page_from_image(
|
def create_pdf_page_from_image(
|
||||||
image: Path, page_context: PageContext, orientation_correction: int
|
image: Path, page_context: PageContext, orientation_correction: int
|
||||||
) -> Path:
|
) -> Path:
|
||||||
|
"""Create a PDF page from a page image."""
|
||||||
# We rasterize a square DPI version of each page because most image
|
# 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
|
# 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
|
# accurately describes the image. It would be possible to resample the image
|
||||||
@@ -714,6 +721,7 @@ def create_pdf_page_from_image(
|
|||||||
|
|
||||||
|
|
||||||
def render_hocr_page(hocr: Path, page_context: PageContext) -> Path:
|
def render_hocr_page(hocr: Path, page_context: PageContext) -> Path:
|
||||||
|
"""Render the hOCR page to a PDF."""
|
||||||
options = page_context.options
|
options = page_context.options
|
||||||
output_file = page_context.get_path('ocr_hocr.pdf')
|
output_file = page_context.get_path('ocr_hocr.pdf')
|
||||||
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
|
dpi = get_page_square_dpi(page_context, calculate_image_dpi(page_context))
|
||||||
@@ -733,6 +741,7 @@ def render_hocr_page(hocr: Path, page_context: PageContext) -> Path:
|
|||||||
def ocr_engine_textonly_pdf(
|
def ocr_engine_textonly_pdf(
|
||||||
input_image: Path, page_context: PageContext
|
input_image: Path, page_context: PageContext
|
||||||
) -> tuple[Path, Path]:
|
) -> tuple[Path, Path]:
|
||||||
|
"""Run the OCR engine and generate a text-only PDF (will look blank)."""
|
||||||
output_pdf = page_context.get_path('ocr_tess.pdf')
|
output_pdf = page_context.get_path('ocr_tess.pdf')
|
||||||
output_text = page_context.get_path('ocr_tess.txt')
|
output_text = page_context.get_path('ocr_tess.txt')
|
||||||
options = page_context.options
|
options = page_context.options
|
||||||
@@ -748,6 +757,7 @@ def ocr_engine_textonly_pdf(
|
|||||||
|
|
||||||
|
|
||||||
def get_docinfo(base_pdf: pikepdf.Pdf, context: PdfContext) -> dict[str, str]:
|
def get_docinfo(base_pdf: pikepdf.Pdf, context: PdfContext) -> dict[str, str]:
|
||||||
|
"""Read the document info and store it in a dictionary."""
|
||||||
options = context.options
|
options = context.options
|
||||||
|
|
||||||
def from_document_info(key):
|
def from_document_info(key):
|
||||||
@@ -793,6 +803,14 @@ def generate_postscript_stub(context: PdfContext) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -> Path:
|
def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -> Path:
|
||||||
|
"""Converts the given PDF to PDF/A.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_pdf: The input PDF file path (presumably not PDF/A).
|
||||||
|
input_ps_stub: The input PostScript file path, containing instructions
|
||||||
|
for the PDF/A generator to use.
|
||||||
|
context: The PDF context.
|
||||||
|
"""
|
||||||
options = context.options
|
options = context.options
|
||||||
input_pdfinfo = context.pdfinfo
|
input_pdfinfo = context.pdfinfo
|
||||||
fix_docinfo_file = context.get_path('fix_docinfo.pdf')
|
fix_docinfo_file = context.get_path('fix_docinfo.pdf')
|
||||||
@@ -847,6 +865,10 @@ def _repair_docinfo_nuls(pdf):
|
|||||||
|
|
||||||
|
|
||||||
def should_linearize(working_file: Path, context: PdfContext) -> bool:
|
def should_linearize(working_file: Path, context: PdfContext) -> bool:
|
||||||
|
"""Determine whether the PDF should be linearized.
|
||||||
|
|
||||||
|
For smaller files, linearization is not worth the effort.
|
||||||
|
"""
|
||||||
filesize = os.stat(working_file).st_size
|
filesize = os.stat(working_file).st_size
|
||||||
if filesize > (context.options.fast_web_view * 1_000_000):
|
if filesize > (context.options.fast_web_view * 1_000_000):
|
||||||
return True
|
return True
|
||||||
@@ -854,6 +876,11 @@ def should_linearize(working_file: Path, context: PdfContext) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def get_pdf_save_settings(output_type: str) -> dict[str, Any]:
|
def get_pdf_save_settings(output_type: str) -> dict[str, Any]:
|
||||||
|
"""Get pikepdf.Pdf.save settings for the given output type.
|
||||||
|
|
||||||
|
Essentially, don't use features that are incompatible with a given
|
||||||
|
PDF/A specification.
|
||||||
|
"""
|
||||||
if output_type == 'pdfa-1':
|
if output_type == 'pdfa-1':
|
||||||
# Trigger recompression to ensure object streams are removed, because
|
# Trigger recompression to ensure object streams are removed, because
|
||||||
# Acrobat complains about them in PDF/A-1b validation.
|
# Acrobat complains about them in PDF/A-1b validation.
|
||||||
@@ -872,6 +899,11 @@ def get_pdf_save_settings(output_type: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def metadata_fixup(working_file: Path, context: PdfContext) -> Path:
|
def metadata_fixup(working_file: Path, context: PdfContext) -> Path:
|
||||||
|
"""Fix certain metadata fields after Ghostscript PDF/A conversion.
|
||||||
|
|
||||||
|
Also report on metadata in the input file that was not retained during
|
||||||
|
PDF/A conversion.
|
||||||
|
"""
|
||||||
output_file = context.get_path('metafix.pdf')
|
output_file = context.get_path('metafix.pdf')
|
||||||
options = context.options
|
options = context.options
|
||||||
|
|
||||||
@@ -894,7 +926,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 original.open_metadata(
|
||||||
|
set_pikepdf_as_editor=False, update_docinfo=False, strict=False
|
||||||
|
) as meta_original, pdf.open_metadata() as meta_pdf:
|
||||||
meta_pdf.load_from_docinfo(
|
meta_pdf.load_from_docinfo(
|
||||||
docinfo, delete_missing=False, raise_failure=False
|
docinfo, delete_missing=False, raise_failure=False
|
||||||
)
|
)
|
||||||
@@ -902,37 +936,33 @@ def metadata_fixup(working_file: Path, context: PdfContext) -> Path:
|
|||||||
# ensure consistency with Ghostscript.
|
# ensure consistency with Ghostscript.
|
||||||
if 'xmp:CreateDate' not in meta_pdf:
|
if 'xmp:CreateDate' not in meta_pdf:
|
||||||
meta_pdf['xmp:CreateDate'] = meta_pdf.get('xmp:ModifyDate', '')
|
meta_pdf['xmp:CreateDate'] = meta_pdf.get('xmp:ModifyDate', '')
|
||||||
|
if meta_pdf.get('dc:title') == 'Untitled':
|
||||||
with original.open_metadata(
|
# Ghostscript likes to set title to Untitled if omitted from input.
|
||||||
set_pikepdf_as_editor=False, update_docinfo=False, strict=False
|
# Reverse this, because PDF/A TechNote 0003:Metadata in PDF/A-1
|
||||||
) as meta_original:
|
# and the XMP Spec do not make this recommendation.
|
||||||
if meta_pdf.get('dc:title') == 'Untitled':
|
if 'dc:title' not in meta_original:
|
||||||
# Ghostscript likes to set title to Untitled if omitted from input.
|
del meta_pdf['dc:title']
|
||||||
# Reverse this, because PDF/A TechNote 0003:Metadata in PDF/A-1
|
# If the user explicitly specified an empty string for any of the
|
||||||
# and the XMP Spec do not make this recommendation.
|
# following, they should be unset and not reported as missing in
|
||||||
if 'dc:title' not in meta_original:
|
# the output pdf. Note that some metadata fields use differing names
|
||||||
del meta_pdf['dc:title']
|
# between PDF-A and PDF.
|
||||||
# If the user explicitly specified an empty string for any of the
|
for meta in [meta_pdf, meta_original]:
|
||||||
# following, they should be unset and not reported as missing in
|
if options.title == '' and 'dc:title' in meta:
|
||||||
# the output pdf. Note that some metadata fields use differing names
|
del meta['dc:title'] # PDF-A and PDF
|
||||||
# between PDF-A and PDF.
|
if options.author == '':
|
||||||
for meta in [meta_pdf, meta_original]:
|
if 'dc:creator' in meta:
|
||||||
if options.title == '' and 'dc:title' in meta:
|
del meta['dc:creator'] # PDF-A (Not xmp:CreatorTool)
|
||||||
del meta['dc:title'] # PDF-A and PDF
|
if 'pdf:Author' in meta:
|
||||||
if options.author == '':
|
del meta['pdf:Author'] # PDF
|
||||||
if 'dc:creator' in meta:
|
if options.subject == '':
|
||||||
del meta['dc:creator'] # PDF-A (Not xmp:CreatorTool)
|
if 'dc:description' in meta:
|
||||||
if 'pdf:Author' in meta:
|
del meta['dc:description'] # PDF-A
|
||||||
del meta['pdf:Author'] # PDF
|
if 'dc:subject' in meta:
|
||||||
if options.subject == '':
|
del meta['dc:subject'] # PDF
|
||||||
if 'dc:description' in meta:
|
if options.keywords == '' and 'pdf:Keywords' in meta:
|
||||||
del meta['dc:description'] # PDF-A
|
del meta['pdf:Keywords'] # PDF-A and PDF
|
||||||
if 'dc:subject' in meta:
|
meta_missing = set(meta_original.keys()) - set(meta_pdf.keys())
|
||||||
del meta['dc:subject'] # PDF
|
report_on_metadata(meta_missing)
|
||||||
if options.keywords == '' and 'pdf:Keywords' in meta:
|
|
||||||
del meta['pdf:Keywords'] # PDF-A and PDF
|
|
||||||
meta_missing = set(meta_original.keys()) - set(meta_pdf.keys())
|
|
||||||
report_on_metadata(meta_missing)
|
|
||||||
|
|
||||||
optimizing = context.plugin_manager.hook.is_optimization_enabled(
|
optimizing = context.plugin_manager.hook.is_optimization_enabled(
|
||||||
context=context
|
context=context
|
||||||
@@ -974,6 +1004,7 @@ def _file_size_ratio(
|
|||||||
def optimize_pdf(
|
def optimize_pdf(
|
||||||
input_file: Path, context: PdfContext, executor: Executor
|
input_file: Path, context: PdfContext, executor: Executor
|
||||||
) -> tuple[Path, Sequence[str]]:
|
) -> tuple[Path, Sequence[str]]:
|
||||||
|
"""Optimize the given PDF file."""
|
||||||
output_file = context.get_path('optimize.pdf')
|
output_file = context.get_path('optimize.pdf')
|
||||||
output_pdf, messages = context.plugin_manager.hook.optimize_pdf(
|
output_pdf, messages = context.plugin_manager.hook.optimize_pdf(
|
||||||
input_pdf=input_file,
|
input_pdf=input_file,
|
||||||
@@ -993,8 +1024,8 @@ def optimize_pdf(
|
|||||||
|
|
||||||
|
|
||||||
def enumerate_compress_ranges(
|
def enumerate_compress_ranges(
|
||||||
iterable: Iterable,
|
iterable: Iterable[T],
|
||||||
) -> Iterator[tuple[tuple[int, int], Any]]:
|
) -> Iterator[tuple[tuple[int, int], T]]:
|
||||||
"""Enumerate the ranges of non-empty elements in an iterable.
|
"""Enumerate the ranges of non-empty elements in an iterable.
|
||||||
|
|
||||||
Compresses consecutive ranges of length 1 into single elements.
|
Compresses consecutive ranges of length 1 into single elements.
|
||||||
@@ -1022,21 +1053,22 @@ def enumerate_compress_ranges(
|
|||||||
|
|
||||||
|
|
||||||
def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Path:
|
def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Path:
|
||||||
|
"""Merge the page sidecar files into a single file.
|
||||||
|
|
||||||
|
Sidecar files are created by the OCR engine and contain the text for each
|
||||||
|
page in the PDF. This function merges the sidecar files into a single file
|
||||||
|
and returns the path to the merged file.
|
||||||
|
"""
|
||||||
output_file = context.get_path('sidecar.txt')
|
output_file = context.get_path('sidecar.txt')
|
||||||
with open(output_file, 'w', encoding="utf-8") as stream:
|
with open(output_file, 'w', encoding="utf-8") as stream:
|
||||||
for (from_, to_), txt_file in enumerate_compress_ranges(txt_files):
|
for (from_, to_), txt_file in enumerate_compress_ranges(txt_files):
|
||||||
if from_ != 1:
|
if from_ != 1:
|
||||||
stream.write('\f') # Form feed between pages
|
stream.write('\f') # Form feed between pages for all pages after first
|
||||||
if txt_file:
|
if txt_file:
|
||||||
with open(txt_file, encoding="utf-8") as in_:
|
txt = txt_file.read_text(encoding="utf-8")
|
||||||
txt = in_.read()
|
# Some versions of Tesseract add a form feed at the end and
|
||||||
# Some OCR engines (e.g. Tesseract v4 alpha) add form feeds
|
# others don't. Remove it if it exists, since we add one manually.
|
||||||
# between pages, and some do not. For consistency, we ignore
|
stream.write(txt.removesuffix('\f'))
|
||||||
# any added by the OCR engine and them on our own.
|
|
||||||
if txt.endswith('\f'):
|
|
||||||
stream.write(txt[:-1])
|
|
||||||
else:
|
|
||||||
stream.write(txt)
|
|
||||||
else:
|
else:
|
||||||
if from_ != to_:
|
if from_ != to_:
|
||||||
pages = f'{from_}-{to_}'
|
pages = f'{from_}-{to_}'
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import importlib
|
|||||||
import importlib.util
|
import importlib.util
|
||||||
import pkgutil
|
import pkgutil
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
import pluggy
|
import pluggy
|
||||||
|
|
||||||
|
|||||||
+22
-4
@@ -13,12 +13,13 @@ import logging.handlers
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
from collections.abc import Sequence
|
||||||
from concurrent.futures.process import BrokenProcessPool
|
from concurrent.futures.process import BrokenProcessPool
|
||||||
from concurrent.futures.thread import BrokenThreadPool
|
from concurrent.futures.thread import BrokenThreadPool
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from tempfile import mkdtemp
|
from tempfile import mkdtemp
|
||||||
from typing import NamedTuple, Sequence, cast
|
from typing import NamedTuple, cast
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
|
|
||||||
@@ -104,6 +105,7 @@ def preprocess(
|
|||||||
deskew: bool,
|
deskew: bool,
|
||||||
clean: bool,
|
clean: bool,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
|
"""Preprocess an image."""
|
||||||
if remove_background:
|
if remove_background:
|
||||||
image = preprocess_remove_background(image, page_context)
|
image = preprocess_remove_background(image, page_context)
|
||||||
if deskew:
|
if deskew:
|
||||||
@@ -116,6 +118,7 @@ def preprocess(
|
|||||||
def make_intermediate_images(
|
def make_intermediate_images(
|
||||||
page_context: PageContext, orientation_correction: int
|
page_context: PageContext, orientation_correction: int
|
||||||
) -> tuple[Path, Path | None]:
|
) -> tuple[Path, Path | None]:
|
||||||
|
"""Create intermediate and preprocessed images for OCR."""
|
||||||
options = page_context.options
|
options = page_context.options
|
||||||
|
|
||||||
ocr_image = preprocess_out = None
|
ocr_image = preprocess_out = None
|
||||||
@@ -173,6 +176,7 @@ def make_intermediate_images(
|
|||||||
|
|
||||||
|
|
||||||
def exec_page_sync(page_context: PageContext) -> PageResult:
|
def exec_page_sync(page_context: PageContext) -> PageResult:
|
||||||
|
"""Execute a pipeline for a single page synchronously."""
|
||||||
options = page_context.options
|
options = page_context.options
|
||||||
tls.pageno = page_context.pageno + 1
|
tls.pageno = page_context.pageno + 1
|
||||||
|
|
||||||
@@ -233,6 +237,7 @@ def exec_page_sync(page_context: PageContext) -> PageResult:
|
|||||||
def post_process(
|
def post_process(
|
||||||
pdf_file: Path, context: PdfContext, executor: Executor
|
pdf_file: Path, context: PdfContext, executor: Executor
|
||||||
) -> tuple[Path, Sequence[str]]:
|
) -> tuple[Path, Sequence[str]]:
|
||||||
|
"""Postprocess the PDF file."""
|
||||||
pdf_out = pdf_file
|
pdf_out = pdf_file
|
||||||
if context.options.output_type.startswith('pdfa'):
|
if context.options.output_type.startswith('pdfa'):
|
||||||
ps_stub_out = generate_postscript_stub(context)
|
ps_stub_out = generate_postscript_stub(context)
|
||||||
@@ -243,6 +248,7 @@ def post_process(
|
|||||||
|
|
||||||
|
|
||||||
def worker_init(max_pixels: int) -> None:
|
def worker_init(max_pixels: int) -> None:
|
||||||
|
"""Initialize a worker thread or process."""
|
||||||
# In Windows, child process will not inherit our change to this value in
|
# In Windows, child process will not inherit our change to this value in
|
||||||
# the parent process, so ensure workers get it set. Not needed when running
|
# the parent process, so ensure workers get it set. Not needed when running
|
||||||
# threaded, but harmless to set again.
|
# threaded, but harmless to set again.
|
||||||
@@ -251,8 +257,8 @@ def worker_init(max_pixels: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
||||||
"""Execute the pipeline concurrently."""
|
"""Execute the OCR pipeline concurrently."""
|
||||||
# Run exec_page_sync on every page context
|
# Run exec_page_sync on every page
|
||||||
options = context.options
|
options = context.options
|
||||||
max_workers = min(len(context.pdfinfo), options.jobs)
|
max_workers = min(len(context.pdfinfo), options.jobs)
|
||||||
if max_workers > 1:
|
if max_workers > 1:
|
||||||
@@ -262,6 +268,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
|||||||
ocrgraft = OcrGrafter(context)
|
ocrgraft = OcrGrafter(context)
|
||||||
|
|
||||||
def update_page(result: PageResult, pbar):
|
def update_page(result: PageResult, pbar):
|
||||||
|
"""After OCR is complete for a page, update the PDF."""
|
||||||
try:
|
try:
|
||||||
tls.pageno = result.pageno + 1
|
tls.pageno = result.pageno + 1
|
||||||
sidecars[result.pageno] = result.text
|
sidecars[result.pageno] = result.text
|
||||||
@@ -317,7 +324,7 @@ def configure_debug_logging(
|
|||||||
) -> logging.FileHandler:
|
) -> logging.FileHandler:
|
||||||
"""Create a debug log file at a specified location.
|
"""Create a debug log file at a specified location.
|
||||||
|
|
||||||
Arguments:
|
Args:
|
||||||
log_filename: Where to the put the log file.
|
log_filename: Where to the put the log file.
|
||||||
prefix: The logging domain prefix that should be sent to the log.
|
prefix: The logging domain prefix that should be sent to the log.
|
||||||
"""
|
"""
|
||||||
@@ -338,6 +345,17 @@ def run_pipeline(
|
|||||||
plugin_manager: OcrmypdfPluginManager | None,
|
plugin_manager: OcrmypdfPluginManager | None,
|
||||||
api: bool = False,
|
api: bool = False,
|
||||||
) -> ExitCode:
|
) -> ExitCode:
|
||||||
|
"""Run the OCR pipeline.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
options: The parsed command line options.
|
||||||
|
plugin_manager: The plugin manager to use. If not provided, one will be
|
||||||
|
created.
|
||||||
|
api: If ``True``, the pipeline is being run from the API. This is used
|
||||||
|
to manage exceptions in a way appropriate for API or CLI usage.
|
||||||
|
For CLI (``api=False``), exceptions are printed and described;
|
||||||
|
for API use, they are propagated to the caller.
|
||||||
|
"""
|
||||||
# Any changes to options will not take effect for options that are already
|
# Any changes to options will not take effect for options that are already
|
||||||
# bound to function parameters in the pipeline. (For example
|
# bound to function parameters in the pipeline. (For example
|
||||||
# options.input_file, options.pdf_renderer are already bound.)
|
# options.input_file, options.pdf_renderer are already bound.)
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from shutil import copyfileobj
|
from shutil import copyfileobj
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
import PIL
|
import PIL
|
||||||
@@ -44,7 +44,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def check_platform() -> None:
|
def check_platform() -> None:
|
||||||
if sys.maxsize <= 2**32: # pragma: no cover
|
if sys.maxsize <= 2**32: # pragma: no cover
|
||||||
log.error(
|
log.warning(
|
||||||
"You are running OCRmyPDF in a 32-bit (x86) Python interpreter. "
|
"You are running OCRmyPDF in a 32-bit (x86) Python interpreter. "
|
||||||
"This is not supported. 32-bit does not have enough address space "
|
"This is not supported. 32-bit does not have enough address space "
|
||||||
"to process large files. "
|
"to process large files. "
|
||||||
|
|||||||
+8
-2
@@ -10,10 +10,11 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from argparse import Namespace
|
from argparse import Namespace
|
||||||
|
from collections.abc import Iterable
|
||||||
from enum import IntEnum
|
from enum import IntEnum
|
||||||
from io import IOBase
|
from io import IOBase
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import AnyStr, BinaryIO, Iterable, Union
|
from typing import AnyStr, BinaryIO, Union
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
|
|
||||||
import pluggy
|
import pluggy
|
||||||
@@ -209,7 +210,7 @@ def create_options(
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
def ocr( # noqa: ruff: disable=D417
|
def ocr( # noqa: D417
|
||||||
input_file: PathOrIO,
|
input_file: PathOrIO,
|
||||||
output_file: PathOrIO,
|
output_file: PathOrIO,
|
||||||
*,
|
*,
|
||||||
@@ -240,6 +241,7 @@ def ocr( # noqa: ruff: disable=D417
|
|||||||
png_quality: int | None = None,
|
png_quality: int | None = None,
|
||||||
jbig2_lossy: bool | None = None,
|
jbig2_lossy: bool | None = None,
|
||||||
jbig2_page_group_size: int | None = None,
|
jbig2_page_group_size: int | None = None,
|
||||||
|
jbig2_threshold: float | None = None,
|
||||||
pages: str | None = None,
|
pages: str | None = None,
|
||||||
max_image_mpixels: float | None = None,
|
max_image_mpixels: float | None = None,
|
||||||
tesseract_config: Iterable[str] | None = None,
|
tesseract_config: Iterable[str] | None = None,
|
||||||
@@ -249,12 +251,16 @@ def ocr( # noqa: ruff: disable=D417
|
|||||||
pdf_renderer: str | None = None,
|
pdf_renderer: str | None = None,
|
||||||
tesseract_timeout: float | None = None,
|
tesseract_timeout: float | None = None,
|
||||||
tesseract_non_ocr_timeout: float | None = None,
|
tesseract_non_ocr_timeout: float | None = None,
|
||||||
|
tesseract_downsample_above: int | None = None,
|
||||||
|
tesseract_downsample_large_images: bool | None = None,
|
||||||
rotate_pages_threshold: float | None = None,
|
rotate_pages_threshold: float | None = None,
|
||||||
pdfa_image_compression: str | None = None,
|
pdfa_image_compression: str | None = None,
|
||||||
|
color_conversion_strategy: str | None = None,
|
||||||
user_words: os.PathLike | None = None,
|
user_words: os.PathLike | None = None,
|
||||||
user_patterns: os.PathLike | None = None,
|
user_patterns: os.PathLike | None = None,
|
||||||
fast_web_view: float | None = None,
|
fast_web_view: float | None = None,
|
||||||
continue_on_soft_render_error: bool | None = None,
|
continue_on_soft_render_error: bool | None = None,
|
||||||
|
invalidate_digital_signatures: bool | None = None,
|
||||||
plugins: Iterable[StrPath] | None = None,
|
plugins: Iterable[StrPath] | None = None,
|
||||||
plugin_manager=None,
|
plugin_manager=None,
|
||||||
keep_temporary_files: bool | None = None,
|
keep_temporary_files: bool | None = None,
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import queue
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
from collections.abc import Iterable
|
||||||
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Callable, Iterable, Type, Union
|
from typing import Callable, Union
|
||||||
|
|
||||||
from rich.console import Console as RichConsole
|
from rich.console import Console as RichConsole
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ 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
|
||||||
|
|
||||||
FuturesExecutorClass = Union[Type[ThreadPoolExecutor], Type[ProcessPoolExecutor]]
|
FuturesExecutorClass = Union[type[ThreadPoolExecutor], type[ProcessPoolExecutor]]
|
||||||
Queue = Union[multiprocessing.Queue, queue.Queue]
|
Queue = Union[multiprocessing.Queue, queue.Queue]
|
||||||
UserInit = Callable[[], None]
|
UserInit = Callable[[], None]
|
||||||
WorkerInit = Callable[[Queue, UserInit, int], None]
|
WorkerInit = Callable[[Queue, UserInit, int], None]
|
||||||
@@ -118,6 +119,11 @@ class StandardExecutor(Executor):
|
|||||||
# Regardless of whether we use_threads for worker processes, the log_listener
|
# Regardless of whether we use_threads for worker processes, the log_listener
|
||||||
# must be a thread. Make sure we create the listener after the worker pool,
|
# must be a thread. Make sure we create the listener after the worker pool,
|
||||||
# so that it does not get forked into the workers.
|
# so that it does not get forked into the workers.
|
||||||
|
# If use_threads is False, we are currently guilty of creating a thread before
|
||||||
|
# forking on Linux, which is not recommended. However, we take a big
|
||||||
|
# performance hit in pdfinfo if we can't fork. Long term solution is to
|
||||||
|
# replace most of this with an asyncio implementation, and probably to
|
||||||
|
# migrate some of pdfinfo into C++ or Rust.
|
||||||
listener = threading.Thread(target=log_listener, args=(log_queue,))
|
listener = threading.Thread(target=log_listener, args=(log_queue,))
|
||||||
listener.start()
|
listener.start()
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Sequence
|
|
||||||
|
|
||||||
from ocrmypdf import Executor, PdfContext, hookimpl
|
from ocrmypdf import Executor, PdfContext, hookimpl
|
||||||
from ocrmypdf._exec import jbig2enc, pngquant
|
from ocrmypdf._exec import jbig2enc, pngquant
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
from typing import Any, Callable, Mapping, TypeVar
|
from collections.abc import Mapping
|
||||||
|
from typing import Any, Callable, TypeVar
|
||||||
|
|
||||||
from ocrmypdf._version import PROGRAM_NAME as _PROGRAM_NAME
|
from ocrmypdf._version import PROGRAM_NAME as _PROGRAM_NAME
|
||||||
from ocrmypdf._version import __version__ as _VERSION
|
from ocrmypdf._version import __version__ as _VERSION
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import logging.handlers
|
import logging.handlers
|
||||||
import signal
|
import signal
|
||||||
|
from collections.abc import Iterable, Iterator
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
from itertools import islice, repeat, takewhile, zip_longest
|
from itertools import islice, repeat, takewhile, zip_longest
|
||||||
from multiprocessing import Pipe, Process
|
from multiprocessing import Pipe, Process
|
||||||
from multiprocessing.connection import Connection, wait
|
from multiprocessing.connection import Connection, wait
|
||||||
from typing import Callable, Iterable, Iterator
|
from typing import Callable
|
||||||
|
|
||||||
from ocrmypdf import Executor, hookimpl
|
from ocrmypdf import Executor, hookimpl
|
||||||
from ocrmypdf._concurrent import NullProgressBar
|
from ocrmypdf._concurrent import NullProgressBar
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import multiprocessing
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import warnings
|
import warnings
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
@@ -21,9 +21,6 @@ from typing import (
|
|||||||
Any,
|
Any,
|
||||||
Callable,
|
Callable,
|
||||||
Generic,
|
Generic,
|
||||||
Sequence,
|
|
||||||
SupportsFloat,
|
|
||||||
SupportsRound,
|
|
||||||
TypeVar,
|
TypeVar,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from math import floor, sqrt
|
from math import floor, sqrt
|
||||||
from typing import Optional, Tuple
|
from typing import Optional
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
# Remove this workaround when we require Pillow >= 9.1.0
|
|
||||||
try:
|
|
||||||
Resampling = Image.Resampling # type: ignore
|
|
||||||
except AttributeError:
|
|
||||||
# Pillow 9 shim
|
|
||||||
Resampling = Image # type: ignore
|
|
||||||
|
|
||||||
|
|
||||||
# While from __future__ import annotations, we use singledispatch here, which
|
# While from __future__ import annotations, we use singledispatch here, which
|
||||||
# does not support annotations. Disable check about using old-style typing
|
# does not support annotations. Disable check about using old-style typing
|
||||||
# until Python 3.10, OR when drop singledispatch in ocrmypdf 15.
|
# until Python 3.10, OR when drop singledispatch in ocrmypdf 15.
|
||||||
@@ -43,13 +35,13 @@ def bytes_per_pixel(mode: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def _calculate_downsample(
|
def _calculate_downsample(
|
||||||
image_size: Tuple[int, int],
|
image_size: tuple[int, int],
|
||||||
bytes_per_pixel: int,
|
bytes_per_pixel: int,
|
||||||
*,
|
*,
|
||||||
max_size: Optional[Tuple[int, int]] = None,
|
max_size: Optional[tuple[int, int]] = None,
|
||||||
max_pixels: Optional[int] = None,
|
max_pixels: Optional[int] = None,
|
||||||
max_bytes: Optional[int] = None,
|
max_bytes: Optional[int] = None,
|
||||||
) -> Tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""Calculate image size required to downsample an image to fit limits.
|
"""Calculate image size required to downsample an image to fit limits.
|
||||||
|
|
||||||
If no limit is exceeded, the input image's size is returned.
|
If no limit is exceeded, the input image's size is returned.
|
||||||
@@ -106,10 +98,10 @@ def _calculate_downsample(
|
|||||||
def calculate_downsample(
|
def calculate_downsample(
|
||||||
image: Image.Image,
|
image: Image.Image,
|
||||||
*,
|
*,
|
||||||
max_size: Optional[Tuple[int, int]] = None,
|
max_size: Optional[tuple[int, int]] = None,
|
||||||
max_pixels: Optional[int] = None,
|
max_pixels: Optional[int] = None,
|
||||||
max_bytes: Optional[int] = None,
|
max_bytes: Optional[int] = None,
|
||||||
) -> Tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""Calculate image size required to downsample an image to fit limits.
|
"""Calculate image size required to downsample an image to fit limits.
|
||||||
|
|
||||||
If no limit is exceeded, the input image's size is returned.
|
If no limit is exceeded, the input image's size is returned.
|
||||||
@@ -135,7 +127,7 @@ def downsample_image(
|
|||||||
image: Image.Image,
|
image: Image.Image,
|
||||||
new_size: tuple[int, int],
|
new_size: tuple[int, int],
|
||||||
*,
|
*,
|
||||||
resample_mode: Image.Resampling = Resampling.BICUBIC,
|
resample_mode: Image.Resampling = Image.Resampling.BICUBIC,
|
||||||
reducing_gap: int = 3,
|
reducing_gap: int = 3,
|
||||||
) -> Image.Image:
|
) -> Image.Image:
|
||||||
"""Downsample an image to fit within the given limits.
|
"""Downsample an image to fit within the given limits.
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import sys
|
|||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from collections.abc import Iterator, MutableSet, Sequence
|
||||||
from os import fspath
|
from os import fspath
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Iterator, MutableSet, NamedTuple, NewType, Sequence
|
from typing import Callable, NamedTuple, NewType
|
||||||
from zlib import compress
|
from zlib import compress
|
||||||
|
|
||||||
import img2pdf
|
import img2pdf
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
from collections.abc import Iterator
|
||||||
from importlib.resources import files as package_files
|
from importlib.resources import files as package_files
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterator
|
|
||||||
|
|
||||||
import pikepdf
|
import pikepdf
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import re
|
|||||||
import statistics
|
import statistics
|
||||||
import sys
|
import sys
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from collections.abc import Container, Iterable, Iterator, Mapping, Sequence
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from enum import Enum, auto
|
from enum import Enum, auto
|
||||||
@@ -19,9 +20,10 @@ from functools import partial
|
|||||||
from math import hypot, inf, isclose
|
from math import hypot, inf, isclose
|
||||||
from os import PathLike
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Container, Iterable, Iterator, Mapping, NamedTuple, Sequence, Tuple
|
from typing import Callable, NamedTuple
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
|
|
||||||
|
from pdfminer.layout import LTPage, LTTextBox
|
||||||
from pikepdf import (
|
from pikepdf import (
|
||||||
Name,
|
Name,
|
||||||
Object,
|
Object,
|
||||||
@@ -37,7 +39,7 @@ from pikepdf import (
|
|||||||
from ocrmypdf._concurrent import Executor, SerialExecutor
|
from ocrmypdf._concurrent import Executor, SerialExecutor
|
||||||
from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
|
from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
|
||||||
from ocrmypdf.helpers import Resolution, available_cpu_count, pikepdf_enable_mmap
|
from ocrmypdf.helpers import Resolution, available_cpu_count, pikepdf_enable_mmap
|
||||||
from ocrmypdf.pdfinfo.layout import get_page_analysis, get_text_boxes
|
from ocrmypdf.pdfinfo.layout import LTStateAwareChar, get_page_analysis, get_text_boxes
|
||||||
|
|
||||||
logger = logging.getLogger()
|
logger = logging.getLogger()
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ class Encoding(Enum):
|
|||||||
runlength = auto()
|
runlength = auto()
|
||||||
|
|
||||||
|
|
||||||
FloatRect = Tuple[float, float, float, float]
|
FloatRect = tuple[float, float, float, float]
|
||||||
|
|
||||||
FRIENDLY_COLORSPACE: dict[str, Colorspace] = {
|
FRIENDLY_COLORSPACE: dict[str, Colorspace] = {
|
||||||
'/DeviceGray': Colorspace.gray,
|
'/DeviceGray': Colorspace.gray,
|
||||||
@@ -654,7 +656,9 @@ def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) ->
|
|||||||
return has_text
|
return has_text
|
||||||
|
|
||||||
|
|
||||||
def simplify_textboxes(miner, textbox_getter) -> Iterator[TextboxInfo]:
|
def simplify_textboxes(
|
||||||
|
miner: LTPage, textbox_getter: Callable[[LTPage], Iterator[LTTextBox]]
|
||||||
|
) -> Iterator[TextboxInfo]:
|
||||||
"""Extract only limited content from text boxes.
|
"""Extract only limited content from text boxes.
|
||||||
|
|
||||||
We do this to save memory and ensure that our objects are pickleable.
|
We do this to save memory and ensure that our objects are pickleable.
|
||||||
@@ -662,7 +666,8 @@ def simplify_textboxes(miner, textbox_getter) -> Iterator[TextboxInfo]:
|
|||||||
for box in textbox_getter(miner):
|
for box in textbox_getter(miner):
|
||||||
first_line = box._objs[0] # pylint: disable=protected-access
|
first_line = box._objs[0] # pylint: disable=protected-access
|
||||||
first_char = first_line._objs[0] # pylint: disable=protected-access
|
first_char = first_line._objs[0] # pylint: disable=protected-access
|
||||||
|
if not isinstance(first_char, LTStateAwareChar):
|
||||||
|
continue
|
||||||
visible = first_char.rendermode != 3
|
visible = first_char.rendermode != 3
|
||||||
corrupt = first_char.get_text() == '\ufffd'
|
corrupt = first_char.get_text() == '\ufffd'
|
||||||
yield TextboxInfo(box.bbox, visible, corrupt)
|
yield TextboxInfo(box.bbox, visible, corrupt)
|
||||||
@@ -821,7 +826,10 @@ class PageInfo:
|
|||||||
if check_this_page and detailed_analysis:
|
if check_this_page and detailed_analysis:
|
||||||
pscript5_mode = str(pdf.docinfo.get(Name.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))
|
if miner is not None:
|
||||||
|
self._textboxes = list(simplify_textboxes(miner, get_text_boxes))
|
||||||
|
else:
|
||||||
|
self._textboxes = []
|
||||||
bboxes = (box.bbox for box in self._textboxes)
|
bboxes = (box.bbox for box in self._textboxes)
|
||||||
|
|
||||||
self._has_text = _page_has_text(bboxes, width_pt, height_pt)
|
self._has_text = _page_has_text(bboxes, width_pt, height_pt)
|
||||||
@@ -937,7 +945,9 @@ class PageInfo:
|
|||||||
def get_textareas(self, visible: bool | None = None, corrupt: bool | None = None):
|
def get_textareas(self, visible: bool | None = None, corrupt: bool | None = None):
|
||||||
"""Return textareas bounding boxes in PDF coordinates on the page."""
|
"""Return textareas bounding boxes in PDF coordinates on the page."""
|
||||||
|
|
||||||
def predicate(obj, want_visible, want_corrupt):
|
def predicate(
|
||||||
|
obj: TextboxInfo, want_visible: bool | None, want_corrupt: bool | None
|
||||||
|
) -> bool:
|
||||||
result = True
|
result = True
|
||||||
if want_visible is not None:
|
if want_visible is not None:
|
||||||
if obj.is_visible != want_visible:
|
if obj.is_visible != want_visible:
|
||||||
|
|||||||
+106
-71
@@ -5,8 +5,12 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from contextlib import contextmanager
|
||||||
from math import copysign
|
from math import copysign
|
||||||
|
from os import PathLike
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterator
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pdfminer
|
import pdfminer
|
||||||
@@ -15,10 +19,13 @@ import pdfminer.pdfdevice
|
|||||||
import pdfminer.pdfinterp
|
import pdfminer.pdfinterp
|
||||||
from pdfminer.converter import PDFLayoutAnalyzer
|
from pdfminer.converter import PDFLayoutAnalyzer
|
||||||
from pdfminer.layout import LAParams, LTChar, LTPage, LTTextBox
|
from pdfminer.layout import LAParams, LTChar, LTPage, LTTextBox
|
||||||
|
from pdfminer.pdfcolor import PDFColorSpace
|
||||||
|
from pdfminer.pdfdevice import PDFTextSeq
|
||||||
from pdfminer.pdfdocument import PDFTextExtractionNotAllowed
|
from pdfminer.pdfdocument import PDFTextExtractionNotAllowed
|
||||||
from pdfminer.pdffont import PDFSimpleFont, PDFUnicodeNotDefined
|
from pdfminer.pdffont import FontWidthDict, PDFFont, PDFSimpleFont, PDFUnicodeNotDefined
|
||||||
|
from pdfminer.pdfinterp import PDFGraphicState, PDFResourceManager, PDFTextState
|
||||||
from pdfminer.pdfpage import PDFPage
|
from pdfminer.pdfpage import PDFPage
|
||||||
from pdfminer.utils import bbox2str, matrix2str
|
from pdfminer.utils import Matrix, bbox2str, matrix2str
|
||||||
|
|
||||||
from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
|
from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
|
||||||
|
|
||||||
@@ -28,7 +35,12 @@ STRIP_NAME = re.compile(r'[0-9]+')
|
|||||||
original_pdfsimplefont_init = PDFSimpleFont.__init__
|
original_pdfsimplefont_init = PDFSimpleFont.__init__
|
||||||
|
|
||||||
|
|
||||||
def pdfsimplefont__init__(self, descriptor, widths, spec):
|
def pdfsimplefont__init__(
|
||||||
|
self,
|
||||||
|
descriptor: Mapping[str, Any],
|
||||||
|
widths: FontWidthDict,
|
||||||
|
spec: Mapping[str, Any],
|
||||||
|
) -> None:
|
||||||
"""Monkeypatch pdfminer.six PDFSimpleFont.__init__.
|
"""Monkeypatch pdfminer.six PDFSimpleFont.__init__.
|
||||||
|
|
||||||
If there is no ToUnicode and no Encoding, pdfminer.six assumes that Unicode
|
If there is no ToUnicode and no Encoding, pdfminer.six assumes that Unicode
|
||||||
@@ -44,7 +56,7 @@ def pdfsimplefont__init__(self, descriptor, widths, spec):
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
PDFSimpleFont.__init__ = pdfsimplefont__init__
|
setattr(PDFSimpleFont, '__init__', pdfsimplefont__init__)
|
||||||
|
|
||||||
#
|
#
|
||||||
# pdfminer patches when creator is PScript5.dll
|
# pdfminer patches when creator is PScript5.dll
|
||||||
@@ -85,6 +97,11 @@ def pdftype3font__pscript5_get_ascent(self):
|
|||||||
return self.ascent * copysign(1.0, self.vscale)
|
return self.ascent * copysign(1.0, self.vscale)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_undefined_char(s: str) -> bool:
|
||||||
|
"""Check if a string is an undefined character."""
|
||||||
|
return s.startswith('(cid:') and s.endswith(')')
|
||||||
|
|
||||||
|
|
||||||
class LTStateAwareChar(LTChar):
|
class LTStateAwareChar(LTChar):
|
||||||
"""A subclass of LTChar that tracks text render mode at time of drawing."""
|
"""A subclass of LTChar that tracks text render mode at time of drawing."""
|
||||||
|
|
||||||
@@ -107,18 +124,18 @@ class LTStateAwareChar(LTChar):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
matrix,
|
matrix: Matrix,
|
||||||
font,
|
font: PDFFont,
|
||||||
fontsize,
|
fontsize: float,
|
||||||
scaling,
|
scaling: float,
|
||||||
rise,
|
rise: float,
|
||||||
text,
|
text: str,
|
||||||
textwidth,
|
textwidth: float,
|
||||||
textdisp,
|
textdisp: float | tuple[float | None, float],
|
||||||
ncs,
|
ncs: PDFColorSpace,
|
||||||
graphicstate,
|
graphicstate: PDFGraphicState,
|
||||||
textstate,
|
textstate: PDFTextState,
|
||||||
):
|
) -> None:
|
||||||
"""Initialize."""
|
"""Initialize."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
matrix,
|
matrix,
|
||||||
@@ -134,7 +151,7 @@ class LTStateAwareChar(LTChar):
|
|||||||
)
|
)
|
||||||
self.rendermode = textstate.render
|
self.rendermode = textstate.render
|
||||||
|
|
||||||
def is_compatible(self, obj):
|
def is_compatible(self, obj: object) -> bool:
|
||||||
"""Check if characters can be combined into a textline.
|
"""Check if characters can be combined into a textline.
|
||||||
|
|
||||||
We consider characters compatible if:
|
We consider characters compatible if:
|
||||||
@@ -142,23 +159,22 @@ class LTStateAwareChar(LTChar):
|
|||||||
- the Unicode mapping is unknown but both are part of the same font
|
- the Unicode mapping is unknown but both are part of the same font
|
||||||
"""
|
"""
|
||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
both_unicode_mapped = isinstance(self._text, str) and isinstance(obj._text, str)
|
if not isinstance(obj, LTStateAwareChar):
|
||||||
try:
|
|
||||||
if both_unicode_mapped:
|
|
||||||
return self.rendermode == obj.rendermode
|
|
||||||
font0, _ = self._text
|
|
||||||
font1, _ = obj._text
|
|
||||||
return font0 == font1 and self.rendermode == obj.rendermode
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
return False
|
return False
|
||||||
|
both_unicode_mapped = not _is_undefined_char(
|
||||||
|
self._text
|
||||||
|
) and not _is_undefined_char(obj._text)
|
||||||
|
if both_unicode_mapped:
|
||||||
|
return self.rendermode == obj.rendermode
|
||||||
|
return self.fontname == obj.fontname and self.rendermode == obj.rendermode
|
||||||
|
|
||||||
def get_text(self):
|
def get_text(self) -> str:
|
||||||
"""Get text from this character."""
|
"""Get text from this character."""
|
||||||
if isinstance(self._text, tuple):
|
if _is_undefined_char(self._text):
|
||||||
return '\ufffd' # standard 'Unknown symbol'
|
return '\ufffd' # standard 'Unknown symbol'
|
||||||
return self._text
|
return self._text
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self) -> str:
|
||||||
"""Return a string representation of this object."""
|
"""Return a string representation of this object."""
|
||||||
return (
|
return (
|
||||||
f"<{self.__class__.__name__} "
|
f"<{self.__class__.__name__} "
|
||||||
@@ -174,19 +190,24 @@ class LTStateAwareChar(LTChar):
|
|||||||
class TextPositionTracker(PDFLayoutAnalyzer):
|
class TextPositionTracker(PDFLayoutAnalyzer):
|
||||||
"""A page layout analyzer that pays attention to text visibility."""
|
"""A page layout analyzer that pays attention to text visibility."""
|
||||||
|
|
||||||
def __init__(self, rsrcmgr, pageno=1, laparams=None):
|
textstate: PDFTextState
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
rsrcmgr: PDFResourceManager,
|
||||||
|
pageno: int = 1,
|
||||||
|
laparams: LAParams | None = None,
|
||||||
|
):
|
||||||
"""Initialize the layout analyzer."""
|
"""Initialize the layout analyzer."""
|
||||||
super().__init__(rsrcmgr, pageno, laparams)
|
super().__init__(rsrcmgr, pageno, laparams)
|
||||||
self.textstate = None
|
self.result: LTPage | None = None
|
||||||
self.result = None
|
|
||||||
self.cur_item = None # not defined in pdfminer code as it should be
|
|
||||||
|
|
||||||
def begin_page(self, page, ctm):
|
def begin_page(self, page: PDFPage, ctm: Matrix) -> None:
|
||||||
"""Begin processing of a page."""
|
"""Begin processing of a page."""
|
||||||
super().begin_page(page, ctm)
|
super().begin_page(page, ctm)
|
||||||
self.cur_item = LTPage(self.pageno, page.mediabox)
|
self.cur_item = LTPage(self.pageno, page.mediabox)
|
||||||
|
|
||||||
def end_page(self, page):
|
def end_page(self, page: PDFPage) -> None:
|
||||||
"""End processing of a page."""
|
"""End processing of a page."""
|
||||||
assert not self._stack, str(len(self._stack))
|
assert not self._stack, str(len(self._stack))
|
||||||
assert isinstance(self.cur_item, LTPage), str(type(self.cur_item))
|
assert isinstance(self.cur_item, LTPage), str(type(self.cur_item))
|
||||||
@@ -195,14 +216,28 @@ class TextPositionTracker(PDFLayoutAnalyzer):
|
|||||||
self.pageno += 1
|
self.pageno += 1
|
||||||
self.receive_layout(self.cur_item)
|
self.receive_layout(self.cur_item)
|
||||||
|
|
||||||
def render_string(self, textstate, seq, ncs, graphicstate):
|
def render_string(
|
||||||
|
self,
|
||||||
|
textstate: PDFTextState,
|
||||||
|
seq: PDFTextSeq,
|
||||||
|
ncs: PDFColorSpace,
|
||||||
|
graphicstate: PDFGraphicState,
|
||||||
|
) -> None:
|
||||||
"""Respond to render string event by updating text state."""
|
"""Respond to render string event by updating text state."""
|
||||||
self.textstate = textstate.copy()
|
self.textstate = textstate.copy()
|
||||||
super().render_string(self.textstate, seq, ncs, graphicstate)
|
super().render_string(self.textstate, seq, ncs, graphicstate)
|
||||||
|
|
||||||
def render_char(
|
def render_char(
|
||||||
self, matrix, font, fontsize, scaling, rise, cid, ncs, graphicstate
|
self,
|
||||||
):
|
matrix: Matrix,
|
||||||
|
font: PDFFont,
|
||||||
|
fontsize: float,
|
||||||
|
scaling: float,
|
||||||
|
rise: float,
|
||||||
|
cid: int,
|
||||||
|
ncs: PDFColorSpace,
|
||||||
|
graphicstate: PDFGraphicState,
|
||||||
|
) -> float:
|
||||||
"""Respond to render char event by updating text state."""
|
"""Respond to render char event by updating text state."""
|
||||||
try:
|
try:
|
||||||
text = font.to_unichr(cid)
|
text = font.to_unichr(cid)
|
||||||
@@ -227,21 +262,34 @@ class TextPositionTracker(PDFLayoutAnalyzer):
|
|||||||
self.cur_item.add(item)
|
self.cur_item.add(item)
|
||||||
return item.adv
|
return item.adv
|
||||||
|
|
||||||
def handle_undefined_char(self, font, cid):
|
def receive_layout(self, ltpage: LTPage) -> None:
|
||||||
"""Handle undefined character."""
|
|
||||||
# log.info('undefined: %r, %r', font, cid)
|
|
||||||
return (font.fontname, cid)
|
|
||||||
|
|
||||||
def receive_layout(self, ltpage):
|
|
||||||
"""Receive layout handler."""
|
"""Receive layout handler."""
|
||||||
self.result = ltpage
|
self.result = ltpage
|
||||||
|
|
||||||
def get_result(self):
|
def get_result(self) -> LTPage | None:
|
||||||
"""Get the result of the analysis."""
|
"""Get the result of the analysis."""
|
||||||
return self.result
|
return self.result
|
||||||
|
|
||||||
|
|
||||||
def get_page_analysis(infile, pageno, pscript5_mode):
|
@contextmanager
|
||||||
|
def patch_pdfminer(pscript5_mode: bool):
|
||||||
|
"""Patch pdfminer.six to work around bugs in PDFs created by PScript5."""
|
||||||
|
if pscript5_mode:
|
||||||
|
with patch.multiple(
|
||||||
|
'pdfminer.pdffont.PDFType3Font',
|
||||||
|
spec=True,
|
||||||
|
get_ascent=pdftype3font__pscript5_get_ascent,
|
||||||
|
get_descent=pdftype3font__pscript5_get_descent,
|
||||||
|
get_height=pdftype3font__pscript5_get_height,
|
||||||
|
):
|
||||||
|
yield
|
||||||
|
else:
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def get_page_analysis(
|
||||||
|
infile: PathLike, pageno: int, pscript5_mode: bool
|
||||||
|
) -> LTPage | None:
|
||||||
"""Get the page analysis for a given page."""
|
"""Get the page analysis for a given page."""
|
||||||
rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
|
rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
|
||||||
disable_boxes_flow = None
|
disable_boxes_flow = None
|
||||||
@@ -253,36 +301,23 @@ def get_page_analysis(infile, pageno, pscript5_mode):
|
|||||||
)
|
)
|
||||||
interp = pdfminer.pdfinterp.PDFPageInterpreter(rman, dev)
|
interp = pdfminer.pdfinterp.PDFPageInterpreter(rman, dev)
|
||||||
|
|
||||||
patcher = None
|
with patch_pdfminer(pscript5_mode):
|
||||||
if pscript5_mode:
|
try:
|
||||||
patcher = patch.multiple(
|
with Path(infile).open('rb') as f:
|
||||||
'pdfminer.pdffont.PDFType3Font',
|
page_iter = PDFPage.get_pages(f, pagenos=[pageno], maxpages=0)
|
||||||
spec=True,
|
page = next(page_iter, None)
|
||||||
get_ascent=pdftype3font__pscript5_get_ascent,
|
if page is None:
|
||||||
get_descent=pdftype3font__pscript5_get_descent,
|
raise InputFileError(
|
||||||
get_height=pdftype3font__pscript5_get_height,
|
f"pdfminer could not process page {pageno} (counting from 0)."
|
||||||
)
|
)
|
||||||
patcher.start()
|
interp.process_page(page)
|
||||||
|
except PDFTextExtractionNotAllowed as e:
|
||||||
try:
|
raise EncryptedPdfError() from e
|
||||||
with Path(infile).open('rb') as f:
|
|
||||||
page_iter = PDFPage.get_pages(f, pagenos=[pageno], maxpages=0)
|
|
||||||
page = next(page_iter, None)
|
|
||||||
if page is None:
|
|
||||||
raise InputFileError(
|
|
||||||
f"pdfminer could not process page {pageno} (counting from 0)."
|
|
||||||
)
|
|
||||||
interp.process_page(page)
|
|
||||||
except PDFTextExtractionNotAllowed as e:
|
|
||||||
raise EncryptedPdfError() from e
|
|
||||||
finally:
|
|
||||||
if patcher is not None:
|
|
||||||
patcher.stop()
|
|
||||||
|
|
||||||
return dev.get_result()
|
return dev.get_result()
|
||||||
|
|
||||||
|
|
||||||
def get_text_boxes(obj):
|
def get_text_boxes(obj) -> Iterator[LTTextBox]:
|
||||||
"""Get the text boxes attached to the current node."""
|
"""Get the text boxes attached to the current node."""
|
||||||
for child in obj:
|
for child in obj:
|
||||||
if isinstance(child, (LTTextBox)):
|
if isinstance(child, (LTTextBox)):
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from argparse import ArgumentParser, Namespace
|
from argparse import ArgumentParser, Namespace
|
||||||
|
from collections.abc import Sequence, Set
|
||||||
from logging import Handler
|
from logging import Handler
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, AbstractSet, NamedTuple, Sequence
|
from typing import TYPE_CHECKING, NamedTuple
|
||||||
|
|
||||||
import pluggy
|
import pluggy
|
||||||
|
|
||||||
@@ -405,7 +406,7 @@ class OcrEngine(ABC):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def languages(options: Namespace) -> AbstractSet[str]:
|
def languages(options: Namespace) -> Set[str]:
|
||||||
"""Returns the set of all languages that are supported by the engine.
|
"""Returns the set of all languages that are supported by the engine.
|
||||||
|
|
||||||
Languages are typically given in 3-letter ISO 3166-1 codes, but actually
|
Languages are typically given in 3-letter ISO 3166-1 codes, but actually
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Iterable
|
from collections.abc import Iterable
|
||||||
|
|
||||||
|
|
||||||
class OcrQualityDictionary:
|
class OcrQualityDictionary:
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from subprocess import PIPE, STDOUT, CalledProcessError, CompletedProcess, Popen
|
from subprocess import PIPE, STDOUT, CalledProcessError, CompletedProcess, Popen
|
||||||
from subprocess import run as subprocess_run
|
from subprocess import run as subprocess_run
|
||||||
from typing import Callable, Mapping, Sequence, Union
|
from typing import Callable, Union
|
||||||
|
|
||||||
from packaging.version import Version
|
from packaging.version import Version
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import os
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
from collections.abc import Iterable, Iterator
|
||||||
from itertools import chain
|
from itertools import chain
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Iterable, Iterator, TypeVar
|
from typing import Any, Callable, TypeVar
|
||||||
|
|
||||||
from packaging.version import InvalidVersion, Version
|
from packaging.version import InvalidVersion, Version
|
||||||
|
|
||||||
|
|||||||
@@ -394,9 +394,12 @@ def test_prevent_gs_invalid_xml(resources, outdir):
|
|||||||
assert contents.find(b'\x00', xmp_start, xmp_end) == -1
|
assert contents.find(b'\x00', xmp_start, xmp_end) == -1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(
|
@pytest.mark.xfail(
|
||||||
ghostscript.version() >= Version('10.2.0'),
|
ghostscript.version() >= Version('10.01.2'),
|
||||||
reason="Ghostscript 10.2.0+ exit with an error on invalid DocumentInfo",
|
reason=(
|
||||||
|
"Ghostscript now exits with an error on invalid DocumentInfo, defeating "
|
||||||
|
"this test.",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
def test_malformed_docinfo(caplog, resources, outdir):
|
def test_malformed_docinfo(caplog, resources, outdir):
|
||||||
generate_pdfa_ps(outdir / 'pdfa.ps')
|
generate_pdfa_ps(outdir / 'pdfa.ps')
|
||||||
|
|||||||
@@ -23,13 +23,6 @@ from .conftest import check_ocrmypdf, run_ocrmypdf
|
|||||||
|
|
||||||
# pylintx: disable=unused-variable
|
# pylintx: disable=unused-variable
|
||||||
|
|
||||||
# Remove this workaround when we require Pillow >= 10
|
|
||||||
try:
|
|
||||||
Transpose = Image.Transpose # type: ignore
|
|
||||||
except AttributeError:
|
|
||||||
# Pillow 9 shim
|
|
||||||
Transpose = Image # type: ignore
|
|
||||||
|
|
||||||
RENDERERS = ['hocr', 'sandwich']
|
RENDERERS = ['hocr', 'sandwich']
|
||||||
|
|
||||||
|
|
||||||
@@ -226,7 +219,7 @@ def test_rotate_page_level(image_angle, page_angle, resources, outdir):
|
|||||||
with Image.open(fspath(resources / 'typewriter.png')) as im:
|
with Image.open(fspath(resources / 'typewriter.png')) as im:
|
||||||
if image_angle != 0:
|
if image_angle != 0:
|
||||||
ccw_angle = -image_angle % 360
|
ccw_angle = -image_angle % 360
|
||||||
im = im.transpose(getattr(Transpose, f'ROTATE_{ccw_angle}'))
|
im = im.transpose(getattr(Image.Transpose, f'ROTATE_{ccw_angle}'))
|
||||||
im.save(memimg, format='PNG')
|
im.save(memimg, format='PNG')
|
||||||
memimg.seek(0)
|
memimg.seek(0)
|
||||||
mempdf = BytesIO()
|
mempdf = BytesIO()
|
||||||
|
|||||||
Reference in New Issue
Block a user