Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02d85ff070 | ||
|
|
179c36151b | ||
|
|
3c4b099cb1 | ||
|
|
15df9c370c | ||
|
|
86d92ef490 | ||
|
|
8f44b29ca3 | ||
|
|
5a08a6cfeb | ||
|
|
cc058be4b2 | ||
|
|
7565d20c0a | ||
|
|
9a075039b5 | ||
|
|
5a1c043331 | ||
|
|
fe89be5dc0 | ||
|
|
d70296b97a | ||
|
|
7d7658018d | ||
|
|
8fb8e9f72c | ||
|
|
85d6fb8ce9 | ||
|
|
828e741c24 | ||
|
|
36837f8353 | ||
|
|
12fd4f70f1 | ||
|
|
250615561d | ||
|
|
a659f83d67 | ||
|
|
08f95c0b13 | ||
|
|
dbd3c93757 | ||
|
|
5d128a91d2 | ||
|
|
a1b8113d56 | ||
|
|
f052e910c9 | ||
|
|
116e2692d0 | ||
|
|
b2669c7d71 | ||
|
|
c8c53d38a3 |
+8
-4
@@ -40,18 +40,18 @@ COPY . /app
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/0.4.27/install.sh | sh
|
# Copy uv from ghcr
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.5.5 /uv /uvx /bin/
|
||||||
|
|
||||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
|
||||||
# Instead of restarting the shell, use uv directly from its installed location.
|
RUN uv sync --extra test --extra webservice --extra watcher --no-dev
|
||||||
RUN /root/.cargo/bin/uv sync --extra test --extra webservice --extra watcher
|
|
||||||
|
|
||||||
FROM base
|
FROM base
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y software-properties-common
|
RUN apt-get update && apt-get install -y software-properties-common
|
||||||
|
|
||||||
RUN add-apt-repository -y ppa:alex-p/tesseract-ocr-devel
|
RUN add-apt-repository -y ppa:alex-p/tesseract-ocr5
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ghostscript \
|
ghostscript \
|
||||||
@@ -75,6 +75,10 @@ COPY --from=builder /usr/local/bin/ /usr/local/bin/
|
|||||||
|
|
||||||
COPY --from=builder --chown=app:app /app /app
|
COPY --from=builder --chown=app:app /app /app
|
||||||
|
|
||||||
|
RUN rm -rf /app/.git && \
|
||||||
|
ln -s /app/misc/webservice.py /app/webservice.py && \
|
||||||
|
ln -s /app/misc/watcher.py /app/watcher.py
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:${PATH}"
|
ENV PATH="/app/.venv/bin:${PATH}"
|
||||||
|
|
||||||
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ COPY . /app
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN curl -LsSf https://astral.sh/uv/0.4.27/install.sh | sh
|
COPY --from=ghcr.io/astral-sh/uv:0.5.5 /uv /uvx /bin/
|
||||||
|
|
||||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
|
|
||||||
# Instead of restarting the shell, use uv directly from its installed location.
|
# Instead of restarting the shell, use uv directly from its installed location.
|
||||||
RUN /root/.cargo/bin/uv sync --extra test --extra webservice --extra watcher
|
RUN uv sync --extra test --extra webservice --extra watcher --no-dev
|
||||||
|
|
||||||
FROM base
|
FROM base
|
||||||
|
|
||||||
@@ -59,6 +59,10 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=builder --chown=app:app /app /app
|
COPY --from=builder --chown=app:app /app /app
|
||||||
|
|
||||||
|
RUN rm -rf /app/.git && \
|
||||||
|
ln -s /app/misc/webservice.py /app/webservice.py && \
|
||||||
|
ln -s /app/misc/watcher.py /app/watcher.py
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:${PATH}"
|
ENV PATH="/app/.venv/bin:${PATH}"
|
||||||
|
|
||||||
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
ENTRYPOINT ["/app/.venv/bin/ocrmypdf"]
|
||||||
|
|||||||
+21
-22
@@ -40,9 +40,9 @@ 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
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v3
|
uses: astral-sh/setup-uv@v4
|
||||||
with:
|
with:
|
||||||
version: "0.4.27"
|
version: "0.5.x"
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
@@ -50,9 +50,9 @@ jobs:
|
|||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
|
|
||||||
- name: Install Tesseract from PPA
|
- name: Install Tesseract from PPA
|
||||||
if: matrix.tesseract_ppa
|
if: matrix.tesseract_ppa == 'ppa'
|
||||||
run: |
|
run: |
|
||||||
sudo add-apt-repository -y ppa:alex-p/tesseract-ocr-devel
|
sudo add-apt-repository -y ppa:alex-p/tesseract-ocr5.3
|
||||||
|
|
||||||
- name: Install common packages
|
- name: Install common packages
|
||||||
run: |
|
run: |
|
||||||
@@ -84,7 +84,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
uv sync --extra test
|
uv sync --extra test --no-dev
|
||||||
|
|
||||||
- name: Report versions
|
- name: Report versions
|
||||||
run: |
|
run: |
|
||||||
@@ -92,21 +92,20 @@ jobs:
|
|||||||
gs --version
|
gs --version
|
||||||
pngquant --version
|
pngquant --version
|
||||||
unpaper --version
|
unpaper --version
|
||||||
uv run img2pdf --version
|
uv run --no-dev img2pdf --version
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
uv run pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v5
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
files: ./coverage.xml
|
files: ./coverage.xml
|
||||||
env_vars: OS,PYTHON
|
env_vars: OS,PYTHON
|
||||||
|
|
||||||
|
|
||||||
test_macos:
|
test_macos:
|
||||||
name: Test macOS
|
name: Test macOS
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
@@ -137,9 +136,9 @@ jobs:
|
|||||||
tesseract
|
tesseract
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v3
|
uses: astral-sh/setup-uv@v4
|
||||||
with:
|
with:
|
||||||
version: "0.4.27"
|
version: "0.5.x"
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
@@ -148,21 +147,21 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
uv sync --extra test
|
uv sync --extra test --no-dev
|
||||||
|
|
||||||
- name: Report versions
|
- name: Report versions
|
||||||
run: |
|
run: |
|
||||||
tesseract --version
|
tesseract --version
|
||||||
gs --version
|
gs --version
|
||||||
pngquant --version
|
pngquant --version
|
||||||
uv run img2pdf --version
|
uv run --no-dev img2pdf --version
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
uv run pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v5
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -187,9 +186,9 @@ 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
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v3
|
uses: astral-sh/setup-uv@v4
|
||||||
with:
|
with:
|
||||||
version: "0.4.27"
|
version: "0.5.x"
|
||||||
|
|
||||||
- name: "Set up Python"
|
- name: "Set up Python"
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
@@ -203,14 +202,14 @@ jobs:
|
|||||||
|
|
||||||
- name: Install Python packages
|
- name: Install Python packages
|
||||||
run: |
|
run: |
|
||||||
uv sync --extra test
|
uv sync --extra test --no-dev
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: |
|
run: |
|
||||||
uv run pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
uv run --no-dev pytest --cov-report xml --cov=ocrmypdf --cov=tests/ -n0 tests/
|
||||||
|
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v4
|
uses: codecov/codecov-action@v5
|
||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -226,9 +225,9 @@ 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
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v3
|
uses: astral-sh/setup-uv@v4
|
||||||
with:
|
with:
|
||||||
version: "0.4.27"
|
version: "0.5.x"
|
||||||
|
|
||||||
- name: Make wheels and sdist
|
- name: Make wheels and sdist
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ Linux, Windows, macOS and FreeBSD are supported. Docker images are also availabl
|
|||||||
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
||||||
| LinuxBrew | ``brew install ocrmypdf`` |
|
| LinuxBrew | ``brew install ocrmypdf`` |
|
||||||
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
||||||
| Conda | ``conda install ocrmypdf`` |
|
|
||||||
| Ubuntu Snap | ``snap install ocrmypdf`` |
|
| Ubuntu Snap | ``snap install ocrmypdf`` |
|
||||||
|
|
||||||
For everyone else, [see our documentation](https://ocrmypdf.readthedocs.io/en/latest/installation.html) for installation steps.
|
For everyone else, [see our documentation](https://ocrmypdf.readthedocs.io/en/latest/installation.html) for installation steps.
|
||||||
@@ -113,9 +112,33 @@ Our [documentation is served on Read the Docs](https://ocrmypdf.readthedocs.io/e
|
|||||||
|
|
||||||
Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) page, and follow the issue template for quick response.
|
Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF/issues) page, and follow the issue template for quick response.
|
||||||
|
|
||||||
|
## Feature demo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add an OCR layer and convert to PDF/A
|
||||||
|
ocrmypdf input.pdf output.pdf
|
||||||
|
|
||||||
|
# Convert an image to single page PDF
|
||||||
|
ocrmypdf input.jpg output.pdf
|
||||||
|
|
||||||
|
# Add OCR to a file in place (only modifies file on success)
|
||||||
|
ocrmypdf myfile.pdf myfile.pdf
|
||||||
|
|
||||||
|
# OCR with non-English languages (look up your language's ISO 639-3 code)
|
||||||
|
ocrmypdf -l fra LeParisien.pdf LeParisien.pdf
|
||||||
|
|
||||||
|
# OCR multilingual documents
|
||||||
|
ocrmypdf -l eng+fra Bilingual-English-French.pdf Bilingual-English-French.pdf
|
||||||
|
|
||||||
|
# Deskew (straighten crooked pages)
|
||||||
|
ocrmypdf --deskew input.pdf output.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
For more features, see the [documentation](https://ocrmypdf.readthedocs.io/en/latest/index.html).
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
In addition to the required Python version (3.8+), OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD.
|
In addition to the required Python version, OCRmyPDF requires external program installations of Ghostscript and Tesseract OCR. OCRmyPDF is pure Python, and runs on pretty much everything: Linux, macOS, Windows and FreeBSD.
|
||||||
|
|
||||||
## Press & Media
|
## Press & Media
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -445,7 +445,7 @@ Debugging the intermediate files
|
|||||||
OCRmyPDF normally saves its intermediate results to a temporary folder
|
OCRmyPDF normally saves its intermediate results to a temporary folder
|
||||||
and deletes this folder when it exits, whether it succeeded or failed.
|
and deletes this folder when it exits, whether it succeeded or failed.
|
||||||
|
|
||||||
If the ``--keep-temporary-files`` (``-k```) argument is issued on the
|
If the ``--keep-temporary-files`` (``-k``) argument is issued on the
|
||||||
command line, OCRmyPDF will keep the temporary folder and print the location,
|
command line, OCRmyPDF will keep the temporary folder and print the location,
|
||||||
whether it succeeded or failed. An example message is:
|
whether it succeeded or failed. An example message is:
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ place, and printing each filename in between runs:
|
|||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
find . -printf '%p\n' -name '*.pdf' -exec ocrmypdf '{}' '{}' \;
|
find . -name '*.pdf' -printf '%p\n' -exec ocrmypdf '{}' '{}' \;
|
||||||
|
|
||||||
This only runs one ``ocrmypdf`` process at a time. This variation uses
|
This only runs one ``ocrmypdf`` process at a time. This variation uses
|
||||||
``find`` to create a directory list and ``parallel`` to parallelize runs
|
``find`` to create a directory list and ``parallel`` to parallelize runs
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ extensions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
# Extension settings
|
# Extension settings
|
||||||
intersphinx_mapping = {'https://docs.python.org/': None}
|
intersphinx_mapping = {'python': ('https://docs.python.org/3', None)}
|
||||||
napoleon_use_rtype = False
|
napoleon_use_rtype = False
|
||||||
issues_github_path = "ocrmypdf/OCRmyPDF"
|
issues_github_path = "ocrmypdf/OCRmyPDF"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -399,7 +399,7 @@ Some users may consider enabling lossy JBIG2. See: :ref:`jbig2-lossy`.
|
|||||||
Digitally signed PDFs
|
Digitally signed PDFs
|
||||||
=====================
|
=====================
|
||||||
|
|
||||||
OCRmyPDF cannot preserve digital signatures in PDFs and also add to OCR to them.
|
OCRmyPDF cannot preserve digital signatures in PDFs and also add OCR to them.
|
||||||
By default, it will refuse to modify a signed PDF regardless of other settings. You can
|
By default, it will refuse to modify a signed PDF regardless of other settings. You can
|
||||||
override this behavior with ``--invalidate-digital-signatures``; as the name suggests,
|
override this behavior with ``--invalidate-digital-signatures``; as the name suggests,
|
||||||
any digital signatures will be invalidated.
|
any digital signatures will be invalidated.
|
||||||
|
|||||||
+3
-3
@@ -69,9 +69,9 @@ OCRmyPDF will use all available CPU cores. See the Docker documentation for
|
|||||||
if you are using Docker on macOS or Windows, where you may need to manually assign
|
if you are using Docker on macOS or Windows, where you may need to manually assign
|
||||||
more resources. On Linux, all resources will be available automatically.
|
more resources. On Linux, all resources will be available automatically.
|
||||||
|
|
||||||
The underlying operating system and other details in Docker images are subject
|
The underlying operating system and other details in Docker images are considered
|
||||||
to change at minor releases. If you are modifying the image, you should pin
|
implementation details and **subject to change at minor releases**. If you are
|
||||||
the version you intend to use.
|
modifying the image, you should pin the version you intend to use.
|
||||||
|
|
||||||
Using the Docker image on the command line
|
Using the Docker image on the command line
|
||||||
==========================================
|
==========================================
|
||||||
|
|||||||
@@ -31,8 +31,6 @@ These platforms have one-liner installs:
|
|||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| FreeBSD | ``pkg install textproc/py-ocrmypdf`` |
|
| FreeBSD | ``pkg install textproc/py-ocrmypdf`` |
|
||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
| Conda (WSL, macOS, Linux) | ``conda install ocrmypdf`` |
|
|
||||||
+-------------------------------+-----------------------------------------+
|
|
||||||
| Snap (snapcraft packaging) | ``snap install ocrmypdf`` |
|
| Snap (snapcraft packaging) | ``snap install ocrmypdf`` |
|
||||||
+-------------------------------+-----------------------------------------+
|
+-------------------------------+-----------------------------------------+
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,33 @@ OCRmyPDF typically supports the three most recent Python versions.
|
|||||||
|
|
||||||
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
||||||
|
|
||||||
|
v16.7.0
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed further issues with Docker build and updated some versions.
|
||||||
|
- Main Docker image returned to Ubuntu 24.04 since the fix in v16.6.2 resolved
|
||||||
|
that concern.
|
||||||
|
- Code that previously sent Ghostscript output to stdout has been changed to
|
||||||
|
output to temporary files, since Ghostscript was doing that anyway internally.
|
||||||
|
This is a modest efficiency improvement.
|
||||||
|
- Fixed an issue with debug log output being parsed as rich markup. :issue:`1444`
|
||||||
|
|
||||||
|
v16.6.2
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Remove invalid hyperlink annotations to satisfy Ghostscript 10.x during PDF/A
|
||||||
|
conversion. :issue:`1425`
|
||||||
|
|
||||||
|
v16.6.1
|
||||||
|
=======
|
||||||
|
|
||||||
|
- Fixed some issues with Docker build, such as removing unnecessary content and using
|
||||||
|
a stable Tesseract version.
|
||||||
|
- Reverted Docker image to Ubuntu 22.04 to access older/more stable Ghostscript
|
||||||
|
for now.
|
||||||
|
- Clarified batch commands in documentation.
|
||||||
|
- Fixed an issue with JSON serialization and pickling of HOCRResult. :issue:`1427`
|
||||||
|
|
||||||
v16.6.0
|
v16.6.0
|
||||||
=======
|
=======
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
"""Helper script for bisecting PDFs to find a page with an issue."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import pikepdf
|
||||||
|
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <input.pdf>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with pikepdf.open(sys.argv[1]) as pdf:
|
||||||
|
num_pages = len(pdf.pages)
|
||||||
|
low = 0
|
||||||
|
high = num_pages - 1
|
||||||
|
while low <= high:
|
||||||
|
mid = (low + high) // 2
|
||||||
|
with pikepdf.new() as new_pdf:
|
||||||
|
new_pdf.pages.extend(pdf.pages[low : mid + 1])
|
||||||
|
new_pdf.save(f"bisect-issue-{low + 1}-{mid + 1}.pdf")
|
||||||
|
print(f"Is bisect-issue-{low + 1}-{mid + 1}.pdf good or bad?", end=" ")
|
||||||
|
while True:
|
||||||
|
response = input().lower()
|
||||||
|
if response == "good":
|
||||||
|
low = mid + 1
|
||||||
|
break
|
||||||
|
elif response == "bad":
|
||||||
|
high = mid - 1
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("Please respond with 'good' or 'bad'.")
|
||||||
|
print(f"The issue is on page {low + 1} of the original PDF.")
|
||||||
|
with pikepdf.new() as new_pdf:
|
||||||
|
new_pdf.pages.extend(pdf.pages[low])
|
||||||
|
new_pdf.save(f"bisect-issue-bad-{low + 1}.pdf")
|
||||||
|
with pikepdf.new() as new_pdf:
|
||||||
|
new_pdf.pages.extend(pdf.pages[:low])
|
||||||
|
new_pdf.pages.extend(pdf.pages[low + 1 :])
|
||||||
|
new_pdf.save(f"bisect-issue-good-{low + 1}.pdf")
|
||||||
+8
-2
@@ -118,8 +118,6 @@ filterwarnings = [
|
|||||||
[[tool.mypy.overrides]]
|
[[tool.mypy.overrides]]
|
||||||
module = [
|
module = [
|
||||||
'pluggy',
|
'pluggy',
|
||||||
'tqdm',
|
|
||||||
'coloredlogs',
|
|
||||||
'img2pdf',
|
'img2pdf',
|
||||||
'pdfminer.*',
|
'pdfminer.*',
|
||||||
'reportlab.*',
|
'reportlab.*',
|
||||||
@@ -155,3 +153,11 @@ convention = "google"
|
|||||||
|
|
||||||
[tool.ruff.format]
|
[tool.ruff.format]
|
||||||
quote-style = "preserve"
|
quote-style = "preserve"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"mypy>=1.13.0",
|
||||||
|
"pymupdf>=1.24.14",
|
||||||
|
"streamlit-pdf-viewer>=0.0.19",
|
||||||
|
"streamlit>=1.40.2",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
"""OCRmyPDF PDF annotation cleanup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from pikepdf import Dictionary, Name, NameTree, Pdf
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_broken_goto_annotations(pdf: Pdf) -> bool:
|
||||||
|
"""Remove broken goto annotations from a PDF.
|
||||||
|
|
||||||
|
If a PDF contains a GoTo Action that points to a named destination that does not
|
||||||
|
exist, Ghostscript PDF/A conversion will fail. In any event, a named destination
|
||||||
|
that is not defined is not useful.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pdf: Opened PDF file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the file was modified, False if not.
|
||||||
|
"""
|
||||||
|
modified = False
|
||||||
|
|
||||||
|
# Check if there are any named destinations
|
||||||
|
if Name.Names not in pdf.Root:
|
||||||
|
return modified
|
||||||
|
if Name.Dests not in pdf.Root[Name.Names]:
|
||||||
|
return modified
|
||||||
|
|
||||||
|
dests = pdf.Root[Name.Names][Name.Dests]
|
||||||
|
if not isinstance(dests, Dictionary):
|
||||||
|
return modified
|
||||||
|
nametree = NameTree(dests)
|
||||||
|
|
||||||
|
# Create a set of all named destinations
|
||||||
|
names = set(k for k in nametree.keys())
|
||||||
|
|
||||||
|
for n, page in enumerate(pdf.pages):
|
||||||
|
if Name.Annots not in page:
|
||||||
|
continue
|
||||||
|
for annot in page[Name.Annots]:
|
||||||
|
if not isinstance(annot, Dictionary):
|
||||||
|
continue
|
||||||
|
if Name.A not in annot or Name.D not in annot[Name.A]:
|
||||||
|
continue
|
||||||
|
# We found an annotation that points to a named destination
|
||||||
|
named_destination = str(annot[Name.A][Name.D])
|
||||||
|
if named_destination not in names:
|
||||||
|
# If there is no corresponding named destination, remove the
|
||||||
|
# annotation. Having no destination set is still valid and just
|
||||||
|
# makes the link non-functional.
|
||||||
|
log.warning(
|
||||||
|
f"Disabling a hyperlink annotation on page {n + 1} to a "
|
||||||
|
"non-existent named destination "
|
||||||
|
f"{named_destination}."
|
||||||
|
)
|
||||||
|
del annot[Name.A][Name.D]
|
||||||
|
modified = True
|
||||||
|
|
||||||
|
return modified
|
||||||
@@ -125,7 +125,7 @@ def rasterize_pdf(
|
|||||||
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
||||||
+ [
|
+ [
|
||||||
'-o',
|
'-o',
|
||||||
'-',
|
fspath(output_file),
|
||||||
'-sstdout=%stderr', # Literal %s, not string interpolation
|
'-sstdout=%stderr', # Literal %s, not string interpolation
|
||||||
'-dAutoRotatePages=/None', # Probably has no effect on raster
|
'-dAutoRotatePages=/None', # Probably has no effect on raster
|
||||||
'-f',
|
'-f',
|
||||||
@@ -137,14 +137,15 @@ def rasterize_pdf(
|
|||||||
p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True)
|
p = run(args_gs, stdout=PIPE, stderr=PIPE, check=True)
|
||||||
except CalledProcessError as e:
|
except CalledProcessError as e:
|
||||||
log.error(e.stderr.decode(errors='replace'))
|
log.error(e.stderr.decode(errors='replace'))
|
||||||
raise SubprocessOutputError('Ghostscript rasterizing failed') from e
|
Path(output_file).unlink(missing_ok=True)
|
||||||
else:
|
raise SubprocessOutputError("Ghostscript rasterizing failed") from e
|
||||||
stderr = p.stderr.decode(errors='replace')
|
|
||||||
if _gs_error_reported(stderr):
|
stderr = p.stderr.decode(errors='replace')
|
||||||
log.error(stderr)
|
if _gs_error_reported(stderr):
|
||||||
|
log.error(stderr)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with Image.open(BytesIO(p.stdout)) as im:
|
with Image.open(output_file) as im:
|
||||||
if rotation is not None:
|
if rotation is not None:
|
||||||
log.debug("Rotating output by %i", rotation)
|
log.debug("Rotating output by %i", rotation)
|
||||||
# rotation is a clockwise angle and Image.ROTATE_* is
|
# rotation is a clockwise angle and Image.ROTATE_* is
|
||||||
@@ -157,13 +158,19 @@ def rasterize_pdf(
|
|||||||
im = im.transpose(Image.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(output_file, dpi=page_dpi)
|
||||||
except UnidentifiedImageError:
|
except UnidentifiedImageError:
|
||||||
log.error(
|
log.error(
|
||||||
f"Ghostscript (using {raster_device} at {raster_dpi} dpi) produced "
|
f"Ghostscript (using {raster_device} at {raster_dpi} dpi) produced "
|
||||||
"an invalid page image file."
|
"an invalid page image file."
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
except OSError as e:
|
||||||
|
log.error(
|
||||||
|
f"Ghostscript (using {raster_device} at {raster_dpi} dpi) produced "
|
||||||
|
"an invalid page image file."
|
||||||
|
)
|
||||||
|
raise UnidentifiedImageError() from e
|
||||||
|
|
||||||
|
|
||||||
class GhostscriptFollower:
|
class GhostscriptFollower:
|
||||||
@@ -271,19 +278,15 @@ def generate_pdfa(
|
|||||||
f"-dPDFA={pdfa_part}",
|
f"-dPDFA={pdfa_part}",
|
||||||
"-dPDFACompatibilityPolicy=1",
|
"-dPDFACompatibilityPolicy=1",
|
||||||
"-o",
|
"-o",
|
||||||
"-",
|
fspath(output_file),
|
||||||
"-sstdout=%stderr", # Literal %s, not string interpolation
|
"-sstdout=%stderr", # Literal %s, not string interpolation
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
args_gs.extend(fspath(s) for s in pdf_pages) # Stringify Path objs
|
||||||
try:
|
try:
|
||||||
with (
|
with GhostscriptFollower(progressbar_class) as pbar:
|
||||||
Path(output_file).open('wb') as output,
|
|
||||||
GhostscriptFollower(progressbar_class) as pbar,
|
|
||||||
):
|
|
||||||
p = run_polling_stderr(
|
p = run_polling_stderr(
|
||||||
args_gs,
|
args_gs,
|
||||||
stdout=output,
|
|
||||||
stderr=PIPE,
|
stderr=PIPE,
|
||||||
check=True,
|
check=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
|||||||
@@ -26,5 +26,5 @@ class PageNumberFilter(logging.Filter):
|
|||||||
class RichLoggingHandler(RichHandler):
|
class RichLoggingHandler(RichHandler):
|
||||||
def __init__(self, console: Console, **kwargs):
|
def __init__(self, console: Console, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
console=console, show_level=False, show_time=False, markup=True, **kwargs
|
console=console, show_level=False, show_time=False, markup=False, **kwargs
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from pikepdf import Dictionary, Name, Pdf
|
|||||||
from pikepdf import __version__ as PIKEPDF_VERSION
|
from pikepdf import __version__ as PIKEPDF_VERSION
|
||||||
from pikepdf.models.metadata import PdfMetadata, encode_pdf_date
|
from pikepdf.models.metadata import PdfMetadata, encode_pdf_date
|
||||||
|
|
||||||
|
from ocrmypdf._annots import remove_broken_goto_annotations
|
||||||
from ocrmypdf._defaults import PROGRAM_NAME
|
from ocrmypdf._defaults import PROGRAM_NAME
|
||||||
from ocrmypdf._jobcontext import PdfContext
|
from ocrmypdf._jobcontext import PdfContext
|
||||||
from ocrmypdf._version import __version__ as OCRMYPF_VERSION
|
from ocrmypdf._version import __version__ as OCRMYPF_VERSION
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ from pathlib import Path
|
|||||||
from typing import NamedTuple, cast
|
from typing import NamedTuple, cast
|
||||||
|
|
||||||
import PIL
|
import PIL
|
||||||
|
from pikepdf import Pdf
|
||||||
|
|
||||||
|
from ocrmypdf._annots import remove_broken_goto_annotations
|
||||||
from ocrmypdf._concurrent import Executor, setup_executor
|
from ocrmypdf._concurrent import Executor, setup_executor
|
||||||
from ocrmypdf._jobcontext import PageContext, PdfContext
|
from ocrmypdf._jobcontext import PageContext, PdfContext
|
||||||
from ocrmypdf._logging import PageNumberFilter
|
from ocrmypdf._logging import PageNumberFilter
|
||||||
@@ -104,6 +106,23 @@ class PageResult(NamedTuple):
|
|||||||
"""Orientation correction in degrees."""
|
"""Orientation correction in degrees."""
|
||||||
|
|
||||||
|
|
||||||
|
class HOCRResultEncoder(json.JSONEncoder):
|
||||||
|
def default(self, obj):
|
||||||
|
if isinstance(obj, Path):
|
||||||
|
return {'Path': str(obj)}
|
||||||
|
return super().default(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class HOCRResultDecoder(json.JSONDecoder):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(object_hook=self.dict_to_object, *args, **kwargs)
|
||||||
|
|
||||||
|
def dict_to_object(self, d):
|
||||||
|
if 'Path' in d:
|
||||||
|
return Path(d['Path'])
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class HOCRResult:
|
class HOCRResult:
|
||||||
"""Result when hOCR is finished processing."""
|
"""Result when hOCR is finished processing."""
|
||||||
@@ -123,38 +142,14 @@ class HOCRResult:
|
|||||||
orientation_correction: int = 0
|
orientation_correction: int = 0
|
||||||
"""Orientation correction in degrees."""
|
"""Orientation correction in degrees."""
|
||||||
|
|
||||||
def __getstate__(self):
|
|
||||||
"""Return state values to be pickled."""
|
|
||||||
return {
|
|
||||||
k: (
|
|
||||||
('Path://' + str(v))
|
|
||||||
if k in ('pdf_page_from_image', 'hocr', 'textpdf') and v is not None
|
|
||||||
else v
|
|
||||||
)
|
|
||||||
for k, v in self.__dict__.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
def __setstate__(self, state):
|
|
||||||
"""Restore state from the unpickled state values."""
|
|
||||||
self.__dict__.update(
|
|
||||||
{
|
|
||||||
k: (
|
|
||||||
Path(v.removeprefix('Path://'))
|
|
||||||
if k in ('pdf_page_from_image', 'hocr', 'textpdf') and v is not None
|
|
||||||
else v
|
|
||||||
)
|
|
||||||
for k, v in state.items()
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_json(cls, json_str: str) -> HOCRResult:
|
def from_json(cls, json_str: str) -> HOCRResult:
|
||||||
"""Create an instance from a dict."""
|
"""Create an instance from a dict."""
|
||||||
return cls(**json.loads(json_str))
|
return cls(**json.loads(json_str, cls=HOCRResultDecoder))
|
||||||
|
|
||||||
def to_json(self) -> str:
|
def to_json(self) -> str:
|
||||||
"""Serialize to a JSON string."""
|
"""Serialize to a JSON string."""
|
||||||
return json.dumps(self.__getstate__())
|
return json.dumps(self.__dict__, cls=HOCRResultEncoder)
|
||||||
|
|
||||||
|
|
||||||
def configure_debug_logging(
|
def configure_debug_logging(
|
||||||
@@ -445,7 +440,14 @@ def postprocess(
|
|||||||
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."""
|
"""Postprocess the PDF file."""
|
||||||
pdf_out = pdf_file
|
# pdf_out = pdf_file
|
||||||
|
with Pdf.open(pdf_file) as pdf:
|
||||||
|
fix_annots = context.get_path('fix_annots.pdf')
|
||||||
|
if remove_broken_goto_annotations(pdf):
|
||||||
|
pdf.save(fix_annots)
|
||||||
|
pdf_out = fix_annots
|
||||||
|
else:
|
||||||
|
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)
|
||||||
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
|
pdf_out = convert_to_pdfa(pdf_out, ps_stub_out, context)
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# SPDX-FileCopyrightText: 2024 James R. Barlow
|
||||||
|
# SPDX-License-Identifier: MPL-2.0
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pikepdf import Array, Dictionary, Name, NameTree, Pdf
|
||||||
|
|
||||||
|
from ocrmypdf._annots import remove_broken_goto_annotations
|
||||||
|
|
||||||
|
|
||||||
|
def test_remove_broken_goto_annotations(resources):
|
||||||
|
with Pdf.open(resources / 'link.pdf') as pdf:
|
||||||
|
assert not remove_broken_goto_annotations(pdf), "File should not be modified"
|
||||||
|
|
||||||
|
# Construct Dests nametree
|
||||||
|
nt = NameTree.new(pdf)
|
||||||
|
names = pdf.Root[Name.Names] = pdf.make_indirect(Dictionary())
|
||||||
|
names[Name.Dests] = nt.obj
|
||||||
|
# Create a broken named destination
|
||||||
|
nt['Invalid'] = pdf.make_indirect(Dictionary())
|
||||||
|
# Create a valid named destination
|
||||||
|
nt['Valid'] = Array([pdf.pages[0].obj, Name.XYZ, 0, 0, 0])
|
||||||
|
|
||||||
|
pdf.pages[0].Annots[0].A.D = 'Missing'
|
||||||
|
pdf.pages[1].Annots[0].A.D = 'Valid'
|
||||||
|
|
||||||
|
assert remove_broken_goto_annotations(pdf), "File should be modified"
|
||||||
|
|
||||||
|
assert Name.D not in pdf.pages[0].Annots[0].A
|
||||||
|
assert Name.D in pdf.pages[1].Annots[0].A
|
||||||
+29
-1
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pickle
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -10,6 +11,7 @@ import pytest
|
|||||||
from pdfminer.high_level import extract_text
|
from pdfminer.high_level import extract_text
|
||||||
|
|
||||||
import ocrmypdf
|
import ocrmypdf
|
||||||
|
import ocrmypdf._pipelines
|
||||||
import ocrmypdf.api
|
import ocrmypdf.api
|
||||||
|
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ def test_sidecar_stringio(resources: Path, outdir: Path, outpdf: Path):
|
|||||||
resources / 'ccitt.pdf',
|
resources / 'ccitt.pdf',
|
||||||
outpdf,
|
outpdf,
|
||||||
plugins=['tests/plugins/tesseract_cache.py'],
|
plugins=['tests/plugins/tesseract_cache.py'],
|
||||||
sidecar=s
|
sidecar=s,
|
||||||
)
|
)
|
||||||
s.seek(0)
|
s.seek(0)
|
||||||
assert b'the' in s.getvalue()
|
assert b'the' in s.getvalue()
|
||||||
@@ -75,3 +77,29 @@ def test_hocr_to_pdf_api(resources: Path, outdir: Path, outpdf: Path):
|
|||||||
text = extract_text(outpdf)
|
text = extract_text(outpdf)
|
||||||
assert 'hocr' in text and 'the' not in text
|
assert 'hocr' in text and 'the' not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_hocr_result_json():
|
||||||
|
result = ocrmypdf._pipelines._common.HOCRResult(
|
||||||
|
pageno=1,
|
||||||
|
pdf_page_from_image=Path('a'),
|
||||||
|
hocr=Path('b'),
|
||||||
|
textpdf=Path('c'),
|
||||||
|
orientation_correction=180,
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
result.to_json()
|
||||||
|
== '{"pageno": 1, "pdf_page_from_image": {"Path": "a"}, "hocr": {"Path": "b"}, '
|
||||||
|
'"textpdf": {"Path": "c"}, "orientation_correction": 180}'
|
||||||
|
)
|
||||||
|
assert ocrmypdf._pipelines._common.HOCRResult.from_json(result.to_json()) == result
|
||||||
|
|
||||||
|
|
||||||
|
def test_hocr_result_pickle():
|
||||||
|
result = ocrmypdf._pipelines._common.HOCRResult(
|
||||||
|
pageno=1,
|
||||||
|
pdf_page_from_image=Path('a'),
|
||||||
|
hocr=Path('b'),
|
||||||
|
textpdf=Path('c'),
|
||||||
|
orientation_correction=180,
|
||||||
|
)
|
||||||
|
assert result == pickle.loads(pickle.dumps(result))
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ def test_ghostscript_mandatory_color_conversion(resources, outpdf):
|
|||||||
|
|
||||||
def test_rasterize_pdf_errors(resources, no_outpdf, caplog):
|
def test_rasterize_pdf_errors(resources, no_outpdf, caplog):
|
||||||
with patch('ocrmypdf._exec.ghostscript.run') as mock:
|
with patch('ocrmypdf._exec.ghostscript.run') as mock:
|
||||||
# ghostscript can produce
|
# ghostscript can produce empty files with return code 0
|
||||||
mock.return_value = subprocess.CompletedProcess(
|
mock.return_value = subprocess.CompletedProcess(
|
||||||
['fakegs'], returncode=0, stdout=b'', stderr=b'error this is an error'
|
['fakegs'], returncode=0, stdout=b'', stderr=b'error this is an error'
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user