Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c38c71794 | ||
|
|
a80e7a127b | ||
|
|
cf3309555f | ||
|
|
f80dd0d86a | ||
|
|
1ba2bce486 | ||
|
|
050dd1f5a8 | ||
|
|
e44a57aec0 | ||
|
|
d94d2671c3 | ||
|
|
5124daa79f | ||
|
|
7293847da7 | ||
|
|
59fd0ac587 | ||
|
|
90619b308c | ||
|
|
d0d49ce989 | ||
|
|
bf0224faa4 | ||
|
|
ae2f8ed8f1 | ||
|
|
14ac9b0560 | ||
|
|
dbe6148d41 | ||
|
|
36d4c2dbbc | ||
|
|
adbffb7bd9 | ||
|
|
05ecb6ca46 | ||
|
|
0a7b60cda5 | ||
|
|
5f211ecf6f | ||
|
|
417ee067a2 | ||
|
|
c4649dabef | ||
|
|
6eadd65dfb | ||
|
|
e8ed510543 | ||
|
|
22d35c199d | ||
|
|
5a82ad63c9 | ||
|
|
4769a6c50b | ||
|
|
9004009adc | ||
|
|
11221f9912 | ||
|
|
177349cc84 | ||
|
|
070c9772ce | ||
|
|
1bc09045a5 | ||
|
|
e46a18dd2f | ||
|
|
c64871c2ed | ||
|
|
de909fb99a | ||
|
|
731b2fc477 | ||
|
|
214f6ec759 | ||
|
|
080aa4dbd1 | ||
|
|
7af5dcd4a4 | ||
|
|
fe9f52fbe7 | ||
|
|
fcbdeb8dbe | ||
|
|
cb251a8d03 | ||
|
|
3731fdfd72 | ||
|
|
b2e6a6431e | ||
|
|
9ff1e56bf6 | ||
|
|
2b30f74fce | ||
|
|
10f4c48e0b | ||
|
|
37d5c086bb | ||
|
|
91830627e5 | ||
|
|
f2fc37b257 | ||
|
|
a99e40fa84 | ||
|
|
9ce692a6f1 | ||
|
|
a3c49b8f31 | ||
|
|
33b70be7d5 | ||
|
|
4924b11b6b | ||
|
|
1d0e4e7c9f | ||
|
|
9b8d14d16e | ||
|
|
b7eb93eb79 | ||
|
|
42c0d0f48f | ||
|
|
5fce50ff7c | ||
|
|
765ed4c386 | ||
|
|
1b2849ec0a | ||
|
|
4f604591b4 | ||
|
|
01dc8e23ff | ||
|
|
b432770cfc | ||
|
|
5502fb8d9f | ||
|
|
e66922b030 | ||
|
|
00e9759b16 | ||
|
|
ba10c5345b | ||
|
|
8a5f94988a | ||
|
|
aa73e3c69f | ||
|
|
9d5fa05a00 | ||
|
|
997380e567 | ||
|
|
2685f910b1 | ||
|
|
bfcc586032 | ||
|
|
2d77b95fd9 | ||
|
|
f072e91120 | ||
|
|
efa2bca8a3 | ||
|
|
b039010d3e | ||
|
|
3f7cefcf5d | ||
|
|
45f97d1565 | ||
|
|
1281f8eb68 | ||
|
|
9ef61864fb | ||
|
|
90b2119ad3 | ||
|
|
f0cc7f2230 | ||
|
|
d60a384aab | ||
|
|
14a6093636 | ||
|
|
54b42d73ab | ||
|
|
9abed14f1f | ||
|
|
d09f61d4fe | ||
|
|
4a0130649e | ||
|
|
a0224d94ca | ||
|
|
9e7b9de830 | ||
|
|
08fc5fc01d | ||
|
|
110c75cba2 | ||
|
|
5afca3f342 | ||
|
|
d9eb0ba7ef | ||
|
|
46d0978a09 | ||
|
|
4e35100978 | ||
|
|
7bd0e43243 | ||
|
|
9cd97da5f2 | ||
|
|
d002703c41 | ||
|
|
b1fecf3b05 | ||
|
|
c2ccc7f29d | ||
|
|
36dfd12e2c | ||
|
|
7da4e6ca7f | ||
|
|
16fc52079d | ||
|
|
f37decf3b3 | ||
|
|
4ad4a13ef0 | ||
|
|
6f61f69a8d | ||
|
|
88831e8ab1 | ||
|
|
2ebc36fcec | ||
|
|
1709e23701 | ||
|
|
2e55cb5980 | ||
|
|
6dbaebdc0c | ||
|
|
5156fe7662 | ||
|
|
2c99c89e45 | ||
|
|
74286e7e1e | ||
|
|
2e937dee9f | ||
|
|
23f3830533 | ||
|
|
305e6594be | ||
|
|
f4155dca77 | ||
|
|
545cd031b0 | ||
|
|
a1c7826336 | ||
|
|
c5359bd990 | ||
|
|
7f77308846 | ||
|
|
8e7c5f3001 | ||
|
|
79db985181 | ||
|
|
7d23a661fc | ||
|
|
99e94807c5 | ||
|
|
8412de9344 | ||
|
|
b458b1422b | ||
|
|
76bd8cab13 | ||
|
|
ef70c9499e | ||
|
|
47dcb6fcd0 | ||
|
|
88d2949e6b | ||
|
|
c9389c7713 | ||
|
|
4d2f499f97 | ||
|
|
4104904a1e | ||
|
|
1a0a797ca6 | ||
|
|
670ce2b969 | ||
|
|
7e97981114 | ||
|
|
53db866ef9 | ||
|
|
d591a3e059 | ||
|
|
37c050aa4f | ||
|
|
4b9ea40a0c | ||
|
|
165432486b | ||
|
|
d619fac0bd | ||
|
|
acc70036cc | ||
|
|
80b7cf6330 | ||
|
|
8a8c06c79c | ||
|
|
67773da309 | ||
|
|
d5a9861d5c | ||
|
|
9ffe829a10 | ||
|
|
8a3b82e364 | ||
|
|
580822a6a2 | ||
|
|
9f3a52fd12 | ||
|
|
52e829d845 | ||
|
|
2b2e5c271a | ||
|
|
5fe3102e4e | ||
|
|
5b57520c98 |
+13
-4
@@ -1,5 +1,5 @@
|
||||
# OCRmyPDF
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
FROM ubuntu:22.04 as base
|
||||
|
||||
@@ -25,7 +25,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libffi-dev \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git
|
||||
git \
|
||||
libcairo2-dev \
|
||||
pkg-config
|
||||
|
||||
# Get the latest pip (Ubuntu version doesn't support manylinux2010)
|
||||
RUN \
|
||||
@@ -50,8 +52,15 @@ RUN pip3 install --no-cache-dir .[test,webservice,watcher]
|
||||
|
||||
FROM base
|
||||
|
||||
# For Tesseract 5
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
software-properties-common gpg-agent
|
||||
RUN add-apt-repository -y ppa:alex-p/tesseract-ocr-devel
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ghostscript \
|
||||
fonts-droid-fallback \
|
||||
jbig2dec \
|
||||
img2pdf \
|
||||
libsm6 libxext6 libxrender-dev \
|
||||
pngquant \
|
||||
@@ -74,7 +83,7 @@ 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/setup.cfg /app/setup.py /app/README.md /app/
|
||||
COPY --from=builder /app/pyproject.toml /app/README.md /app/
|
||||
COPY --from=builder /app/tests /app/tests
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/ocrmypdf"]
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
# dotfiles
|
||||
.*
|
||||
!.coveragerc
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
# Always use Unix convention for new lines
|
||||
* text eol=lf
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: General issues
|
||||
description: Installation, packages, dependencies, "nothing works", test suite failures...
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
assignees:
|
||||
- jbarlow83
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What were you trying to do?
|
||||
description: Also tell us, what did you expect to happen?
|
||||
placeholder: Tell us what you see!
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: packaging-system
|
||||
attributes:
|
||||
label: Where are you installing from?
|
||||
multiple: true
|
||||
options:
|
||||
- PyPI (pip, poetry, pipx, etc.)
|
||||
- Linux package manager (apt, dnf, etc.)
|
||||
- Wndows package manager (chocolatey, etc.)
|
||||
- Homebrew
|
||||
- Docker container
|
||||
- Ubuntu snap
|
||||
- Conda
|
||||
- source build
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: operating-system
|
||||
attributes:
|
||||
label: What operating system are you working on?
|
||||
multiple: true
|
||||
options:
|
||||
- Linux
|
||||
- Windows
|
||||
- macOS
|
||||
- BSD
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: plain text
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
name: General issues
|
||||
about: Installation, packages, dependencies, "nothing works", test suite failures...
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
What's the problem?
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior.
|
||||
|
||||
**Expected behavior**
|
||||
What did you expected to happen?
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**System (please complete the following information):**
|
||||
- OS:
|
||||
- Python version:
|
||||
- OCRmyPDF version:
|
||||
|
||||
**Installation**
|
||||
How did you install OCRmyPDF? Did you install it from your operating system's
|
||||
package manager, or using pip?
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
name: Problem with a specific input file
|
||||
about: Something went wrong while trying to OCR a specific file
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
What command line or API call were you trying to run?
|
||||
|
||||
```bash
|
||||
ocrmypdf ...arguments... input.pdf output.pdf
|
||||
```
|
||||
|
||||
Run with verbosity or higher `-v1` to see more detailed logging. This information may be helpful.
|
||||
|
||||
**Example file**
|
||||
If your issue is a problem that affects only certain files, and we will require an input file (PDF or image) that demonstrates your issue.
|
||||
|
||||
Please provide an input file with no personal or confidential information. At your option you may [GPG-encrypt the file](https://github.com/ocrmypdf/OCRmyPDF/wiki) for OCRmyPDF's author only.
|
||||
|
||||
Links to files hosted elsewhere are perfectly acceptable. You could also look in ``tests/resources`` and see if any of those files reproduce your issue.
|
||||
|
||||
*(Issues without example files usually cannot be resolved. It's like reporting an issue against a web browser without providing a URL.)*
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**System**
|
||||
- OS: [e.g. Linux, Windows, macOS]
|
||||
- OCRmyPDF Version: ``ocrmypdf --version``
|
||||
- How did you install ocrmypdf? Did you use a system package manager, `pip`, or a Docker image?
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Problem with specific file
|
||||
description: Something went wrong while trying to OCR a specific file
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
assignees:
|
||||
- jbarlow83
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for taking the time to describe this issue with a particular file.
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of what the bug is.
|
||||
placeholder: Tell us what you see!
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduce
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Please include steps to reproduce
|
||||
value: |
|
||||
1. Run ocrmypdf -v1 ...arguments... input.pdf output.pdf
|
||||
2. Open output.pdf
|
||||
3. ...
|
||||
render: plain text
|
||||
- type: textarea
|
||||
id: files
|
||||
attributes:
|
||||
label: Files
|
||||
description: Please attach the input and output files, or any screenshots that may be helpful.
|
||||
placeholder: Drag and drop files here
|
||||
- type: dropdown
|
||||
id: packaging-system
|
||||
attributes:
|
||||
label: How did you download and install the software?
|
||||
multiple: true
|
||||
options:
|
||||
- PyPI (pip, poetry, pipx, etc.)
|
||||
- Linux package manager (apt, dnf, etc.)
|
||||
- Windows package manager (chocolatey, etc.)
|
||||
- Homebrew
|
||||
- Docker container
|
||||
- Ubuntu snap
|
||||
- Conda
|
||||
- source build
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: OCRmyPDF version
|
||||
description: Paste "ocrmypdf --version" here
|
||||
placeholder: ocrmypdf --version
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
placeholder: Run OCRmyPDF with verbosity `-v1` to get more detailed logging output.
|
||||
render: plain text
|
||||
@@ -0,0 +1,12 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea for this project
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "triage"]
|
||||
assignees:
|
||||
- jbarlow83
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature
|
||||
attributes:
|
||||
label: Describe the proposed feature
|
||||
description: A clear and concise description of what the desired is.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered. Please include the versions of OCRmyPDF and other supporting programs (Tesseract OCR, Ghostscript) - maybe an alternative already exists in a newer version.
|
||||
|
||||
**Example file**
|
||||
If your issue concerns how OCRmyPDF processes certain files, and please provide an example file that helps illustrate how OCRmyPDF's output could be improve.
|
||||
|
||||
Please provide an input file with no personal or confidential information. At your option you may [GPG-encrypt the file](https://github.com/ocrmypdf/OCRmyPDF/wiki) for OCRmyPDF's author only.
|
||||
|
||||
Links to files hosted elsewhere are perfectly acceptable. You could also look in ``tests/resources`` and see if any of those files reproduce your issue.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
|
||||
+26
-26
@@ -1,9 +1,11 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
name: Test and deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
- ci
|
||||
- release/*
|
||||
- feature/*
|
||||
@@ -20,8 +22,6 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-18.04
|
||||
python: "3.7"
|
||||
- os: ubuntu-20.04
|
||||
python: "3.8"
|
||||
- os: ubuntu-20.04
|
||||
@@ -31,7 +31,13 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
python: "3.9"
|
||||
- os: ubuntu-latest
|
||||
python: "pypy-3.8"
|
||||
python: "3.10"
|
||||
- os: ubuntu-latest
|
||||
python: "3.11"
|
||||
# - os: ubuntu-latest
|
||||
# python: "pypy3.8"
|
||||
#- os: ubuntu-latest
|
||||
# python: "pypy3.9"
|
||||
- os: ubuntu-latest
|
||||
python: "3.9"
|
||||
tesseract5: true
|
||||
@@ -73,12 +79,6 @@ jobs:
|
||||
unpaper \
|
||||
zlib1g
|
||||
|
||||
- name: Install Ubuntu 18.04 packages
|
||||
if: matrix.os == 'ubuntu-18.04'
|
||||
run: |
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libexempi3
|
||||
|
||||
- name: Install Ubuntu 20.04 packages
|
||||
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
- name: Install Python packages
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel
|
||||
python -m pip install .[test]
|
||||
python -m pip install --prefer-binary .[test]
|
||||
|
||||
- name: Report versions
|
||||
run: |
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest]
|
||||
python: ["3.9", "3.10"]
|
||||
python: ["3.10", "3.11"]
|
||||
|
||||
env:
|
||||
OS: ${{ matrix.os }}
|
||||
@@ -133,12 +133,8 @@ jobs:
|
||||
with:
|
||||
fetch-depth: "0" # 0=all, needed for setuptools-scm to resolve version tags
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
name: Install Python
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
|
||||
- name: Install Homebrew deps
|
||||
continue-on-error: true
|
||||
run: |
|
||||
brew update
|
||||
brew install \
|
||||
@@ -149,10 +145,15 @@ jobs:
|
||||
pngquant \
|
||||
tesseract
|
||||
|
||||
- uses: actions/setup-python@v4
|
||||
name: Install Python
|
||||
with:
|
||||
python-version: ${{ matrix.python }}
|
||||
|
||||
- name: Install Python packages
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel
|
||||
python -m pip install .[test]
|
||||
python -m pip install --prefer-binary .[test]
|
||||
|
||||
- name: Report versions
|
||||
run: |
|
||||
@@ -177,7 +178,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest]
|
||||
python: ["3.9", "3.10"]
|
||||
python: ["3.10", "3.11"]
|
||||
|
||||
env:
|
||||
OS: ${{ matrix.os }}
|
||||
@@ -201,7 +202,7 @@ jobs:
|
||||
- name: Install Python packages
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel
|
||||
python -m pip install .[test]
|
||||
python -m pip install --prefer-binary .[test]
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
@@ -228,9 +229,8 @@ jobs:
|
||||
|
||||
- name: Make wheels and sdist
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel
|
||||
python setup.py sdist
|
||||
python setup.py bdist_wheel
|
||||
python -m pip install --upgrade pip wheel build
|
||||
python -m build --sdist --wheel
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
@@ -249,7 +249,7 @@ jobs:
|
||||
name: artifact
|
||||
path: dist
|
||||
|
||||
- uses: pypa/gh-action-pypi-publish@master
|
||||
- uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.TOKEN_PYPI }}
|
||||
@@ -264,9 +264,9 @@ jobs:
|
||||
- name: Set image tag to release or branch
|
||||
run: echo "DOCKER_IMAGE_TAG=${GITHUB_REF##*/}" >> $GITHUB_ENV
|
||||
|
||||
- name: If master, set to latest
|
||||
- name: If main, set to latest
|
||||
run: echo 'DOCKER_IMAGE_TAG=latest' >> $GITHUB_ENV
|
||||
if: env.DOCKER_IMAGE_TAG == 'master'
|
||||
if: env.DOCKER_IMAGE_TAG == 'main'
|
||||
|
||||
- name: Set Docker Hub repository to username
|
||||
run: echo "DOCKER_REPOSITORY=jbarlow83" >> $GITHUB_ENV
|
||||
|
||||
+11
-11
@@ -1,14 +1,15 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
|
||||
# dotfiles
|
||||
.*
|
||||
!.coveragerc
|
||||
!.dockerignore
|
||||
!.git_archival.txt
|
||||
!.gitattributes
|
||||
!.gitignore
|
||||
!.pre-commit-config.yaml
|
||||
!.readthedocs.yaml
|
||||
!.github/
|
||||
!.docker/
|
||||
.coverage
|
||||
.venv*/
|
||||
.tox/
|
||||
.vscode/
|
||||
.hypothesis/
|
||||
.ipynb_checkpoints/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
|
||||
# Dev scratch
|
||||
*.ipynb
|
||||
@@ -42,4 +43,3 @@ docs/_build/
|
||||
docs/_static/
|
||||
docs/_templates/
|
||||
docs/Makefile
|
||||
ocrmypdf/lib/_*.py
|
||||
|
||||
+11
-16
@@ -1,33 +1,28 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: check-case-conflict
|
||||
- id: check-merge-conflict
|
||||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.10.1
|
||||
- repo: https://github.com/charliermarsh/ruff-pre-commit
|
||||
rev: "v0.0.261"
|
||||
hooks:
|
||||
- id: isort
|
||||
args: ["--profile", "black", "-a", "from __future__ import annotations"]
|
||||
- id: ruff
|
||||
files: "src/.*\\.pyi?$"
|
||||
args: [--fix, --exit-non-zero-on-fix]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.6.0
|
||||
rev: 23.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
language_version: python
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v1.20.2
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.37.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: ["--py37-plus"]
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.971
|
||||
rev: v1.2.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
additional_dependencies:
|
||||
|
||||
+4
-1
@@ -1,3 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
@@ -14,7 +17,7 @@ formats:
|
||||
|
||||
# Optionally set the version of Python and requirements required to build your docs
|
||||
python:
|
||||
version: "3.7"
|
||||
version: "3.8"
|
||||
install:
|
||||
- method: pip
|
||||
path: .
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
Upstream-Name: OCRmyPDF
|
||||
Upstream-Contact: James R. Barlow <james@purplerock.ca>
|
||||
Source: https://github.com/ocrmypdf/OCRmyPDF
|
||||
|
||||
|
||||
Files:
|
||||
.git_archival.txt
|
||||
docs/images/logo-social.png
|
||||
docs/images/logo-square-256.svg
|
||||
docs/images/logo-square.png
|
||||
docs/images/logo-square.svg
|
||||
docs/images/logo.svg
|
||||
setup.cfg
|
||||
Copyright: (C) 2022 James R. Barlow
|
||||
License: MPL-2.0
|
||||
|
||||
Files:
|
||||
.github/ISSUE_TEMPLATE/*.md
|
||||
docs/images/macos-workflow.png
|
||||
Copyright: (C) 2022 James R. Barlow
|
||||
License: CC-BY-SA-4.0
|
||||
|
||||
Files:
|
||||
tests/resources/acroform.pdf
|
||||
tests/resources/aspect.pdf
|
||||
tests/resources/blank.pdf
|
||||
tests/resources/cmyk.pdf
|
||||
tests/resources/crom.png
|
||||
tests/resources/enormous.pdf
|
||||
tests/resources/formxobject.pdf
|
||||
tests/resources/francais.pdf
|
||||
tests/resources/hugemono.pdf
|
||||
tests/resources/invalid.pdf
|
||||
tests/resources/kcs.pdf
|
||||
tests/resources/livecycle.pdf
|
||||
tests/resources/missing_docinfo.pdf
|
||||
tests/resources/negzero.pdf
|
||||
tests/resources/no_contents.pdf
|
||||
tests/resources/toc.pdf
|
||||
tests/resources/trivial.pdf
|
||||
tests/resources/truetype_font_nomapping.pdf
|
||||
tests/resources/type3_font_nomapping.pdf
|
||||
misc/screencast/*
|
||||
Copyright: (C) 2022 James R. Barlow
|
||||
License: CC-BY-SA-4.0
|
||||
|
||||
Files:
|
||||
tests/resources/graph.pdf
|
||||
tests/resources/graph_ocred.pdf
|
||||
Copyright: (C) 2012 SmokeyJoe
|
||||
License: GFDL-1.2-or-later or CC-BY-SA-3.0
|
||||
|
||||
Files: tests/resources/c02-22.pdf
|
||||
tests/resources/congress.jpg
|
||||
tests/resources/multipage.pdf
|
||||
Copyright: Public domain
|
||||
License: public-domain
|
||||
Copyright on these files has expired.
|
||||
|
||||
Files: docs/images/bitmap_vs_svg.svg
|
||||
Copyright: (C) 2006 Yug
|
||||
License: CC-BY-SA-2.5
|
||||
|
||||
Files: tests/cache/*
|
||||
Copyright: (C) 2022 James R. Barlow
|
||||
License: CC-BY-SA-4.0
|
||||
|
||||
Files: tests/resources/linn.png
|
||||
tests/resources/linn.pdf
|
||||
tests/resources/linn.txt
|
||||
tests/resources/ccitt.pdf
|
||||
tests/resources/cardinal.pdf
|
||||
tests/resources/jbig2.pdf
|
||||
tests/resources/skew.pdf
|
||||
tests/resources/rotated_skew.pdf
|
||||
tests/resources/poster.pdf
|
||||
Copyright: (C) 1985 Forat Electronics
|
||||
License: GFDL-1.2-or-later or CC-BY-SA-3.0
|
||||
|
||||
Files: tests/resources/lichtenstein.pdf
|
||||
Copyright: (C) 2001 Andreas Tille
|
||||
(C) 2007 Alessio Damato
|
||||
License: GFDL-1.2-or-later or CC-BY-SA-3.0
|
||||
|
||||
Files: tests/resources/masks.pdf
|
||||
Copyright: held by the contributors to the German Wikipedia article "Linux"
|
||||
see: https://de.wikipedia.org/w/index.php?title=Linux&action=history
|
||||
(masks.pdf generated from Wikipedia article as of 2016-08-24)
|
||||
License: CC-BY-SA-3.0
|
||||
|
||||
Files: tests/resources/epson.pdf
|
||||
Copyright: held by the contributors to the Wikipedia article "Optical character recognition"
|
||||
see: https://en.wikipedia.org/w/index.php?title=Optical_character_recognition&action=history
|
||||
(epson.pdf generated from Wikipedia article as of 2016-09-14)
|
||||
License: CC-BY-SA-3.0
|
||||
|
||||
Files: tests/resources/typewriter.png tests/resources/2400dpi.pdf
|
||||
Copyright: (C) 2005 Ellywa
|
||||
License: GFDL-1.2-or-later or CC-BY-SA-1.0 or CC-BY-SA-2.0 or CC-BY-SA-2.5 or CC-BY-SA-3.0
|
||||
Comment:
|
||||
Obtained from: https://commons.wikimedia.org/wiki/File:Triumph.typewriter_text_Linzensoep.gif
|
||||
|
||||
Files: tests/resources/overlay.pdf
|
||||
Copyright: (C) 2017 Max Anderson
|
||||
License: MIT
|
||||
|
||||
Files:
|
||||
tests/resources/baiona*.png
|
||||
tests/resources/baiona*.jpg
|
||||
tests/resources/link.pdf
|
||||
tests/resources/palette.pdf
|
||||
Copyright: (C) 2014 Euskaldunaa
|
||||
License: CC-BY-SA-4.0
|
||||
|
||||
Files: tests/resources/vector.pdf
|
||||
Copyright: (C) 2018 Catscratch
|
||||
License: MIT
|
||||
|
||||
Files: src/ocrmypdf/data/sRGB.icc
|
||||
Copyright: Kai-Uwe Behrmann <www.behrmann.name>
|
||||
Marti Maria <www.littlecms.com>
|
||||
Photogamut <www.photogamut.org>
|
||||
Graeme Gill <www.argyllcms.com>
|
||||
ColorSolutions <www.basICColor.com>
|
||||
License: Zlib
|
||||
|
||||
Files: tests/resources/3small.pdf
|
||||
Copyright: (C) 2014 Euskaldunaa
|
||||
(C) 2017 James R. Barlow
|
||||
(C) 2005 Ellywa
|
||||
License: CC-BY-SA-4.0 and (GFDL-1.2-or-later or CC-BY-SA-1.0 or CC-BY-SA-2.0 or CC-BY-SA-2.5 or CC-BY-SA-3.0)
|
||||
Comment: concatenation of baiona_gray.png, crom.png and typewriter.png/2400dpi.pdf
|
||||
@@ -1,3 +1,7 @@
|
||||
<!-- SPDX-FileCopyrightText: 2014 Julien Pfefferkorn -->
|
||||
<!-- SPDX-FileCopyrightText: 2015 James R. Barlow -->
|
||||
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
|
||||
|
||||
<img src="docs/images/logo.svg" width="240" alt="OCRmyPDF">
|
||||
|
||||
[](https://github.com/ocrmypdf/OCRmyPDF/actions/workflows/build.yml) [![PyPI version][pypi]](https://pypi.org/project/ocrmypdf/) ![Homebrew version][homebrew] ![ReadTheDocs][docs] ![Python versions][pyversions]
|
||||
@@ -38,6 +42,8 @@ ocrmypdf # it's a scriptable command line program
|
||||
- Scales properly to handle files with thousands of pages
|
||||
- Battle-tested on millions of PDFs
|
||||
|
||||
<img src="misc/screencast/demo.svg" alt="Demo of OCRmyPDF in a terminal session">
|
||||
|
||||
For details: please consult the [documentation](https://ocrmypdf.readthedocs.io/en/latest/).
|
||||
|
||||
## Motivation
|
||||
@@ -66,7 +72,7 @@ Linux, Windows, macOS and FreeBSD are supported. Docker images are also availabl
|
||||
| macOS (Homebrew) | ``brew install ocrmypdf`` |
|
||||
| macOS (nix) | ``nix-env -i ocrmypdf`` |
|
||||
| LinuxBrew | ``brew install ocrmypdf`` |
|
||||
| FreeBSD | ``pkg install py37-ocrmypdf`` |
|
||||
| FreeBSD | ``pkg install py-ocrmypdf`` |
|
||||
| Conda | ``conda install ocrmypdf`` |
|
||||
| Ubuntu Snap | ``snap install ocrmypdf`` |
|
||||
|
||||
@@ -92,10 +98,7 @@ brew install tesseract-lang
|
||||
|
||||
You can then pass the `-l LANG` argument to OCRmyPDF to give a hint as to what languages it should search for. Multiple languages can be requested.
|
||||
|
||||
OCRmyPDF supports Tesseract 4.0 and the beta versions of Tesseract 5.0. It will
|
||||
automatically use whichever version it finds first on the `PATH` environment
|
||||
variable. On Windows, if `PATH` does not provide a Tesseract binary, we use
|
||||
the highest version number that is installed according to the Windows Registry.
|
||||
OCRmyPDF supports Tesseract 4.1.1+. It will automatically use whichever version it finds first on the `PATH` environment variable. On Windows, if `PATH` does not provide a Tesseract binary, we use the highest version number that is installed according to the Windows Registry.
|
||||
|
||||
## Documentation and support
|
||||
|
||||
@@ -111,7 +114,7 @@ Please report issues on our [GitHub issues](https://github.com/ocrmypdf/OCRmyPDF
|
||||
|
||||
## Requirements
|
||||
|
||||
In addition to the required Python version (3.7+), 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 (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.
|
||||
|
||||
## Press & Media
|
||||
|
||||
@@ -122,6 +125,7 @@ In addition to the required Python version (3.7+), OCRmyPDF requires external pr
|
||||
- [heise Durchsuchbare PDF-Dokumente mit OCRmyPDF erstellen](https://www.heise.de/ratgeber/Durchsuchbare-PDF-Dokumente-mit-OCRmyPDF-erstellen-4607592.html)
|
||||
- [Excellent Utilities: OCRmyPDF](https://www.linuxlinks.com/excellent-utilities-ocrmypdf-add-ocr-text-layer-scanned-pdfs/)
|
||||
- [LinuxUser Texterkennung mit OCRmyPDF und Scanbd automatisieren](https://www.linux-community.de/ausgaben/linuxuser/2021/06/texterkennung-mit-ocrmypdf-und-scanbd-automatisieren/)
|
||||
- [Y Combinator discussion](https://news.ycombinator.com/item?id=32028752)
|
||||
|
||||
## Business enquiries
|
||||
|
||||
|
||||
+33
-7
@@ -10,7 +10,7 @@ Control of unpaper
|
||||
|
||||
OCRmyPDF uses ``unpaper`` to provide the implementation of the
|
||||
``--clean`` and ``--clean-final`` arguments.
|
||||
`unpaper <https://github.com/Flameeyes/unpaper/blob/master/doc/basic-concepts.md>`__
|
||||
`unpaper <https://github.com/Flameeyes/unpaper/blob/main/doc/basic-concepts.md>`__
|
||||
provides a variety of image processing filters to improve images.
|
||||
|
||||
By default, OCRmyPDF uses only ``unpaper`` arguments that were found to
|
||||
@@ -104,7 +104,7 @@ was requested, the preprocessed image layer will be inserted.
|
||||
If you want to adjust the amount of time spent on OCR, change
|
||||
``--tesseract-timeout``. You can also automatically skip images that
|
||||
exceed a certain number of megapixels with ``--skip-big``. (A 300 DPI,
|
||||
8.5×11" page is 8.4 megapixels.)
|
||||
8.5×11" page image is 8.4 megapixels.)
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
@@ -160,7 +160,7 @@ Changing tesseract configuration variables
|
||||
------------------------------------------
|
||||
|
||||
You can override tesseract's default `control
|
||||
parameters <https://github.com/tesseract-ocr/tesseract/wiki/ControlParams>`__
|
||||
parameters <https://tesseract-ocr.github.io/tessdoc/tess3/ControlParams.html>`__
|
||||
with a configuration file.
|
||||
|
||||
As an example, this configuration will disable Tesseract's dictionary
|
||||
@@ -241,6 +241,17 @@ PDF.js viewer.
|
||||
|
||||
This works in all versions of Tesseract.
|
||||
|
||||
Rendering and rasterizing options
|
||||
=================================
|
||||
|
||||
.. versionadded:: 14.3.0
|
||||
|
||||
The ``--continue-on-soft-render-error`` option allows OCRmyPDF to
|
||||
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,
|
||||
and you are willing to accept some pages that may not visually match the
|
||||
input, and that may not OCR well.
|
||||
|
||||
Return code policy
|
||||
==================
|
||||
|
||||
@@ -299,16 +310,31 @@ stable user interface. They may be imported from
|
||||
- The program was interrupted by pressing Ctrl+C.
|
||||
|
||||
|
||||
.. _tmpdir:
|
||||
|
||||
Changing temporary storage location
|
||||
===================================
|
||||
|
||||
OCRmyPDF generates many temporary files during processing.
|
||||
|
||||
To change where temporary files are stored, change the ``TMPDIR``
|
||||
environment variable for ocrmypdf's environment. (Python's
|
||||
``tempfile.gettempdir()`` returns the root directory in which temporary
|
||||
files will be stored.) For example, one could redirect ``TMPDIR`` to a
|
||||
large RAM disk to avoid wear on HDD/SSD and potentially improve
|
||||
performance.
|
||||
|
||||
On Windows, the ``TEMP`` environment variable is used instead.
|
||||
|
||||
Debugging the intermediate files
|
||||
================================
|
||||
|
||||
OCRmyPDF normally saves its intermediate results to a temporary folder
|
||||
and deletes this folder when it exits, whether it succeeded or failed.
|
||||
|
||||
If the ``-k`` argument is issued on the command line, OCRmyPDF will keep
|
||||
the temporary folder and print the location, whether it succeeded or
|
||||
failed (provided the Python interpreter did not crash). An example
|
||||
message is:
|
||||
If the ``--keep-temporary-files`` (``-k```) argument is issued on the
|
||||
command line, OCRmyPDF will keep the temporary folder and print the location,
|
||||
whether it succeeded or failed. An example message is:
|
||||
|
||||
.. code-block:: none
|
||||
|
||||
|
||||
@@ -72,14 +72,6 @@ OCRmyPDF, use processes.
|
||||
not take at least one of these steps, process semantics will prevent
|
||||
OCRmyPDF from working correctly.
|
||||
|
||||
.. warning::
|
||||
|
||||
On macOS with Python 3.7, you must call
|
||||
:func:`multiprocessing.set_start_method("spawn")`. Without this, multiprocessing
|
||||
will be unstable. From the command line, OCRmyPDF does this automatically,
|
||||
but as an API user you must do this. See Python bpo-33725 for details.
|
||||
Python 3.8+ also resolve this automatically.
|
||||
|
||||
Logging
|
||||
-------
|
||||
|
||||
|
||||
+18
-18
@@ -21,8 +21,8 @@ processors. To maximize parallelism without overloading your system with
|
||||
processes, consider using ``parallel -j 2`` to limit parallel to running
|
||||
two jobs at once.
|
||||
|
||||
This command will run all ocrmypdf all files named ``*.pdf`` in the
|
||||
current directory and write them to the previous created ``output/``
|
||||
This command will run ``ocrmypdf`` on all files named ``*.pdf`` in the
|
||||
current directory and write them to the previously created ``output/``
|
||||
folder. It will not search subdirectories.
|
||||
|
||||
The ``--tag`` argument tells parallel to print the filename as a prefix
|
||||
@@ -133,7 +133,7 @@ Users may need to customize the script to meet their requirements.
|
||||
"OCR_OUTPUT_DIRECTORY", "Set output directory (should not be under input)"
|
||||
"OCR_ARCHIVE_DIRECTORY", "Set archive directory for processed originals (should not be under input, requires ``OCR_ON_SUCCESS_ARCHIVE`` to be set)"
|
||||
"OCR_ON_SUCCESS_DELETE", "This will delete the input file if the exit code is 0 (OK)"
|
||||
"OCR_ON_SUCCESS_ARCHIVE", "This will move the processed orignal file to ``OCR_ARCHIVE_DIRECTORY`` if the exit code is 0 (OK). Note that ``OCR_ON_SUCCESS_DELETE`` takes precedence over this option, i.e. if both options are set, the input file will be deleted."
|
||||
"OCR_ON_SUCCESS_ARCHIVE", "This will move the processed original file to ``OCR_ARCHIVE_DIRECTORY`` if the exit code is 0 (OK). Note that ``OCR_ON_SUCCESS_DELETE`` takes precedence over this option, i.e. if both options are set, the input file will be deleted."
|
||||
"OCR_OUTPUT_DIRECTORY_YEAR_MONTH", "This will place files in the output in ``{output}/{year}/{month}/{filename}``"
|
||||
"OCR_DESKEW", "Apply deskew to crooked input PDFs"
|
||||
"OCR_JSON_SETTINGS", "A JSON string specifying any other arguments for ``ocrmypdf.ocr``, e.g. ``'OCR_JSON_SETTINGS={""rotate_pages"": true}'``."
|
||||
@@ -151,14 +151,14 @@ The watcher service is included in the OCRmyPDF Docker image. To run it:
|
||||
.. code-block:: bash
|
||||
|
||||
docker run \
|
||||
-v <path to files to convert>:/input \
|
||||
-v <path to store results>:/output \
|
||||
-v <path to store processed originals>:/archive \
|
||||
-e OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1 \
|
||||
-e OCR_ON_SUCCESS_ARCHIVE=1 \
|
||||
-e OCR_DESKEW=1 \
|
||||
-e PYTHONUNBUFFERED=1 \
|
||||
-it --entrypoint python3 \
|
||||
--volume <path to files to convert>:/input \
|
||||
--volume <path to store results>:/output \
|
||||
--volume <path to store processed originals>:/archive \
|
||||
--env OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1 \
|
||||
--env OCR_ON_SUCCESS_ARCHIVE=1 \
|
||||
--env OCR_DESKEW=1 \
|
||||
--env PYTHONUNBUFFERED=1 \
|
||||
--interactive --tty --entrypoint python3 \
|
||||
jbarlow83/ocrmypdf \
|
||||
watcher.py
|
||||
|
||||
@@ -170,13 +170,13 @@ original to ``/archive``. The parameters to this image are:
|
||||
:header: "Parameter", "Description"
|
||||
:widths: 50, 50
|
||||
|
||||
"``-v <path to files to convert>:/input``", "Files placed in this location will be OCRed"
|
||||
"``-v <path to store results>:/output``", "This is where OCRed files will be stored"
|
||||
"``-v <path to store processed originals>:/archive``", "Archive processed originals here"
|
||||
"``-e OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1``", "Define environment variable ``OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1`` to place files in the output in ``{output}/{year}/{month}/{filename}``"
|
||||
"``-e OCR_ON_SUCCESS_ARCHIVE=1``", "Define environment variable ``OCR_ON_SUCCESS_ARCHIVE`` to move processed originals"
|
||||
"``-e OCR_DESKEW=1``", "Define environment variable ``OCR_DESKEW`` to apply deskew to crooked input PDFs"
|
||||
"``-e PYTHONBUFFERED=1``", "This will force ``STDOUT`` to be unbuffered and allow you to see messages in docker logs"
|
||||
"``--volume <path to files to convert>:/input``", "Files placed in this location will be OCRed"
|
||||
"``--volume <path to store results>:/output``", "This is where OCRed files will be stored"
|
||||
"``--volume <path to store processed originals>:/archive``", "Archive processed originals here"
|
||||
"``--env OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1``", "Define environment variable ``OCR_OUTPUT_DIRECTORY_YEAR_MONTH=1`` to place files in the output in ``{output}/{year}/{month}/{filename}``"
|
||||
"``--env OCR_ON_SUCCESS_ARCHIVE=1``", "Define environment variable ``OCR_ON_SUCCESS_ARCHIVE`` to move processed originals"
|
||||
"``--env OCR_DESKEW=1``", "Define environment variable ``OCR_DESKEW`` to apply deskew to crooked input PDFs"
|
||||
"``--env PYTHONBUFFERED=1``", "This will force ``STDOUT`` to be unbuffered and allow you to see messages in docker logs"
|
||||
|
||||
This service relies on polling to check for changes to the filesystem. It
|
||||
may not be suitable for some environments, such as filesystems shared on a
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
.. _ocr-service:
|
||||
|
||||
==================
|
||||
Online deployments
|
||||
==================
|
||||
|
||||
OCRmyPDF is designed to be used as a command line tool, but it can be
|
||||
used in a web service. This document describes some considerations for
|
||||
doing so.
|
||||
|
||||
A basic web service implementation is provided in the source code
|
||||
repository, as ``misc/webservice.py``. It is only demonstration quality
|
||||
and is not intended for production use.
|
||||
|
||||
OCRmyPDF is not designed for use as a public web service where a
|
||||
malicious user could upload a chosen PDF. In particular, it is not
|
||||
necessarily secure against PDF malware or PDFs that cause denial of
|
||||
service. For further discussino of security, see :ref:`security`.
|
||||
|
||||
OCRmyPDF relies on Ghostscript, and therefore, if deployed
|
||||
online one should be prepared to comply with Ghostscript's Affero GPL
|
||||
license, and any other licenses.
|
||||
|
||||
Setting aside these concerns, a side effect of OCRmyPDF is that it may
|
||||
incidentally sanitize PDFs containing certain types of malware. It
|
||||
repairs the PDF with pikepdf/libqpdf, which could correct malformed PDF
|
||||
structures that are part of an attack. When PDF/A output is selected
|
||||
(the default), the input PDF is partially reconstructed by Ghostscript.
|
||||
When ``--force-ocr`` is used, all pages are rasterized and reconverted
|
||||
to PDF, which could remove malware in embedded images.
|
||||
|
||||
Limiting CPU usage
|
||||
------------------
|
||||
|
||||
OCRmyPDF will attempt to use all available CPUs and storage, so
|
||||
executing ``nice ocrmypdf`` or limiting the number of jobs with the
|
||||
``--jobs`` argument may ensure the server remains responsive. Another option
|
||||
would be to run OCRmyPDF jobs inside a Docker container, a virtual machine,
|
||||
or a cloud instance, which can impose its own limits on CPU usage and be
|
||||
terminated "from orbit" if it fails to complete.
|
||||
|
||||
Temporary storage requirements
|
||||
------------------------------
|
||||
|
||||
OCRmyPDF will use a large amount of temporary storage for its work,
|
||||
proportional to the total number of pixels needed to rasterize the PDF.
|
||||
The raster image of a 8.5×11" color page at 300 DPI takes 25 MB
|
||||
uncompressed; OCRmyPDF saves its intermediates as PNG, but that still
|
||||
means it requires about 9 MB per intermediate based on average
|
||||
compression ratios. Multiple intermediates per page are also required,
|
||||
depending on the command line given. A rule of thumb would be to allow
|
||||
100 MB of temporary storage per page in a file – meaning that a small
|
||||
cloud servers or small VM partitions should be provisioned with plenty
|
||||
of extra space, if say, a 500 page file might be sent.
|
||||
|
||||
To change the temporary directory, see :ref:`tmpdir`.
|
||||
|
||||
On Amazon Web Services or other cloud vendors, consider setting your
|
||||
temporary directory to `empheral
|
||||
storage <https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/InstanceStorage.html>`__.
|
||||
|
||||
Timeouts
|
||||
--------
|
||||
|
||||
To prevent excessively long OCR jobs consider setting
|
||||
``--tesseract-timeout`` and/or ``--skip-big`` arguments. ``--skip-big``
|
||||
is particularly helpful if your PDFs include documents such as reports
|
||||
on standard page sizes with large images attached - often large images
|
||||
are not worth OCR'ing anyway.
|
||||
|
||||
Document management systems
|
||||
---------------------------
|
||||
|
||||
If you are looking for a full document management system, consider
|
||||
`paperless-ngx <https://github.com/paperless-ngx/paperless-ngx>`__,
|
||||
which is a web application that uses OCRmyPDF to automatically OCR and
|
||||
archive documents.
|
||||
|
||||
Commercial OCR alternatives
|
||||
---------------------------
|
||||
|
||||
The author also provides professional services that include OCR and
|
||||
building databases around PDFs, and is happy to provide consultation.
|
||||
|
||||
Abbyy Cloud OCR is viable commercial alternative with a web services
|
||||
API. Amazon Textract, Google Cloud Vision, and Microsoft Azure
|
||||
Computer Vision provide advanced OCR but have less PDF rendering capability.
|
||||
+3
-6
@@ -2,6 +2,8 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
|
||||
# ruff: noqa: E402
|
||||
|
||||
# ocrmypdf documentation build configuration file, created by
|
||||
# sphinx-quickstart on Sun Sep 4 14:29:43 2016.
|
||||
#
|
||||
@@ -22,8 +24,6 @@
|
||||
# import sys
|
||||
# sys.path.insert(0, os.path.abspath('.'))
|
||||
|
||||
"""isort:skip_file"""
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
# If your documentation needs a minimal Sphinx version, state it here.
|
||||
@@ -76,6 +76,7 @@ author = 'James R. Barlow'
|
||||
# The short X.Y version.
|
||||
|
||||
import os
|
||||
from importlib.metadata import version as package_version
|
||||
|
||||
on_rtd = os.environ.get('READTHEDOCS') == 'True'
|
||||
|
||||
@@ -96,10 +97,6 @@ if on_rtd:
|
||||
]
|
||||
sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES)
|
||||
|
||||
try:
|
||||
from importlib_metadata import version as package_version
|
||||
except ModuleNotFoundError:
|
||||
from importlib.metadata import version as package_version
|
||||
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = package_version('ocrmypdf')
|
||||
|
||||
@@ -18,7 +18,7 @@ work you're contemplating is already half-done in a development branch.
|
||||
Code style
|
||||
==========
|
||||
|
||||
We use PEP8, ``black`` for code formatting and ``isort`` for import sorting. The
|
||||
We use PEP8, ``black`` for code formatting and ``ruff`` for everything else. The
|
||||
settings for these programs are in ``pyproject.toml`` and ``setup.cfg``. Pull
|
||||
requests should follow the style guide. One difference we use from "black" style
|
||||
is that strings shown to the user are always in double quotes (``"``) and strings
|
||||
|
||||
+2
-2
@@ -283,7 +283,7 @@ argument. (Normally, OCRmyPDF will exit with an error if asked to modify
|
||||
a file with OCR.)
|
||||
|
||||
This may be helpful for users who want to take advantage of accuracy
|
||||
improvements in Tesseract 4.0 for files they previously OCRed with an
|
||||
improvements in Tesseract for files they previously OCRed with an
|
||||
earlier version of Tesseract and OCRmyPDF.
|
||||
|
||||
.. code-block:: bash
|
||||
@@ -360,7 +360,7 @@ The types of optimization available may expand over time. By default,
|
||||
OCRmyPDF compresses data streams inside PDFs, and will change
|
||||
inefficient compression modes to more modern versions. A program like
|
||||
``qpdf`` can be used to change encodings, e.g. to inspect the internals
|
||||
fo a PDF.
|
||||
for a PDF.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
|
||||
+2
-1
@@ -20,7 +20,6 @@ image processing and OCR to existing PDFs.
|
||||
introduction
|
||||
release_notes
|
||||
installation
|
||||
optimizer
|
||||
languages
|
||||
jbig2
|
||||
|
||||
@@ -29,9 +28,11 @@ image processing and OCR to existing PDFs.
|
||||
:maxdepth: 2
|
||||
|
||||
cookbook
|
||||
optimizer
|
||||
docker
|
||||
advanced
|
||||
batch
|
||||
cloud
|
||||
performance
|
||||
pdfsecurity
|
||||
errors
|
||||
|
||||
+24
-65
@@ -21,7 +21,7 @@ These platforms have one-liner installs:
|
||||
+-------------------------------+-----------------------------------------+
|
||||
| Windows Subsystem for Linux | ``apt install ocrmypdf`` |
|
||||
+-------------------------------+-----------------------------------------+
|
||||
| Fedora | ``dnf install ocrmypdf`` |
|
||||
| Fedora | ``dnf install ocrmypdf tesseract-osd`` |
|
||||
+-------------------------------+-----------------------------------------+
|
||||
| macOS | ``brew install ocrmypdf`` |
|
||||
+-------------------------------+-----------------------------------------+
|
||||
@@ -44,7 +44,7 @@ install, or install a more recent version than your platform provides, read on.
|
||||
Installing on Linux
|
||||
===================
|
||||
|
||||
Debian and Ubuntu 18.04 or newer
|
||||
Debian and Ubuntu 20.04 or newer
|
||||
--------------------------------
|
||||
|
||||
.. |deb-11| image:: https://repology.org/badge/version-for-repo/debian_11/ocrmypdf.svg
|
||||
@@ -56,9 +56,6 @@ Debian and Ubuntu 18.04 or newer
|
||||
.. |deb-unstable| image:: https://repology.org/badge/version-for-repo/debian_unstable/ocrmypdf.svg
|
||||
:alt: Debian unstable
|
||||
|
||||
.. |ubu-1804| image:: https://repology.org/badge/version-for-repo/ubuntu_18_04/ocrmypdf.svg
|
||||
:alt: Ubuntu 18.04 LTS
|
||||
|
||||
.. |ubu-2004| image:: https://repology.org/badge/version-for-repo/ubuntu_20_04/ocrmypdf.svg
|
||||
:alt: Ubuntu 20.04 LTS
|
||||
|
||||
@@ -72,7 +69,7 @@ Debian and Ubuntu 18.04 or newer
|
||||
+-----------------------------------------------+
|
||||
| |deb-11| |deb-12| |deb-unstable| |
|
||||
+-----------------------------------------------+
|
||||
| |ubu-1804| |ubu-2004| |ubu-2204| |
|
||||
| |ubu-2004| |ubu-2204| |
|
||||
+-----------------------------------------------+
|
||||
|
||||
Users of Debian 9 ("stretch") or later, or Ubuntu 18.04 or later, including users
|
||||
@@ -80,7 +77,7 @@ of Windows Subsystem for Linux, may simply
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
apt-get install ocrmypdf
|
||||
apt install ocrmypdf
|
||||
|
||||
As indicated in the table above, Debian and Ubuntu releases may lag
|
||||
behind the latest version. If the version available for your platform is
|
||||
@@ -124,7 +121,7 @@ Users of Fedora 29 or later may simply
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
dnf install ocrmypdf
|
||||
dnf install ocrmypdf tesseract-osd
|
||||
|
||||
For full details on version availability, check the `Fedora Package
|
||||
Tracker <https://apps.fedoraproject.org/packages/ocrmypdf>`__.
|
||||
@@ -198,46 +195,6 @@ To install for the current user only:
|
||||
|
||||
To add JBIG2 encoding, see :ref:`jbig2`.
|
||||
|
||||
Ubuntu 18.04 LTS
|
||||
----------------
|
||||
|
||||
Ubuntu 18.04 includes ocrmypdf 6.1.2 - you can install that with ``apt``, but
|
||||
it is quite old now. To install a more recent version, uninstall the old version
|
||||
of ocrmypdf, and install the following dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
sudo apt-get -y remove ocrmypdf
|
||||
sudo apt-get -y update
|
||||
sudo apt-get -y install \
|
||||
ghostscript \
|
||||
icc-profiles-free \
|
||||
libxml2 \
|
||||
pngquant \
|
||||
python3-distutils \
|
||||
python3-pkg-resources \
|
||||
python3-reportlab \
|
||||
qpdf \
|
||||
tesseract-ocr \
|
||||
zlib1g \
|
||||
unpaper
|
||||
|
||||
We will need a newer version of ``pip`` then was available for Ubuntu 18.04:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
wget https://bootstrap.pypa.io/get-pip.py && python3 get-pip.py
|
||||
|
||||
Then install the most recent ocrmypdf for the local user and set the
|
||||
user's ``PATH`` to check for the user's Python packages.
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
export PATH=$HOME/.local/bin:$PATH
|
||||
python3 -m pip install --user ocrmypdf
|
||||
|
||||
To add JBIG2 encoding, see :ref:`jbig2`.
|
||||
|
||||
Arch Linux (AUR)
|
||||
----------------
|
||||
|
||||
@@ -336,7 +293,7 @@ Homebrew
|
||||
|
||||
.. image:: https://img.shields.io/homebrew/v/ocrmypdf.svg
|
||||
:alt: homebrew
|
||||
:target: http://brewformulas.org/Ocrmypdf
|
||||
:target: https://formulae.brew.sh/formula/ocrmypdf
|
||||
|
||||
OCRmyPDF is now a standard `Homebrew <https://brew.sh>`__ formula. To
|
||||
install on macOS:
|
||||
@@ -417,9 +374,9 @@ Native Windows
|
||||
|
||||
You must install the following for Windows:
|
||||
|
||||
* Python 3.7 (64-bit) or later
|
||||
* Tesseract 4.0 or later
|
||||
* Ghostscript 9.50 or later
|
||||
* Python 3.8 (64-bit) or later
|
||||
* Tesseract 4.1.1 (64-bit) or later
|
||||
* Ghostscript 9.50 (64-bit) or later
|
||||
|
||||
Using the `Chocolatey <https://chocolatey.org/>`_ package manager, install the
|
||||
following when running in an Administrator command prompt:
|
||||
@@ -438,10 +395,8 @@ Administrator.):
|
||||
|
||||
* ``pip install ocrmypdf``
|
||||
|
||||
Chocolatey automatically selects appropriate versions of these applications. If you
|
||||
are installing them manually, please install 64-bit versions of all applications for
|
||||
64-bit Windows, or 32-bit versions of all applications for 32-bit Windows. Mixing
|
||||
the "bitness" of these programs will lead to errors.
|
||||
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
|
||||
for third party software it needs (specifically, Tesseract and Ghostscript). To
|
||||
@@ -456,6 +411,10 @@ to change the PATH.
|
||||
Please download Python from Python.org or Chocolatey instead, and do not use the
|
||||
Microsoft Store version.
|
||||
|
||||
.. warning::
|
||||
|
||||
32-bit Windows might work, but is not supported.
|
||||
|
||||
Windows Subsystem for Linux
|
||||
---------------------------
|
||||
|
||||
@@ -481,7 +440,7 @@ Cygwin64
|
||||
|
||||
First install the the following prerequisite Cygwin packages using ``setup-x86_64.exe``::
|
||||
|
||||
python37 (or later)
|
||||
python38 (or later)
|
||||
python3?-devel
|
||||
python3?-pip
|
||||
python3?-lxml
|
||||
@@ -571,7 +530,7 @@ programs being installed.
|
||||
``apt install python3-pip python3-venv``, create a virtual environment,
|
||||
and install OCRmyPDF in that environment.
|
||||
|
||||
`See here for more inforation on Debian-Python issues
|
||||
`See here for more information on Debian-Python issues
|
||||
<https://gist.github.com/tiran/2dec9e03c6f901814f6d1e8dad09528e>`__.
|
||||
|
||||
For best results, first install `your platform's
|
||||
@@ -616,9 +575,9 @@ manager. ``pip`` cannot provide them.
|
||||
|
||||
The following versions are required:
|
||||
|
||||
- Python 3.7 or newer
|
||||
- Ghostscript 9.23 or newer
|
||||
- Tesseract 4.0.0 or newer
|
||||
- Python 3.8 or newer
|
||||
- Ghostscript 9.50 or newer
|
||||
- Tesseract 4.1.1 or newer
|
||||
- jbig2enc 0.29 or newer
|
||||
- pngquant 2.5 or newer
|
||||
- unpaper 6.1
|
||||
@@ -649,7 +608,7 @@ unfortunately, the ``pip install`` command cannot satisfy all of them.
|
||||
Installing HEAD revision from sources
|
||||
=====================================
|
||||
|
||||
If you have ``git`` and Python 3.7 or newer installed, you can install
|
||||
If you have ``git`` and Python 3.8 or newer installed, you can install
|
||||
from source. When the ``pip`` installer runs, it will alert you if
|
||||
dependencies are missing.
|
||||
|
||||
@@ -678,8 +637,8 @@ system-wide:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone -b master https://github.com/ocrmypdf/OCRmyPDF.git
|
||||
python3 -m venv
|
||||
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
cd OCRmyPDF
|
||||
pip install .
|
||||
@@ -705,7 +664,7 @@ To install all of the development and test requirements:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone -b master https://github.com/ocrmypdf/OCRmyPDF.git
|
||||
git clone -b main https://github.com/ocrmypdf/OCRmyPDF.git
|
||||
python -m venv
|
||||
source venv/bin/activate
|
||||
cd OCRmyPDF
|
||||
|
||||
@@ -85,7 +85,7 @@ OCRmyPDF analyzes each page of a PDF to determine the colorspace and
|
||||
resolution (DPI) needed to capture all of the information on that page
|
||||
without losing content. It uses
|
||||
`Ghostscript <http://ghostscript.com/>`__ to rasterize the page, and
|
||||
then performs on OCR the rasterized image to create an OCR "layer".
|
||||
then performs OCR on the rasterized image to create an OCR "layer".
|
||||
The layer is then grafted back onto the original PDF.
|
||||
|
||||
While one can use a program like Ghostscript or ImageMagick to get an
|
||||
@@ -190,11 +190,10 @@ Ghostscript also imposes some limitations:
|
||||
behavior can be suppressed by setting ``--pdfa-image-compression`` to
|
||||
``jpeg`` or ``lossless`` to set all images to one type or the other.
|
||||
Ghostscript has no option to maintain the input image's format.
|
||||
(Ghostscript 9.25+ can copy JPEG images without transcoding them;
|
||||
earlier versions will transcode.)
|
||||
(Modern Ghostscript can copy JPEG images without transcoding them.)
|
||||
- Ghostscript's PDF/A conversion removes any XMP metadata that is not
|
||||
one of the standard XMP metadata namespaces for PDFs. In particular,
|
||||
PRISM Metdata is removed.
|
||||
PRISM Metadata is removed.
|
||||
- Ghostscript's PDF/A conversion seems to remove or deactivate
|
||||
hyperlinks and other active content.
|
||||
|
||||
|
||||
+2
-1
@@ -37,7 +37,8 @@ For all other Linux, you must build a JBIG2 encoder from source:
|
||||
.. _jbig2-lossy:
|
||||
|
||||
Dependencies include libtoolize and libleptonica, which on Ubuntu systems
|
||||
are packaged as libtool and libleptonica-dev.
|
||||
are packaged as libtool and libleptonica-dev. On Fedora (35) they are packaged
|
||||
as libtool and leptonica-devel.
|
||||
|
||||
Lossy mode JBIG2
|
||||
================
|
||||
|
||||
+8
-8
@@ -12,7 +12,7 @@ OCRmyPDF uses Tesseract for OCR, and relies on its language packs for all langua
|
||||
On most platforms, English is installed with Tesseract by default, but not always.
|
||||
|
||||
Tesseract supports `most
|
||||
languages <https://github.com/tesseract-ocr/tesseract/blob/master/doc/tesseract.1.asc#languages>`__.
|
||||
languages <https://github.com/tesseract-ocr/tesseract/blob/main/doc/tesseract.1.asc#languages>`__.
|
||||
Languages are identified by standardized three-letter codes (called ISO 639-2 Alpha-3).
|
||||
Tesseract's documentation also lists the three-letter code for your language.
|
||||
Some are anglicized, e.g. Spanish is ``spa`` rather than ``esp``, while others
|
||||
@@ -70,13 +70,13 @@ This enables these languages for all packages (e.g. including aspell).
|
||||
|
||||
# Display a list of all Tesseract language packs
|
||||
equery uses app-text/tessdata_fast
|
||||
|
||||
|
||||
# Add English and German language support for Tesseract only
|
||||
echo 'app-text/tessdata_fast l10n_de l10n_en' >> /etc/portage/package.use
|
||||
|
||||
# Add global English and German language support (the `l10n_` from equery has to be omited)
|
||||
|
||||
# Add global English and German language support (the `l10n_` from equery has to be omitted)
|
||||
echo L10N="de en" >> /etc/portage/make.conf
|
||||
|
||||
|
||||
# update system to reflect changed USE flags
|
||||
emerge --update --deep --newuse @world
|
||||
|
||||
@@ -101,7 +101,7 @@ derived Docker image as
|
||||
Windows users
|
||||
=============
|
||||
|
||||
The Tesseract installer provided by Chocolatey currently includes only English language.
|
||||
To install other languages, download the respective language pack (``.traineddata`` file)
|
||||
from https://github.com/tesseract-ocr/tessdata/ and place it in
|
||||
The Tesseract installer provided by Chocolatey currently includes only English language.
|
||||
To install other languages, download the respective language pack (``.traineddata`` file)
|
||||
from https://github.com/tesseract-ocr/tessdata/ and place it in
|
||||
``C:\\Program Files\\Tesseract-OCR\\tessdata`` (or wherever Tesseract OCR is installed).
|
||||
|
||||
+30
-9
@@ -13,14 +13,33 @@ tuned. Optimization occurs after OCR, and only if OCR succeeded. It does not
|
||||
perform other possible optimizations such as deduplicating resources,
|
||||
consolidating fonts, simplifying vector drawings, or anything of that nature.
|
||||
|
||||
Optimization ranges from ``-O0`` through ``-O3``, where ``0`` disables
|
||||
optimization and ``3`` implements all options. ``1``, the default, performs only
|
||||
safe and lossless optimizations. (This is similar to GCC's optimization
|
||||
parameter.) The exact type of optimizations performed will vary over time.
|
||||
.. list-table:: Title
|
||||
:widths: 33 6 60
|
||||
:header-rows: 1
|
||||
|
||||
PDF optimization requires third-party, optional tools for certain optimizations.
|
||||
If these are not installed or cannot be found by OCRmyPDF, optimization will not
|
||||
be as good.
|
||||
* - Optimization level
|
||||
- Shorthand
|
||||
- Description
|
||||
* - ``--optimize 0``
|
||||
- ``-O0``
|
||||
- Disable most optimizations.
|
||||
* - ``--optimize 1`` (default)
|
||||
- ``-O1``
|
||||
- Safe and lossless optimizations.
|
||||
* - ``--optimize 2``
|
||||
- ``-O2``
|
||||
- Safe and lossy optimizations.
|
||||
* - ``--optimize 3``
|
||||
- ``-O3``
|
||||
- Aggressive lossy optimizations.
|
||||
|
||||
The exact type of optimizations performed will vary over time, and depend on
|
||||
the availability of third-party tools.
|
||||
|
||||
Despite optimizations, OCRmyPDF might still increase the overall file size,
|
||||
since it must embed information about the recognized text, and depending on the
|
||||
settings chosen, may not be able to represent the output file as compactly as
|
||||
the input file.
|
||||
|
||||
Optimizations that always occurs
|
||||
================================
|
||||
@@ -37,12 +56,14 @@ Fast web view
|
||||
OCRmyPDF automatically optimizes PDFs for "fast web view" in Adobe Acrobat's
|
||||
parlance, or equivalently, linearizes PDFs so that the resources they reference
|
||||
are presented in the order a viewer needs them for sequential display. This
|
||||
reduces the latency of viewing a PDF both online and from local storage. This
|
||||
actually slightly increases the file size.
|
||||
reduces the latency of viewing a PDF both online and from local storage, in
|
||||
exchange for a slight increase in file size.
|
||||
|
||||
To disable this optimization and all others, use ``ocrmypdf --optimize 0 ...``
|
||||
or the shorthand ``-O0``.
|
||||
|
||||
Adobe Acrobat might not report the file as being "fast web view".
|
||||
|
||||
Lossless optimizations
|
||||
======================
|
||||
|
||||
|
||||
+6
-88
@@ -29,13 +29,9 @@ attack vectors.
|
||||
In short, PDFs `may contain
|
||||
viruses <https://security.stackexchange.com/questions/64052/can-a-pdf-file-contain-a-virus>`__.
|
||||
|
||||
This
|
||||
`article <https://theinvisiblethings.blogspot.ca/2013/02/converting-untrusted-pdfs-into-trusted.html>`__
|
||||
describes a high-paranoia method which allows potentially hostile PDFs
|
||||
to be viewed and rasterized safely in a disposable virtual machine. A
|
||||
trusted PDF created in this manner is converted to images and loses all
|
||||
information making it searchable and losing all compression. OCRmyPDF
|
||||
could be used to restore searchability.
|
||||
If you do not trust a PDF or its source, do not open it or use OCRmyPDF
|
||||
on it. Consider using a Docker container or virtual machine to isolate
|
||||
an untrusted PDF from your system.
|
||||
|
||||
How OCRmyPDF processes PDFs
|
||||
===========================
|
||||
@@ -43,11 +39,11 @@ How OCRmyPDF processes PDFs
|
||||
OCRmyPDF must open and interpret your PDF in order to insert an OCR
|
||||
layer. First, it runs all PDFs through
|
||||
`pikepdf <https://github.com/pikepdf/pikepdf>`__, a library based on
|
||||
`qpdf <https://github.com/qpdf/qpdf>`__, a program that repairs PDFs
|
||||
`QPDF <https://github.com/qpdf/qpdf>`__, a program that repairs PDFs
|
||||
with syntax errors. This is done because, in the author's experience, a
|
||||
significant number of PDFs in the wild, especially those created by
|
||||
scanners, are not well-formed files. qpdf makes it more likely that
|
||||
OCRmyPDF will succeed, but offers no security guarantees. qpdf is also
|
||||
scanners, are not well-formed files. QPDF makes it more likely that
|
||||
OCRmyPDF will succeed, but offers no security guarantees. QPDF is also
|
||||
used to split the PDF into single page PDFs.
|
||||
|
||||
Finally, OCRmyPDF rasterizes each page of the PDF using
|
||||
@@ -58,84 +54,6 @@ into the existing PDF or it may essentially reconstruct ("re-fry") a
|
||||
visually identical PDF that may be quite different at the binary level.
|
||||
That said, OCRmyPDF is not a tool designed for sanitizing PDFs.
|
||||
|
||||
.. _ocr-service:
|
||||
|
||||
Using OCRmyPDF online or as a service
|
||||
=====================================
|
||||
|
||||
OCRmyPDF is not designed for use as a public web service where a
|
||||
malicious user could upload a chosen PDF. In particular, it is not
|
||||
necessarily secure against PDF malware or PDFs that cause denial of
|
||||
service. OCRmyPDF relies on Ghostscript, and therefore, if deployed
|
||||
online one should be prepared to comply with Ghostscript's Affero GPL
|
||||
license, and any other licenses.
|
||||
|
||||
Setting aside these concerns, a side effect of OCRmyPDF is that it may
|
||||
incidentally sanitize PDFs containing certain types of malware. It
|
||||
repairs the PDF with pikepdf/libqpdf, which could correct malformed PDF
|
||||
structures that are part of an attack. When PDF/A output is selected
|
||||
(the default), the input PDF is partially reconstructed by Ghostscript.
|
||||
When ``--force-ocr`` is used, all pages are rasterized and reconverted
|
||||
to PDF, which could remove malware in embedded images.
|
||||
|
||||
OCRmyPDF should be relatively safe to use in a trusted intranet, with
|
||||
some considerations:
|
||||
|
||||
Limiting CPU usage
|
||||
------------------
|
||||
|
||||
OCRmyPDF will attempt to use all available CPUs and storage, so
|
||||
executing ``nice ocrmypdf`` or limiting the number of jobs with the
|
||||
``-j`` argument may ensure the server remains available. Another option
|
||||
would be to run OCRmyPDF jobs inside a Docker container, a virtual machine,
|
||||
or a cloud instance, which can impose its own limits on CPU usage and be
|
||||
terminated "from orbit" if it fails to complete.
|
||||
|
||||
Temporary storage requirements
|
||||
------------------------------
|
||||
|
||||
OCRmyPDF will use a large amount of temporary storage for its work,
|
||||
proportional to the total number of pixels needed to rasterize the PDF.
|
||||
The raster image of a 8.5×11" color page at 300 DPI takes 25 MB
|
||||
uncompressed; OCRmyPDF saves its intermediates as PNG, but that still
|
||||
means it requires about 9 MB per intermediate based on average
|
||||
compression ratios. Multiple intermediates per page are also required,
|
||||
depending on the command line given. A rule of thumb would be to allow
|
||||
100 MB of temporary storage per page in a file – meaning that a small
|
||||
cloud servers or small VM partitions should be provisioned with plenty
|
||||
of extra space, if say, a 500 page file might be sent.
|
||||
|
||||
To check temporary storage usage on actual files, run
|
||||
``ocrmypdf -k ...`` which will preserve and print the path to temporary
|
||||
storage when the job is done.
|
||||
|
||||
To change where temporary files are stored, change the ``TMPDIR``
|
||||
environment variable for ocrmypdf's environment. (Python's
|
||||
``tempfile.gettempdir()`` returns the root directory in which temporary
|
||||
files will be stored.) For example, one could redirect ``TMPDIR`` to a
|
||||
large RAM disk to avoid wear on HDD/SSD and potentially improve
|
||||
performance. On Amazon Web Services, ``TMPDIR`` can be set to `empheral
|
||||
storage <https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/InstanceStorage.html>`__.
|
||||
|
||||
Timeouts
|
||||
--------
|
||||
|
||||
To prevent excessively long OCR jobs consider setting
|
||||
``--tesseract-timeout`` and/or ``--skip-big`` arguments. ``--skip-big``
|
||||
is particularly helpful if your PDFs include documents such as reports
|
||||
on standard page sizes with large images attached - often large images
|
||||
are not worth OCR'ing anyway.
|
||||
|
||||
Commercial alternatives
|
||||
-----------------------
|
||||
|
||||
The author also provides professional services that include OCR and
|
||||
building databases around PDFs, and is happy to provide consultation.
|
||||
|
||||
Abbyy Cloud OCR is viable commercial alternative with a web services
|
||||
API. Amazon Textract, Google Cloud Vision, and Microsoft Azure
|
||||
Computer Vision provide advanced OCR but have less PDF rendering capability.
|
||||
|
||||
Password protection, digital signatures and certification
|
||||
=========================================================
|
||||
|
||||
|
||||
+6
-10
@@ -76,20 +76,16 @@ Setuptools plugins
|
||||
You can also create a plugin that OCRmyPDF will always automatically load if both are
|
||||
installed in the same virtual environment, using a setuptools entrypoint.
|
||||
|
||||
Your package's ``setup.py`` would need to contain the following, for a plugin
|
||||
Your package's ``pyproject.toml`` would need to contain the following, for a plugin
|
||||
named ``ocrmypdf-exampleplugin``:
|
||||
|
||||
.. code-block:: python
|
||||
.. code-block:: toml
|
||||
|
||||
# sample ./setup.py file
|
||||
from setuptools import setup
|
||||
[project]
|
||||
name = "ocrmypdf-exampleplugin"
|
||||
|
||||
setup(
|
||||
name="ocrmypdf-exampleplugin",
|
||||
packages=["exampleplugin"],
|
||||
# the following makes a plugin available to pytest
|
||||
entry_points={"ocrmypdf": ["exampleplugin = exampleplugin.pluginmodule"]},
|
||||
)
|
||||
[project.entry-points."ocrmypdf"]
|
||||
exampleplugin = "exampleplugin.pluginmodule"
|
||||
|
||||
.. code-block:: ini
|
||||
|
||||
|
||||
+90
-1
@@ -28,6 +28,95 @@ tagged yet.
|
||||
|
||||
.. |OCRmyPDF PyPI| image:: https://img.shields.io/pypi/v/ocrmypdf.svg
|
||||
|
||||
v14.3.0
|
||||
=======
|
||||
|
||||
- Renamed master branch to main.
|
||||
- Improve PDF rasterization accuracy by using the ``-dPDFSTOPONERROR`` option
|
||||
to Ghostscript. Use ``--continue-on-soft-render-error`` if you want to render
|
||||
the PDF anyway. The plugin specification was adjusted to support this feature;
|
||||
plugin authors may want to adapt PDF rasterizing and rendering
|
||||
plugins. :issue:`1083`
|
||||
- The calculated deskew angle is now recorded in the logged output. :issue:`1101`
|
||||
- Metadata can now be unset by setting a metadata type such as ``--title`` to an
|
||||
empty string. :issue:`1117,1059`
|
||||
- Fixed random order of languages due to use of a set. This may have caused output
|
||||
to vary when multiple languages were set for OCR. :issue:`1113`
|
||||
- Clarified the optimization ratio reported in the log output.
|
||||
- Documentation improvements.
|
||||
|
||||
v14.2.1
|
||||
=======
|
||||
|
||||
- Fixed :issue:`977`, where images inside Form XObjects were always excluded
|
||||
from image optimization.
|
||||
|
||||
v14.2.0
|
||||
=======
|
||||
|
||||
- Added `--tesseract-downsample-above` to downsample larger images even when
|
||||
they do not exceed Tesseract's internal limits. This can be used to speed
|
||||
up OCR, possibly sacrificing accuracy.
|
||||
- Fixed resampling AttributeError on older Pillow. :issue:`1096`
|
||||
- Removed an error about using Ghostscript on PDFs with that have the /UserUnit
|
||||
feature in use. Previously, Ghostscript would fail to process these PDFs,
|
||||
but in all supported versions it is now supported, so the error is no longer
|
||||
needed.
|
||||
- Improved documentation around installing other language packs for Tesseract.
|
||||
|
||||
v14.1.0
|
||||
=======
|
||||
|
||||
- Added ``--tesseract-non-ocr-timeout``. This allows using Tesseract's deskew
|
||||
and other non-OCR features while disabling OCR using ``--tesseract-timeout 0``.
|
||||
- Added ``--tesseract-downsample-large-images``. This downsamples larges images
|
||||
that exceed the maximum image size Tesseract can handle. Large images may still
|
||||
take a long time to process, but this allows them to be processed if that
|
||||
is desired.
|
||||
- Fixed :issue:`1082`, an issue with snap packaged building.
|
||||
- Change linter to ruff, fix lint errors, update documentation.
|
||||
|
||||
v14.0.4
|
||||
=======
|
||||
|
||||
- Fixed :issue:`1066, 1075`, an exception when processing certain malformed PDFs.
|
||||
|
||||
v14.0.3
|
||||
=======
|
||||
|
||||
- Fixed :issue:`1068`, avoid deleting /dev/null when running as root.
|
||||
- Other documentation fixes.
|
||||
|
||||
v14.0.2
|
||||
=======
|
||||
|
||||
- Fixed :issue:`1052`, an exception on attempting to process certain nonconforming PDFs.
|
||||
- Explicitly documented that Windows 32-bit is no longer supported.
|
||||
- Fixed source installation instructions.
|
||||
- Other documentation fixes.
|
||||
|
||||
v14.0.1
|
||||
=======
|
||||
|
||||
- Fixed some version checks done with smart version comparison.
|
||||
- Added missing jbig2dec to Docker image.
|
||||
|
||||
v14.0.0
|
||||
=======
|
||||
|
||||
- Dropped support for Python 3.7.
|
||||
- Dropped support generally speaking, all dependencies older than what Ubuntu 20.04
|
||||
provides.
|
||||
- Ghostscript 9.50 or newer is now required. Shims to support old versions were
|
||||
removed.
|
||||
- Tesseract 4.1.1 or newer is now required. Shims to support old versions were
|
||||
removed.
|
||||
- Docker image now uses Tesseract 5.
|
||||
- Dropped setup.cfg configuration for pyproject.toml.
|
||||
- Removed deprecation exception PdfMergeFailedError.
|
||||
- A few more public domain test files were removed or replaced. We are aiming for
|
||||
100% compliance with SPDX and generally towards simplifying copyright.
|
||||
|
||||
v13.7.0
|
||||
=======
|
||||
|
||||
@@ -681,7 +770,7 @@ v10.3.2
|
||||
v10.3.1
|
||||
=======
|
||||
|
||||
- Fixed a number of test suite failures with pdfminer.six older than veresion 20200402.
|
||||
- Fixed a number of test suite failures with pdfminer.six older than version 20200402.
|
||||
- Enabled support for pdfminer.six 20200720.
|
||||
|
||||
v10.3.0
|
||||
|
||||
+9
-2
@@ -2,11 +2,18 @@
|
||||
# SPDX-FileCopyrightText: 2016 findingorder <https://github.com/findingorder>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Example of using ocrmypdf as a library in a script.
|
||||
|
||||
This script will recursively search a directory for PDF files and run OCR on
|
||||
them. It will log the results. It runs OCR on every file, even if it already
|
||||
has text. OCRmyPDF will detect files that already have text.
|
||||
|
||||
You should edit this script to meet your needs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# This script must be edited to meet your needs.
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MIT
|
||||
---
|
||||
version: "3.3"
|
||||
services:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R Barlow: https://github.com/jbarlow83
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""
|
||||
An example of an OCRmyPDF plugin.
|
||||
"""An example of an OCRmyPDF plugin.
|
||||
|
||||
This plugin adds two new command line arguments
|
||||
--grayscale-ocr: converts the image to grayscale before performing OCR on it
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
|
||||
|
||||
To regenerate
|
||||
=============
|
||||
|
||||
Using asciinema and svg-term (`npm install -g svg-term-cli`).
|
||||
|
||||
Create `~/.config/asciinema/config` to disable prompt.
|
||||
|
||||
```
|
||||
[record]
|
||||
|
||||
command = fish --init-command 'alias fish_prompt="echo \>\ "'
|
||||
```
|
||||
|
||||
Run asciinema
|
||||
|
||||
```
|
||||
asciinema rec new_input.cast
|
||||
```
|
||||
|
||||
Re-record faster version with fewer pauses
|
||||
|
||||
```
|
||||
asciinema rec demo.cast -c "asciinema play new_input.cast --speed 2 --idle-time-limit 0.5"
|
||||
```
|
||||
|
||||
Convert to SVG
|
||||
```
|
||||
svg-term --in=misc/screencast/demo.cast --out=misc/screencast/demo.svg --window
|
||||
```
|
||||
@@ -0,0 +1,65 @@
|
||||
{"version": 2, "width": 131, "height": 24, "timestamp": 1687247006, "env": {"SHELL": "/usr/bin/fish", "TERM": "xterm-256color"}}
|
||||
[0.103649, "o", "\u001b[?2004h\u001b]7; \u0007"]
|
||||
[0.104223, "o", "\u001b]0;fish \u0007\u001b[30m\u001b(B\u001b[m\r> \u001b[K\r\u001b[C\u001b[C"]
|
||||
[0.604542, "o", "o\r\u001b[3C\b\u001b[38;2;255;0;0mo\r\u001b[3C\u001b[30m\u001b(B\u001b[m\u001b[38;2;85;85;85mcrmypdf multipage.pdf multipage_with_ocr.pdf\r\u001b[3C\u001b[30m\u001b(B\u001b[m"]
|
||||
[0.679571, "o", "\u001b[38;2;255;0;0mc\u001b[38;2;85;85;85mrmypdf multipage.pdf multipage_with_ocr.pdf\r\u001b[4C\u001b[30m\u001b(B\u001b[m"]
|
||||
[0.767271, "o", "\u001b[38;2;255;0;0mr\u001b[38;2;85;85;85mmypdf multipage.pdf multipage_with_ocr.pdf\r\u001b[5C\u001b[30m\u001b(B\u001b[m"]
|
||||
[0.814505, "o", "\u001b[38;2;255;0;0mm\u001b[38;2;85;85;85mypdf multipage.pdf multipage_with_ocr.pdf\r\u001b[6C\u001b[30m\u001b(B\u001b[m"]
|
||||
[0.938919, "o", "\u001b[38;2;255;0;0my\u001b[38;2;85;85;85mpdf multipage.pdf multipage_with_ocr.pdf\r\u001b[7C\u001b[30m\u001b(B\u001b[m"]
|
||||
[0.967347, "o", "\u001b[38;2;255;0;0mp\u001b[38;2;85;85;85mdf multipage.pdf multipage_with_ocr.pdf\r\u001b[8C\u001b[30m\u001b(B\u001b[m"]
|
||||
[1.009954, "o", "\u001b[38;2;255;0;0md\u001b[38;2;85;85;85mf multipage.pdf multipage_with_ocr.pdf\r\u001b[9C\u001b[30m\u001b(B\u001b[m"]
|
||||
[1.034488, "o", "\u001b[38;2;255;0;0mf\u001b[38;2;85;85;85m multipage.pdf multipage_with_ocr.pdf\r\u001b[10C\u001b[30m\u001b(B\u001b[m\b\b\b\b\b\b\b\b\u001b[38;2;0;95;215mocrmypdf\u001b[38;2;85;85;85m multipage.pdf multipage_with_ocr.pdf\r\u001b[10C\u001b[30m\u001b(B\u001b[m"]
|
||||
[1.069226, "o", "\u001b[38;2;0;95;215m \u001b[38;2;85;85;85mmultipage.pdf multipage_with_ocr.pdf\r\u001b[11C\u001b[30m\u001b(B\u001b[m\b \u001b[38;2;85;85;85mmultipage.pdf multipage_with_ocr.pdf\r\u001b[11C\u001b[30m\u001b(B\u001b[m"]
|
||||
[1.569682, "o", "-\u001b[K\r\u001b[12C\u001b[38;2;85;85;85m-version\r\u001b[12C\u001b[30m\u001b(B\u001b[m\b\u001b[38;2;0;175;255m-\u001b[38;2;85;85;85m-version\r\u001b[12C\u001b[30m\u001b(B\u001b[m"]
|
||||
[1.642096, "o", "\u001b[38;2;0;175;255m-\u001b[38;2;85;85;85mversion\r\u001b[13C\u001b[30m\u001b(B\u001b[m"]
|
||||
[1.71793, "o", "\u001b[38;2;0;175;255ms\u001b[30m\u001b(B\u001b[m\u001b[K\r\u001b[14C"]
|
||||
[1.771483, "o", "\u001b[38;2;0;175;255mk\r\u001b[15C\u001b[30m\u001b(B\u001b[m"]
|
||||
[1.864664, "o", "\u001b[38;2;0;175;255mi\r\u001b[16C\u001b[30m\u001b(B\u001b[m"]
|
||||
[1.876085, "o", "\u001b[38;2;0;175;255mp\r\u001b[17C\u001b[30m\u001b(B\u001b[m"]
|
||||
[2.092979, "o", "\u001b[38;2;0;175;255m-\r\u001b[18C\u001b[30m\u001b(B\u001b[m"]
|
||||
[2.138821, "o", "\u001b[38;2;0;175;255mt\r\u001b[19C\u001b[30m\u001b(B\u001b[m"]
|
||||
[2.18017, "o", "\u001b[38;2;0;175;255me\r\u001b[20C\u001b[30m\u001b(B\u001b[m"]
|
||||
[2.268222, "o", "\u001b[38;2;0;175;255mx\r\u001b[21C\u001b[30m\u001b(B\u001b[m"]
|
||||
[2.277031, "o", "\u001b[38;2;0;175;255mt\r\u001b[22C\u001b[30m\u001b(B\u001b[m"]
|
||||
[2.322469, "o", "\u001b[38;2;0;175;255m \r\u001b[23C\u001b[30m\u001b(B\u001b[m\b \r\u001b[23C"]
|
||||
[2.824696, "o", "m\r\u001b[24C\b\u001b[38;2;0;175;255m\u001b[4mm\r\u001b[24C\u001b[30m\u001b(B\u001b[m\u001b[38;2;85;85;85masks.pdf \r\u001b[24C\u001b[30m\u001b(B\u001b[m"]
|
||||
[2.923234, "o", "\u001b[38;2;0;175;255m\u001b[4mu\u001b[30m\u001b(B\u001b[m\u001b[K\r\u001b[25C\u001b[38;2;85;85;85mltipage.pdf \r\u001b[25C\u001b[30m\u001b(B\u001b[m"]
|
||||
[2.960685, "o", "\u001b[38;2;0;175;255m\u001b[4ml\u001b[38;2;85;85;85m\u001b[24mtipage.pdf \r\u001b[26C\u001b[30m\u001b(B\u001b[m"]
|
||||
[3.03365, "o", "\u001b[38;2;0;175;255m\u001b[4mt\u001b[38;2;85;85;85m\u001b[24mipage.pdf \r\u001b[27C\u001b[30m\u001b(B\u001b[m"]
|
||||
[3.479338, "o", "\u001b[38;2;0;175;255m\u001b[4mipage.pdf \r\u001b[37C\u001b[30m\u001b(B\u001b[m\b \r\u001b[37C"]
|
||||
[3.754818, "o", "m\r\u001b[38C\b\u001b[38;2;0;175;255m\u001b[4mm\r\u001b[38C\u001b[30m\u001b(B\u001b[m\u001b[38;2;85;85;85masks.pdf \r\u001b[38C\u001b[30m\u001b(B\u001b[m"]
|
||||
[3.873318, "o", "\u001b[38;2;0;175;255m\u001b[4mu\u001b[30m\u001b(B\u001b[m\u001b[K\r\u001b[39C\u001b[38;2;85;85;85mltipage.pdf \r\u001b[39C\u001b[30m\u001b(B\u001b[m"]
|
||||
[3.926829, "o", "\u001b[38;2;0;175;255m\u001b[4ml\u001b[38;2;85;85;85m\u001b[24mtipage.pdf \r\u001b[40C\u001b[30m\u001b(B\u001b[m"]
|
||||
[4.272251, "o", "\u001b[38;2;0;175;255m\u001b[4mtipage.pdf \r\u001b[51C\u001b[30m\u001b(B\u001b[m\b \r\u001b[51C"]
|
||||
[4.343464, "o", "\r\u001b[50C"]
|
||||
[4.416286, "o", "\r\u001b[49C"]
|
||||
[4.490574, "o", "\r\u001b[48C"]
|
||||
[4.564115, "o", "\r\u001b[47C"]
|
||||
[4.630398, "o", "\r\u001b[46C"]
|
||||
[4.76825, "o", "\u001b[38;2;0;175;255m\u001b[4m_.pd\u001b[30m\u001b(B\u001b[mf \r\u001b[47C\u001b[10D\u001b[38;2;0;175;255mmultipage_.pdf\u001b[30m\u001b(B\u001b[m \r\u001b[47C"]
|
||||
[5.012506, "o", "\u001b[38;2;0;175;255mo.pd\u001b[30m\u001b(B\u001b[mf \r\u001b[48C\u001b[3C\u001b[38;2;0;175;255mf\u001b[30m\u001b(B\u001b[m \r\u001b[48C"]
|
||||
[5.053615, "o", "\u001b[38;2;0;175;255mc.pd\u001b[30m\u001b(B\u001b[mf \r\u001b[49C\u001b[3C\u001b[38;2;0;175;255mf\u001b[30m\u001b(B\u001b[m \r\u001b[49C"]
|
||||
[5.103957, "o", "\u001b[38;2;0;175;255mr.pd\u001b[30m\u001b(B\u001b[mf \r\u001b[50C\u001b[3C\u001b[38;2;0;175;255mf\u001b[30m\u001b(B\u001b[m \r\u001b[50C"]
|
||||
[5.226183, "o", "\r\u001b[55C"]
|
||||
[5.728321, "o", "\r\n\u001b[30m\u001b(B\u001b[m\u001b[?2004l\u001b]0;ocrmypdf --skip-text multipage.pdf multipage_ocr.pdf /home/jb/src/ocrmypdf/tests/resources\u0007\u001b[30m\u001b(B\u001b[m\r"]
|
||||
[5.801032, "o", "\rScanning contents: 0%| | 0/6 [00:00<?, ?page/s]"]
|
||||
[5.802664, "o", "\rScanning contents: 100%|█████████████████████████████████████████████████████████████████████████| 6/6 [00:00<00:00, 1270.68page/s]\r\n"]
|
||||
[5.802747, "o", "Start processing 6 pages concurrently\r\n"]
|
||||
[5.803488, "o", "\rOCR: 0%| | 0.0/6.0 [00:00<?, ?page/s]"]
|
||||
[5.804896, "o", "\r \r 4 skipping all processing on this page\r\n\rOCR: 0%| | 0.0/6.0 [00:00<?, ?page/s]"]
|
||||
[5.896969, "o", "\rOCR: 25%|█████████████████████▎ | 1.5/6.0 [00:00<00:00, 8.12page/s]"]
|
||||
[6.170021, "o", "\rOCR: 42%|███████████████████████████████████▍ | 2.5/6.0 [00:00<00:01, 3.05page/s]"]
|
||||
[6.292338, "o", "\rOCR: 58%|█████████████████████████████████████████████████▌ | 3.5/6.0 [00:00<00:00, 3.39page/s]"]
|
||||
[6.586017, "o", "\rOCR: 75%|███████████████████████████████████████████████████████████████▊ | 4.5/6.0 [00:01<00:00, 2.49page/s]"]
|
||||
[7.087058, "o", "\rOCR: 92%|█████████████████████████████████████████████████████████████████████████████▉ | 5.5/6.0 [00:06<00:00, 1.98s/page]\rOCR: 100%|█████████████████████████████████████████████████████████████████████████████████████| 6.0/6.0 [00:06<00:00, 1.09s/page]\r\nPostprocessing...\r\n"]
|
||||
[7.104927, "o", "\rPDF/A conversion: 0%| | 0/6 [00:00<?, ?page/s]"]
|
||||
[7.607392, "o", "\rPDF/A conversion: 50%|██████████████████████████████████████ | 3/6 [00:01<00:01, 1.61page/s]"]
|
||||
[7.653781, "o", "\rPDF/A conversion: 83%|███████████████████████████████████████████████████████████████▎ | 5/6 [00:01<00:00, 2.90page/s]"]
|
||||
[7.774532, "o", "\rPDF/A conversion: 100%|████████████████████████████████████████████████████████████████████████████| 6/6 [00:02<00:00, 2.71page/s]\r\n"]
|
||||
[7.778252, "o", "\u001b[33mSome input metadata could not be copied because it is not permitted in PDF/A. You may wish to examine the output PDF's XMP metadata.\u001b[0m\r\n"]
|
||||
[8.280789, "o", "\rRecompressing JPEGs: 0image [00:00, ?image/s]\rRecompressing JPEGs: 0image [00:00, ?image/s]\r\n\rDeflating JPEGs: 0%| | 0/4 [00:00<?, ?image/s]\rDeflating JPEGs: 100%|███████████████████████████████████████████████████████████████████████████| 4/4 [00:00<00:00, 238.28image/s]\r\n"]
|
||||
[8.28149, "o", "\rJBIG2: 0item [00:00, ?item/s]\rJBIG2: 0item [00:00, ?item/s]\r\n"]
|
||||
[8.289998, "o", "Image optimization ratio: 1.01 savings: 1.3%\r\nTotal file size ratio: 1.02 savings: 1.6%\r\n"]
|
||||
[8.291209, "o", "Output file is a PDF/A-2B (as expected)\r\n"]
|
||||
[8.361316, "o", "\u001b[2m⏎\u001b(B\u001b[m \r⏎ \r\u001b[K\u001b[?2004h\u001b]0;fish /home/jb/src/ocrmypdf/tests/resources\u0007\u001b[30m\u001b(B\u001b[m> \u001b[K\r\u001b[C\u001b[C"]
|
||||
[8.862206, "o", "\r\n\u001b[30m\u001b(B\u001b[m\u001b[30m\u001b(B\u001b[m\u001b[?2004l"]
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 29 KiB |
+3
-4
@@ -2,6 +2,8 @@
|
||||
# SPDX-FileCopyrightText: 2017 Enantiomerie
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Example OCRmyPDF for Synology NAS."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# This script must be edited to meet your needs.
|
||||
@@ -25,10 +27,7 @@ logging.basicConfig(
|
||||
filemode='w',
|
||||
)
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
start_dir = sys.argv[1]
|
||||
else:
|
||||
start_dir = '.'
|
||||
start_dir = sys.argv[1] if len(sys.argv) > 1 else '.'
|
||||
|
||||
for dir_name, _subdirs, file_list in os.walk(start_dir):
|
||||
logging.info(dir_name)
|
||||
|
||||
+7
-1
@@ -3,6 +3,8 @@
|
||||
# SPDX-FileCopyrightText: 2020 James R Barlow <https://github.com/jbarlow83>
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Watch a directory for new PDFs and OCR them."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
@@ -38,6 +40,7 @@ DESKEW = getenv_bool('OCR_DESKEW')
|
||||
OCR_JSON_SETTINGS = json.loads(os.getenv('OCR_JSON_SETTINGS', '{}'))
|
||||
POLL_NEW_FILE_SECONDS = int(os.getenv('OCR_POLL_NEW_FILE_SECONDS', '1'))
|
||||
USE_POLLING = getenv_bool('OCR_USE_POLLING')
|
||||
RETRIES_LOADING_FILE = int(os.getenv('OCR_RETRIES_LOADING_FILE', '5'))
|
||||
LOGLEVEL = os.getenv('OCR_LOGLEVEL', 'INFO')
|
||||
PATTERNS = ['*.pdf', '*.PDF']
|
||||
|
||||
@@ -64,7 +67,7 @@ def wait_for_file_ready(file_path):
|
||||
# watchdog event before the file is actually fully on disk, causing
|
||||
# pikepdf to fail.
|
||||
|
||||
retries = 5
|
||||
retries = RETRIES_LOADING_FILE
|
||||
while retries:
|
||||
try:
|
||||
pdf = pikepdf.open(file_path)
|
||||
@@ -103,6 +106,8 @@ def execute_ocrmypdf(file_path):
|
||||
elif ON_SUCCESS_ARCHIVE:
|
||||
log.info(f'OCR is done. Archiving {file_path.name} to {ARCHIVE_DIRECTORY}')
|
||||
shutil.move(file_path, f'{ARCHIVE_DIRECTORY}/{file_path.name}')
|
||||
else:
|
||||
log.info('OCR is done')
|
||||
else:
|
||||
log.info('OCR is done')
|
||||
|
||||
@@ -140,6 +145,7 @@ def main():
|
||||
f"DESKEW: {DESKEW}\n"
|
||||
f"ARGS: {OCR_JSON_SETTINGS}\n"
|
||||
f"POLL_NEW_FILE_SECONDS: {POLL_NEW_FILE_SECONDS}\n"
|
||||
f"RETRIES_LOADING_FILE: {RETRIES_LOADING_FILE}\n"
|
||||
f"USE_POLLING: {USE_POLLING}\n"
|
||||
f"LOGLEVEL: {LOGLEVEL}"
|
||||
)
|
||||
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
# SPDX-FileCopyrightText: 2019 James R. Barlow
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
"""This is a simple web service/HTTP wrapper for OCRmyPDF
|
||||
"""This is a simple web service/HTTP wrapper for OCRmyPDF.
|
||||
|
||||
This may be more convenient than the command line tool for some Docker users.
|
||||
Note that OCRmyPDF uses Ghostscript, which is licensed under AGPLv3+. While
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from subprocess import PIPE, run
|
||||
from subprocess import run
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from flask import Flask, Response, request, send_from_directory
|
||||
@@ -48,7 +48,7 @@ def do_ocrmypdf(file):
|
||||
return Response("--sidecar not supported", 501, mimetype='text/plain')
|
||||
|
||||
ocrmypdf_args = ["ocrmypdf", *cmd_args, up_file, down_file]
|
||||
proc = run(ocrmypdf_args, capture_output=True, encoding="utf-8")
|
||||
proc = run(ocrmypdf_args, capture_output=True, encoding="utf-8", check=False)
|
||||
if proc.returncode != 0:
|
||||
stderr = proc.stderr
|
||||
return Response(stderr, 400, mimetype='text/plain')
|
||||
|
||||
+109
-29
@@ -1,16 +1,99 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
[build-system]
|
||||
requires = [
|
||||
"setuptools >= 52",
|
||||
"setuptools >= 61",
|
||||
"setuptools_scm[toml] >= 7.0.5",
|
||||
"wheel"
|
||||
]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ocrmypdf"
|
||||
dynamic = ["version"]
|
||||
description = "OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched"
|
||||
readme = "README.md"
|
||||
license = {text = "MPL-2.0"}
|
||||
requires-python = ">=3.8"
|
||||
dependencies = [
|
||||
"Pillow>=8.2.0",
|
||||
"coloredlogs>=14.0",
|
||||
"deprecation>=2.1.0",
|
||||
"img2pdf>=0.3.0", # pure Python
|
||||
"packaging>=20",
|
||||
"pdfminer.six>=20201018",
|
||||
"pikepdf>=5.0.1",
|
||||
"pluggy>=0.13.0",
|
||||
"reportlab>=3.5.66",
|
||||
"tqdm>=4",
|
||||
"importlib-resources>=5;python_version<'3.9'", # until Python 3.9
|
||||
"typing-extensions>=4;python_version<'3.10'",
|
||||
]
|
||||
authors = [{name = "James R. Barlow", email="james@purplerock.ca"}]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: End Users/Desktop",
|
||||
"Intended Audience :: Science/Research",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
|
||||
"Operating System :: MacOS",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Operating System :: POSIX",
|
||||
"Operating System :: POSIX :: BSD",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Topic :: Scientific/Engineering :: Image Recognition",
|
||||
"Topic :: Text Processing :: Indexing",
|
||||
"Topic :: Text Processing :: Linguistic",
|
||||
]
|
||||
keywords = [
|
||||
"PDF",
|
||||
"OCR",
|
||||
"optical character recognition",
|
||||
"PDF/A",
|
||||
"scanning",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Documentation = "https://ocrmypdf.readthedocs.io/"
|
||||
Source = "https://github.com/ocrmypdf/OCRmyPDF"
|
||||
Tracker = "https://github.com/ocrmypdf/OCRmyPDF/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
docs = ["sphinx", "sphinx-issues", "sphinx-rtd-theme"]
|
||||
extended_test = ["PyMuPDF==1.19.1"]
|
||||
test = [
|
||||
"coverage[toml]>=5",
|
||||
"hypothesis>=6.0.0",
|
||||
"pytest>=6.0.0",
|
||||
"pytest-cov>=2.11.1",
|
||||
"pytest-xdist>=2.2.0",
|
||||
"python-xmp-toolkit==2.0.1", # also requires apt-get install libexempi3
|
||||
"types-Pillow",
|
||||
"types-humanfriendly",
|
||||
]
|
||||
watcher = ["watchdog>=1.0.2"]
|
||||
webservice = ["Flask>=1"]
|
||||
|
||||
[project.scripts]
|
||||
ocrmypdf = "ocrmypdf.__main__:run"
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
ocrmypdf = ["data/sRGB.icc", "py.typed"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
namespaces = false
|
||||
|
||||
[tool.setuptools_scm]
|
||||
|
||||
[tool.distutils.bdist_wheel]
|
||||
python-tag = "py38"
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ["py37", "py38"]
|
||||
target-version = ["py38", "py39", "py310", "py311"]
|
||||
skip-string-normalization = true
|
||||
include = '\.pyi?$'
|
||||
exclude = '''
|
||||
@@ -54,27 +137,6 @@ exclude_lines = [
|
||||
"if TYPE_CHECKING:"
|
||||
]
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
known_first_party = "ocrmypdf"
|
||||
known_third_party = [
|
||||
"PIL",
|
||||
"flask",
|
||||
"img2pdf",
|
||||
"ocrmypdf",
|
||||
"pdfminer",
|
||||
"pikepdf",
|
||||
"pkg_resources",
|
||||
"pluggy",
|
||||
"pytest",
|
||||
"reportlab",
|
||||
"setuptools",
|
||||
"sphinx_rtd_theme",
|
||||
"tqdm",
|
||||
"watchdog",
|
||||
"werkzeug"
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "6.0"
|
||||
norecursedirs = ["lib", ".pc", ".git", "venv", "output", "cache", "resources"]
|
||||
@@ -94,12 +156,30 @@ module = [
|
||||
'pdfminer.*',
|
||||
'reportlab.*',
|
||||
'fitz',
|
||||
'libxmp.utils',
|
||||
'importlib_metadata'
|
||||
'libxmp.utils'
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
[tool.pylint.basic]
|
||||
good-names = ["i", "j", "k", "ex", "Run", "_", "e", "p", "im", "w", "h", "m", "x", "y", "a", "b", "fp", "n", "f", "s", "v", "q", "dx", "dy"]
|
||||
logging-format-style = "old"
|
||||
disable = ["raw-checker-failed", "bad-inline-option", "locally-disabled", "file-ignored", "suppressed-message", "useless-suppression", "deprecated-pragma", "use-symbolic-message-instead", "logging-fstring-interpolation", "missing-function-docstring", "too-few-public-methods"]
|
||||
[tool.ruff]
|
||||
select = [
|
||||
"D", # pydocstyle
|
||||
"E", # pycodestyle
|
||||
"W", # pycodestyle
|
||||
"F", # pyflakes
|
||||
"I001", # isort
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
target-version = "py38"
|
||||
|
||||
[tool.ruff.isort]
|
||||
known-first-party = ["ocrmypdf"]
|
||||
required-imports = ["from __future__ import annotations"]
|
||||
|
||||
[tool.ruff.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
"docs/conf.py" = ["D100", "D101", "D105"]
|
||||
"tests/*.py" = ["D100", "D101", "D102", "D103", "D105"]
|
||||
"misc/*.py" = ["D103", "D101", "D102"]
|
||||
"src/ocrmypdf/builtin_plugins/*.py" = ["D103", "D102", "D105"]
|
||||
@@ -1,116 +0,0 @@
|
||||
[metadata]
|
||||
name = ocrmypdf
|
||||
description = OCRmyPDF adds an OCR text layer to scanned PDF files, allowing them to be searched
|
||||
long_description = file: README.md
|
||||
long_description_content_type = text/markdown
|
||||
url = https://github.com/ocrmypdf/OCRmyPDF
|
||||
author = James R. Barlow
|
||||
author_email = james@purplerock.ca
|
||||
license = MPL-2.0
|
||||
license_file = LICENSE
|
||||
license_files =
|
||||
LICENSE
|
||||
classifiers =
|
||||
Development Status :: 5 - Production/Stable
|
||||
Environment :: Console
|
||||
Intended Audience :: End Users/Desktop
|
||||
Intended Audience :: Science/Research
|
||||
Intended Audience :: System Administrators
|
||||
License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
|
||||
Operating System :: MacOS :: MacOS X
|
||||
Operating System :: Microsoft :: Windows :: Windows 10
|
||||
Operating System :: POSIX
|
||||
Operating System :: POSIX :: BSD
|
||||
Operating System :: POSIX :: Linux
|
||||
Programming Language :: Python :: 3
|
||||
Programming Language :: Python :: 3 :: Only
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: 3.10
|
||||
Topic :: Scientific/Engineering :: Image Recognition
|
||||
Topic :: Text Processing :: Indexing
|
||||
Topic :: Text Processing :: Linguistic
|
||||
keywords =
|
||||
PDF
|
||||
OCR
|
||||
optical character recognition
|
||||
PDF/A
|
||||
scanning
|
||||
project_urls =
|
||||
Documentation = https://ocrmypdf.readthedocs.io/
|
||||
Source = https://github.com/ocrmypdf/OCRmyPDF
|
||||
Tracker = https://github.com/ocrmypdf/OCRmyPDF/issues
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
install_requires =
|
||||
Pillow>=8.2.0
|
||||
coloredlogs>=14.0 # strictly optional
|
||||
img2pdf>=0.3.0 # pure Python
|
||||
packaging>=20
|
||||
pdfminer.six!=20200720,>=20191110
|
||||
pikepdf!=5.0.0,>=4.0.0
|
||||
pluggy>=0.13.0
|
||||
reportlab>=3.5.66
|
||||
tqdm>=4
|
||||
importlib-metadata>=4;python_version<'3.8' # until Python 3.8
|
||||
importlib-resources>=5;python_version<'3.9' # until Python 3.9
|
||||
typing-extensions>=4;python_version<'3.10'
|
||||
python_requires = >=3.7
|
||||
include_package_data = True
|
||||
package_dir =
|
||||
=src
|
||||
platforms = any
|
||||
setup_requires =
|
||||
setuptools-scm
|
||||
setuptools-scm-git-archive
|
||||
zip_safe = False
|
||||
|
||||
[options.packages.find]
|
||||
where = src
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
ocrmypdf = ocrmypdf.__main__:run
|
||||
|
||||
[options.extras_require]
|
||||
docs =
|
||||
sphinx
|
||||
sphinx-issues
|
||||
sphinx-rtd-theme
|
||||
extended_test =
|
||||
PyMuPDF==1.19.1
|
||||
test =
|
||||
coverage[toml]>=5
|
||||
pytest>=6.0.0
|
||||
pytest-cov>=2.11.1
|
||||
pytest-xdist>=2.2.0
|
||||
python-xmp-toolkit==2.0.1 # also requires apt-get install libexempi3
|
||||
types-Pillow
|
||||
types-humanfriendly
|
||||
watcher =
|
||||
watchdog>=1.0.2
|
||||
webservice =
|
||||
Flask>=1
|
||||
|
||||
[options.package_data]
|
||||
ocrmypdf =
|
||||
data/sRGB.icc
|
||||
py.typed
|
||||
|
||||
[bdist_wheel]
|
||||
python-tag = py37
|
||||
|
||||
[aliases]
|
||||
test = pytest
|
||||
|
||||
[check-manifest]
|
||||
ignore =
|
||||
.github
|
||||
|
||||
[flake8]
|
||||
ignore = D203,F401,W503,E501,E203,F841
|
||||
exclude = .git,__pycache__,docs/conf.py,build,dist,.venv,.venvpp,.eggs,tmp,src/ocrmypdf/lib/
|
||||
max-complexity = 10
|
||||
max-line-length = 100
|
||||
@@ -1,10 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""setup.py to support older setuptools and pip."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
setup()
|
||||
+23
-1
@@ -1,3 +1,8 @@
|
||||
# SPDX-FileCopyrightText: 2022 Alexander Langanke
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-FileCopyrightText: 2023 林博仁(Buo-ren, Lin) <Buo.Ren.Lin@gmail.com>
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
name: ocrmypdf
|
||||
title: OCRmyPDF
|
||||
base: core20
|
||||
@@ -41,9 +46,19 @@ parts:
|
||||
stage-packages:
|
||||
- lib32stdc++6
|
||||
|
||||
jbig2enc:
|
||||
plugin: autotools
|
||||
source: https://github.com/agl/jbig2enc.git
|
||||
source-tag: '0.29'
|
||||
build-packages:
|
||||
- libleptonica-dev
|
||||
|
||||
ocrmypdf:
|
||||
plugin: python
|
||||
source: https://github.com/ocrmypdf/OCRmyPDF.git
|
||||
source: .
|
||||
|
||||
build-packages:
|
||||
- python3-pip
|
||||
|
||||
stage-packages:
|
||||
- ghostscript
|
||||
@@ -66,7 +81,14 @@ parts:
|
||||
- setuptools
|
||||
- tqdm
|
||||
- pipe
|
||||
- wheel
|
||||
|
||||
override-build: |
|
||||
pip3 install --user dephell[full]
|
||||
$HOME/.local/bin/dephell deps convert \
|
||||
--from-path pyproject.toml \
|
||||
--from-format pyproject \
|
||||
--to-path setup.py \
|
||||
--to-format setuppy
|
||||
snapcraftctl build
|
||||
ln -sf ../usr/lib/libsnapcraft-preload.so $SNAPCRAFT_PART_INSTALL/lib/libsnapcraft-preload.so
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<!-- SPDX-FileCopyrightText: 2022 James R. Barlow -->
|
||||
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
|
||||
|
||||
# Release checklist
|
||||
|
||||
## Patch release
|
||||
@@ -14,11 +17,11 @@
|
||||
|
||||
- Check README.md
|
||||
|
||||
- Check setup.py
|
||||
- Check pyproject.toml
|
||||
|
||||
- Are classifiers up to date?
|
||||
- Is `python_requires` correct?
|
||||
- Python 3.6 is EOL on December 2021-12. Could drop support then.
|
||||
- Is it to drop support for older Pythons?
|
||||
- Can we tighten any `install_requires` dependencies?
|
||||
|
||||
- Search for old version shims we can remove
|
||||
|
||||
@@ -21,7 +21,6 @@ from ocrmypdf.exceptions import (
|
||||
InputFileError,
|
||||
MissingDependencyError,
|
||||
OutputFileAccessError,
|
||||
PdfMergeFailedError,
|
||||
PriorOcrFoundError,
|
||||
SubprocessOutputError,
|
||||
TesseractConfigError,
|
||||
@@ -30,3 +29,33 @@ from ocrmypdf.exceptions import (
|
||||
from ocrmypdf.pluginspec import OcrEngine, OrientationConfidence
|
||||
|
||||
hookimpl = _HookimplMarker('ocrmypdf')
|
||||
|
||||
__all__ = [
|
||||
'__version__',
|
||||
'BadArgsError',
|
||||
'configure_logging',
|
||||
'DpiError',
|
||||
'EncryptedPdfError',
|
||||
'Executor',
|
||||
'ExitCode',
|
||||
'ExitCodeException',
|
||||
'helpers',
|
||||
'hocrtransform',
|
||||
'hookimpl',
|
||||
'InputFileError',
|
||||
'MissingDependencyError',
|
||||
'ocr',
|
||||
'OcrEngine',
|
||||
'OrientationConfidence',
|
||||
'OutputFileAccessError',
|
||||
'PageContext',
|
||||
'pdfa',
|
||||
'PdfContext',
|
||||
'pdfinfo',
|
||||
'PriorOcrFoundError',
|
||||
'PROGRAM_NAME',
|
||||
'SubprocessOutputError',
|
||||
'TesseractConfigError',
|
||||
'UnsupportedImageFormatError',
|
||||
'Verbosity',
|
||||
]
|
||||
|
||||
@@ -11,7 +11,6 @@ import os
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from multiprocessing import set_start_method
|
||||
|
||||
from ocrmypdf import __version__
|
||||
from ocrmypdf._plugin_manager import get_parser_options_plugins
|
||||
@@ -29,10 +28,16 @@ log = logging.getLogger('ocrmypdf')
|
||||
|
||||
|
||||
def sigbus(*args):
|
||||
"""Handle SIGBUS signals.
|
||||
|
||||
pikepdf, depending on configuration, may use mmap so SIGBUS is a
|
||||
possibility.
|
||||
"""
|
||||
raise InputFileError("Lost access to the input file")
|
||||
|
||||
|
||||
def run(args=None):
|
||||
"""Run the ocrmypdf command line interface."""
|
||||
_parser, options, plugin_manager = get_parser_options_plugins(args=args)
|
||||
|
||||
with suppress(AttributeError, PermissionError):
|
||||
@@ -71,6 +76,4 @@ def run(args=None):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if sys.platform == 'darwin' and sys.version_info < (3, 8):
|
||||
set_start_method('spawn') # see python bpo-33725
|
||||
sys.exit(run())
|
||||
|
||||
@@ -51,8 +51,7 @@ class Executor(ABC):
|
||||
task_arguments: Iterable | None = None,
|
||||
task_finished: Callable | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Set up parallel execution and progress reporting.
|
||||
"""Set up parallel execution and progress reporting.
|
||||
|
||||
Args:
|
||||
use_threads: If ``False``, the workload is the sort that will benefit from
|
||||
@@ -60,7 +59,7 @@ class Executor(ABC):
|
||||
heavily, and parallelizing it with threads is not expected to be
|
||||
performant).
|
||||
max_workers: The maximum number of workers that should be run.
|
||||
tdqm_kwargs: Arguments to set up the progress bar.
|
||||
tqdm_kwargs: Arguments to set up the progress bar.
|
||||
worker_initializer: Called when a worker is initialized, in the worker's
|
||||
execution context. If the child workers are processes, it must be
|
||||
possible to marshall/pickle the worker initializer.
|
||||
@@ -73,7 +72,6 @@ class Executor(ABC):
|
||||
task. This runs in the parent's context, but the parameters must be
|
||||
marshallable to the worker.
|
||||
"""
|
||||
|
||||
if not task_arguments:
|
||||
return # Nothing to do!
|
||||
if not worker_initializer:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""Manage third party executables"""
|
||||
"""Manage third party executables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""Interface to Ghostscript executable"""
|
||||
"""Interface to Ghostscript executable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -14,6 +14,7 @@ from os import fspath
|
||||
from pathlib import Path
|
||||
from subprocess import PIPE, CalledProcessError
|
||||
|
||||
from packaging.version import Version
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from ocrmypdf.exceptions import SubprocessOutputError
|
||||
@@ -29,7 +30,7 @@ except AttributeError:
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Most reliable what to get the bitness of Python interpreter, according to Python docs
|
||||
# Most reliable way to get the bitness of Python interpreter, according to Python docs
|
||||
_IS_64BIT = sys.maxsize > 2**32
|
||||
|
||||
_GSWIN = None
|
||||
@@ -47,21 +48,6 @@ def version():
|
||||
return get_version(GS)
|
||||
|
||||
|
||||
def jpeg_passthrough_available() -> bool:
|
||||
"""Returns True if the installed version of Ghostscript supports JPEG passthru
|
||||
|
||||
Prior to 9.23, Ghostscript decoded and re-encoded JPEGs internally. In 9.23
|
||||
it gained the ability to keep JPEGs unmodified. However, the 9.23
|
||||
implementation was buggy and would deletes the last two bytes of images in
|
||||
some cases, as reported here.
|
||||
https://bugs.ghostscript.com/show_bug.cgi?id=699216
|
||||
|
||||
The issue was fixed for 9.24, hence that is the first version we consider
|
||||
the feature available. (Ghostscript 9.24 has its own problems is blacklisted.)
|
||||
"""
|
||||
return version() >= '9.24'
|
||||
|
||||
|
||||
def _gs_error_reported(stream) -> bool:
|
||||
match = re.search(r'error', stream, flags=re.IGNORECASE)
|
||||
return bool(match)
|
||||
@@ -77,6 +63,7 @@ def rasterize_pdf(
|
||||
page_dpi: Resolution | None = None,
|
||||
rotation: int | None = None,
|
||||
filter_vector: bool = False,
|
||||
stop_on_error: bool = False,
|
||||
):
|
||||
"""Rasterize one page of a PDF at resolution raster_dpi in canvas units."""
|
||||
raster_dpi = raster_dpi.round(6)
|
||||
@@ -97,6 +84,7 @@ def rasterize_pdf(
|
||||
f'-r{raster_dpi.x:f}x{raster_dpi.y:f}',
|
||||
]
|
||||
+ (['-dFILTERVECTOR'] if filter_vector else [])
|
||||
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
||||
+ [
|
||||
'-o',
|
||||
'-',
|
||||
@@ -175,6 +163,7 @@ def generate_pdfa(
|
||||
pdf_version: str = '1.5',
|
||||
pdfa_part: str = '2',
|
||||
progressbar_class=None,
|
||||
stop_on_error: bool = False,
|
||||
):
|
||||
# Ghostscript's compression is all or nothing. We can either force all images
|
||||
# to JPEG, force all to Flate/PNG, or let it decide how to encode the images.
|
||||
@@ -201,22 +190,16 @@ def generate_pdfa(
|
||||
]
|
||||
|
||||
strategy = 'LeaveColorUnchanged'
|
||||
# Older versions of Ghostscript expect a leading slash in
|
||||
# sColorConversionStrategy, newer ones should not have it. See Ghostscript
|
||||
# git commit fe1c025d.
|
||||
gs_version = version()
|
||||
strategy = ('/' + strategy) if gs_version < '9.19' else strategy
|
||||
|
||||
if gs_version == '9.23':
|
||||
# 9.23: added JPEG passthrough as a new feature, but with a bug that
|
||||
# incorrectly formats some images. Fixed as of 9.24. So we disable this
|
||||
# feature for 9.23.
|
||||
# https://bugs.ghostscript.com/show_bug.cgi?id=699216
|
||||
compression_args.append('-dPassThroughJPEGImages=false')
|
||||
elif gs_version == '9.56.0':
|
||||
# 9.56.0 breaks our OCR...?
|
||||
gs_version = Version(version())
|
||||
if gs_version == Version('9.56.0'):
|
||||
# 9.56.0 breaks our OCR, should be fixed in 9.56.1
|
||||
# https://bugs.ghostscript.com/show_bug.cgi?id=705187
|
||||
compression_args.append('-dNEWPDF=false')
|
||||
|
||||
if os.name == 'nt':
|
||||
# Windows has lots of fatal "permission denied" errors
|
||||
stop_on_error = False
|
||||
|
||||
# nb no need to specify ProcessColorModel when ColorConversionStrategy
|
||||
# is set; see:
|
||||
# https://bugs.ghostscript.com/show_bug.cgi?id=699392
|
||||
@@ -231,6 +214,7 @@ def generate_pdfa(
|
||||
"-dAutoRotatePages=/None",
|
||||
"-sColorConversionStrategy=" + strategy,
|
||||
]
|
||||
+ (['-dPDFSTOPONERROR'] if stop_on_error else [])
|
||||
+ compression_args
|
||||
+ [
|
||||
"-dJPEGQ=95",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""Interface to jbig2 executable"""
|
||||
"""Interface to jbig2 executable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""Interface to pngquant executable"""
|
||||
"""Interface to pngquant executable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""Interface to Tesseract executable"""
|
||||
"""Interface to Tesseract executable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -33,11 +33,13 @@ HOCR_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<head>
|
||||
<title></title>
|
||||
<meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
|
||||
<meta name='ocr-system' content='tesseract 4.0.0' />
|
||||
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
|
||||
<meta name='ocr-system' content='tesseract 4.1.1' />
|
||||
<meta name='ocr-capabilities'
|
||||
content='ocr_page ocr_carea ocr_par ocr_line ocrx_word ocrp_wconf'/>
|
||||
</head>
|
||||
<body>
|
||||
<div class='ocr_page' id='page_1' title='image "_blank.png"; bbox 0 0 {0} {1}; ppageno 0'>
|
||||
<div class='ocr_page' id='page_1'
|
||||
title='image "_blank.png"; bbox 0 0 {0} {1}; ppageno 0'>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -52,7 +54,7 @@ TESSERACT_THRESHOLDING_METHODS: dict[str, int] = {
|
||||
|
||||
|
||||
class TesseractLoggerAdapter(logging.LoggerAdapter):
|
||||
"Prepend [tesseract] to messages emitted from tesseract"
|
||||
"""Prepend [tesseract] to messages emitted from tesseract."""
|
||||
|
||||
def process(self, msg, kwargs):
|
||||
kwargs['extra'] = self.extra
|
||||
@@ -104,7 +106,8 @@ TESSERACT_VERSION_PATTERN = r"""
|
||||
|
||||
|
||||
class TesseractVersion(Version):
|
||||
"Modify standard packaging.Version regex to support Tesseract idiosyncracies."
|
||||
"""Modify standard packaging.Version regex to support Tesseract idiosyncrasies."""
|
||||
|
||||
_regex = re.compile(
|
||||
r"^\s*" + TESSERACT_VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE
|
||||
)
|
||||
@@ -114,15 +117,6 @@ def version() -> str:
|
||||
return get_version('tesseract', regex=r'tesseract\s(.+)')
|
||||
|
||||
|
||||
def has_user_words() -> bool:
|
||||
"""Does Tesseract have --user-words capability?
|
||||
|
||||
Not available in 4.0, but available in 4.1. Also available in 3.x, but
|
||||
we no longer support 3.x.
|
||||
"""
|
||||
return version() >= '4.1'
|
||||
|
||||
|
||||
def has_thresholding() -> bool:
|
||||
"""Does Tesseract have -c thresholding method capability?"""
|
||||
return version() >= '5.0'
|
||||
@@ -239,12 +233,13 @@ def get_deskew(
|
||||
parsed = _parse_tesseract_output(p.stdout)
|
||||
deskew_radians = float(parsed.get('Deskew angle', 0))
|
||||
deskew_degrees = 180 / pi * deskew_radians
|
||||
log.debug(f"Deskew angle: {deskew_degrees:.3f}")
|
||||
return deskew_degrees
|
||||
|
||||
|
||||
def tesseract_log_output(stream: bytes) -> None:
|
||||
tlog = TesseractLoggerAdapter(
|
||||
log, extra=log.extra if hasattr(log, 'extra') else None
|
||||
log, extra=log.extra if hasattr(log, 'extra') else None # type: ignore
|
||||
)
|
||||
|
||||
if not stream:
|
||||
@@ -289,8 +284,10 @@ def page_timedout(timeout: float) -> None:
|
||||
|
||||
|
||||
def _generate_null_hocr(output_hocr: Path, output_text: Path, image: Path) -> None:
|
||||
"""Produce a .hocr file that reports no text detected on a page that is
|
||||
the same size as the input image."""
|
||||
"""Produce a .hocr file that reports no text detected.
|
||||
|
||||
Ensures page is the same size as the input image.
|
||||
"""
|
||||
with Image.open(image) as im:
|
||||
w, h = im.size
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
"""Interface to unpaper executable."""
|
||||
|
||||
# unpaper documentation:
|
||||
# https://github.com/Flameeyes/unpaper/blob/master/doc/basic-concepts.md
|
||||
"""Interface to unpaper executable"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
@@ -22,6 +20,10 @@ from PIL import Image
|
||||
from ocrmypdf.exceptions import MissingDependencyError, SubprocessOutputError
|
||||
from ocrmypdf.subprocess import get_version, run
|
||||
|
||||
# unpaper documentation:
|
||||
# https://github.com/Flameeyes/unpaper/blob/main/doc/basic-concepts.md
|
||||
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
from tempfile import TemporaryDirectory
|
||||
else:
|
||||
|
||||
@@ -37,7 +37,6 @@ def _update_resources(*, obj, font, font_key, procset):
|
||||
|
||||
obj can be a page or Form XObject.
|
||||
"""
|
||||
|
||||
resources = _ensure_dictionary(obj, Name.Resources)
|
||||
fonts = _ensure_dictionary(resources, Name.Font)
|
||||
if font_key is not None and font_key not in fonts:
|
||||
@@ -131,7 +130,8 @@ class OcrGrafter:
|
||||
text_misaligned = (text_rotation - content_rotation) % 360
|
||||
log.debug(
|
||||
f"Text rotation: (text, autorotate, content) -> text misalignment = "
|
||||
f"({text_rotation}, {autorotate_correction}, {content_rotation}) -> {text_misaligned}"
|
||||
f"({text_rotation}, {autorotate_correction}, {content_rotation}) -> "
|
||||
f"{text_misaligned}"
|
||||
)
|
||||
|
||||
if textpdf and self.font:
|
||||
@@ -166,7 +166,6 @@ class OcrGrafter:
|
||||
the font to page 1 even if page 1 doesn't use it, so we have a way to get it
|
||||
back.
|
||||
"""
|
||||
|
||||
page0 = self.pdf_base.pages[0]
|
||||
_update_resources(
|
||||
obj=page0, font=self.font, font_key=self.font_key, procset=self.procset
|
||||
@@ -199,8 +198,7 @@ class OcrGrafter:
|
||||
return self.output_file
|
||||
|
||||
def _find_font(self, text):
|
||||
"""Copy a font from the filename text into pdf_base"""
|
||||
|
||||
"""Copy a font from the filename text into pdf_base."""
|
||||
font, font_key = None, None
|
||||
possible_font_names = ('/f-0-0', '/F1')
|
||||
try:
|
||||
@@ -233,8 +231,7 @@ class OcrGrafter:
|
||||
text_rotation: int,
|
||||
strip_old_text: bool,
|
||||
):
|
||||
"""Insert the text layer from text page 0 on to pdf_base at page_num"""
|
||||
|
||||
"""Insert the text layer from text page 0 on to pdf_base at page_num."""
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
log.debug("Grafting")
|
||||
|
||||
@@ -59,7 +59,7 @@ class PdfContext:
|
||||
class PageContext:
|
||||
"""Holds our context for a page.
|
||||
|
||||
Must be pickable, so stores only intrinsic/simple data elements or those
|
||||
Must be pickle-able, so stores only intrinsic/simple data elements or those
|
||||
capable of their serializing themselves via ``__getstate__``.
|
||||
"""
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ class PageNumberFilter(logging.Filter):
|
||||
|
||||
|
||||
class TqdmConsole:
|
||||
"""Wrapper to log messages in a way that is compatible with tqdm progress bar
|
||||
"""Wrapper to log messages in a way that is compatible with tqdm progress bar.
|
||||
|
||||
This routes log messages through tqdm so that it can print them above the
|
||||
progress bar, and then refresh the progress bar, rather than overwriting
|
||||
|
||||
+179
-89
@@ -1,4 +1,5 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-FileCopyrightText: 2018-2022 James R. Barlow
|
||||
# SPDX-FileCopyrightText: 2019 Martin Wind
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""OCRmyPDF page processing pipeline functions."""
|
||||
@@ -13,7 +14,7 @@ from contextlib import suppress
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from shutil import copyfileobj
|
||||
from typing import Iterable
|
||||
from typing import Any, BinaryIO, Iterable, Sequence, cast
|
||||
|
||||
import img2pdf
|
||||
import pikepdf
|
||||
@@ -35,7 +36,8 @@ from ocrmypdf.exceptions import (
|
||||
from ocrmypdf.helpers import IMG2PDF_KWARGS, Resolution, safe_symlink
|
||||
from ocrmypdf.hocrtransform import HocrTransform
|
||||
from ocrmypdf.pdfa import generate_pdfa_ps
|
||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, PdfInfo
|
||||
from ocrmypdf.pdfinfo import Colorspace, Encoding, PageInfo, PdfInfo
|
||||
from ocrmypdf.pluginspec import OrientationConfidence
|
||||
|
||||
# Remove this workaround when we require Pillow >= 10
|
||||
try:
|
||||
@@ -49,7 +51,21 @@ log = logging.getLogger(__name__)
|
||||
VECTOR_PAGE_DPI = 400
|
||||
|
||||
|
||||
def triage_image_file(input_file, output_file, options):
|
||||
def triage_image_file(input_file: Path, output_file: Path, options) -> None:
|
||||
"""Triage the input image file.
|
||||
|
||||
If the input file is an image, check its resolution and convert it to PDF.
|
||||
|
||||
Args:
|
||||
input_file: The path to the input file.
|
||||
output_file: The path to the output file.
|
||||
options: An object containing the options passed to the OCRmyPDF command.
|
||||
|
||||
Raises:
|
||||
UnsupportedImageFormatError: If the input file is not a supported image format.
|
||||
DpiError: If the input image has no resolution (DPI) in its metadata or if the
|
||||
resolution is not credible.
|
||||
"""
|
||||
log.info("Input file is not a PDF, checking if it is an image...")
|
||||
try:
|
||||
im = Image.open(input_file)
|
||||
@@ -113,23 +129,24 @@ def triage_image_file(input_file, output_file, options):
|
||||
raise UnsupportedImageFormatError() from e
|
||||
|
||||
|
||||
def _pdf_guess_version(input_file, search_window=1024):
|
||||
def _pdf_guess_version(input_file: Path, search_window=1024) -> str:
|
||||
"""Try to find version signature at start of file.
|
||||
|
||||
Not robust enough to deal with appended files.
|
||||
|
||||
Returns empty string if not found, indicating file is probably not PDF.
|
||||
"""
|
||||
|
||||
with open(input_file, 'rb') as f:
|
||||
signature = f.read(search_window)
|
||||
m = re.search(br'%PDF-(\d\.\d)', signature)
|
||||
if m:
|
||||
return m.group(1)
|
||||
return m.group(1).decode('ascii')
|
||||
return ''
|
||||
|
||||
|
||||
def triage(original_filename, input_file, output_file, options):
|
||||
def triage(
|
||||
original_filename: str, input_file: Path, output_file: Path, options
|
||||
) -> Path:
|
||||
try:
|
||||
if _pdf_guess_version(input_file):
|
||||
if options.image_dpi:
|
||||
@@ -153,9 +170,9 @@ def get_pdfinfo(
|
||||
input_file,
|
||||
*,
|
||||
executor: Executor,
|
||||
detailed_analysis=False,
|
||||
progbar=False,
|
||||
max_workers=None,
|
||||
detailed_analysis: bool = False,
|
||||
progbar: bool = False,
|
||||
max_workers: int | None = None,
|
||||
check_pages=None,
|
||||
) -> PdfInfo:
|
||||
try:
|
||||
@@ -173,7 +190,7 @@ def get_pdfinfo(
|
||||
raise InputFileError() from e
|
||||
|
||||
|
||||
def validate_pdfinfo_options(context: PdfContext):
|
||||
def validate_pdfinfo_options(context: PdfContext) -> None:
|
||||
pdfinfo = context.pdfinfo
|
||||
options = context.options
|
||||
|
||||
@@ -183,15 +200,6 @@ def validate_pdfinfo_options(context: PdfContext):
|
||||
"Designer and can only be read by Adobe Acrobat or Adobe Reader."
|
||||
)
|
||||
raise InputFileError()
|
||||
if pdfinfo.has_userunit and options.output_type.startswith('pdfa'):
|
||||
log.error(
|
||||
"This input file uses a PDF feature that is not supported "
|
||||
"by Ghostscript, so you cannot use --output-type=pdfa for this "
|
||||
"file. (Specifically, it uses the PDF-1.6 /UserUnit feature to "
|
||||
"support very large or small page sizes, and Ghostscript cannot "
|
||||
"output these files.) Use --output-type=pdf instead."
|
||||
)
|
||||
raise InputFileError()
|
||||
if pdfinfo.has_acroform:
|
||||
if options.redo_ocr:
|
||||
log.error(
|
||||
@@ -214,12 +222,13 @@ def validate_pdfinfo_options(context: PdfContext):
|
||||
context.plugin_manager.hook.validate(pdfinfo=pdfinfo, options=options)
|
||||
|
||||
|
||||
def _vector_page_dpi(pageinfo):
|
||||
return VECTOR_PAGE_DPI if pageinfo.has_vector or pageinfo.has_text else 0.0
|
||||
def _vector_page_dpi(pageinfo: PageInfo) -> int:
|
||||
"""Get a DPI to use for vector pages, if the page has vector content."""
|
||||
return VECTOR_PAGE_DPI if pageinfo.has_vector or pageinfo.has_text else 0
|
||||
|
||||
|
||||
def get_page_dpi(pageinfo, options):
|
||||
"Get the DPI when nonsquare DPI is tolerable"
|
||||
def get_page_dpi(pageinfo: PageInfo, options) -> Resolution:
|
||||
"""Get the DPI when nonsquare DPI is tolerable."""
|
||||
xres = max(
|
||||
pageinfo.dpi.x or VECTOR_PAGE_DPI,
|
||||
options.oversample or 0.0,
|
||||
@@ -233,8 +242,11 @@ def get_page_dpi(pageinfo, options):
|
||||
return Resolution(float(xres), float(yres))
|
||||
|
||||
|
||||
def get_page_square_dpi(pageinfo, options) -> Resolution:
|
||||
"Get the DPI when we require xres == yres, scaled to physical units"
|
||||
def get_page_square_dpi(pageinfo: PageInfo, options) -> Resolution:
|
||||
"""Get the DPI when we require xres == yres, scaled to physical units.
|
||||
|
||||
Page DPI includes UserUnit scaling.
|
||||
"""
|
||||
xres = pageinfo.dpi.x or 0.0
|
||||
yres = pageinfo.dpi.y or 0.0
|
||||
userunit = float(pageinfo.userunit) or 1.0
|
||||
@@ -249,8 +261,13 @@ def get_page_square_dpi(pageinfo, options) -> Resolution:
|
||||
return Resolution(units, units)
|
||||
|
||||
|
||||
def get_canvas_square_dpi(pageinfo, options) -> Resolution:
|
||||
"""Get the DPI when we require xres == yres, in Postscript units"""
|
||||
def get_canvas_square_dpi(pageinfo: PageInfo, options) -> Resolution:
|
||||
"""Get the DPI when we require xres == yres, in Postscript units.
|
||||
|
||||
Canvas DPI is independent of PDF UserUnit scaling, which is
|
||||
used to describe situations where the PDF user space is not 1:1 with
|
||||
the physical units of the page.
|
||||
"""
|
||||
units = float(
|
||||
max(
|
||||
(pageinfo.dpi.x) or VECTOR_PAGE_DPI,
|
||||
@@ -262,7 +279,8 @@ def get_canvas_square_dpi(pageinfo, options) -> Resolution:
|
||||
return Resolution(units, units)
|
||||
|
||||
|
||||
def is_ocr_required(page_context: PageContext):
|
||||
def is_ocr_required(page_context: PageContext) -> bool:
|
||||
"""Check if the page needs to be OCR'd."""
|
||||
pageinfo = page_context.pageinfo
|
||||
options = page_context.options
|
||||
|
||||
@@ -312,8 +330,8 @@ def is_ocr_required(page_context: PageContext):
|
||||
log.warning(
|
||||
"page has no images - "
|
||||
"all vector content will be "
|
||||
f"rasterized at {VECTOR_PAGE_DPI} DPI, losing some resolution and likely "
|
||||
"increasing file size. Use --oversample to adjust the "
|
||||
f"rasterized at {VECTOR_PAGE_DPI} DPI, losing some resolution and "
|
||||
"likely increasing file size. Use --oversample to adjust the "
|
||||
"DPI."
|
||||
)
|
||||
else:
|
||||
@@ -337,7 +355,8 @@ def is_ocr_required(page_context: PageContext):
|
||||
return ocr_required
|
||||
|
||||
|
||||
def rasterize_preview(input_file: Path, page_context: PageContext):
|
||||
def rasterize_preview(input_file: Path, page_context: PageContext) -> Path:
|
||||
"""Generate a lower quality preview image."""
|
||||
output_file = page_context.get_path('rasterize_preview.jpg')
|
||||
canvas_dpi = get_canvas_square_dpi(page_context.pageinfo, page_context.options)
|
||||
page_dpi = get_page_square_dpi(page_context.pageinfo, page_context.options)
|
||||
@@ -350,14 +369,15 @@ def rasterize_preview(input_file: Path, page_context: PageContext):
|
||||
page_dpi=page_dpi,
|
||||
rotation=0,
|
||||
filter_vector=False,
|
||||
stop_on_soft_error=not page_context.options.continue_on_soft_render_error,
|
||||
)
|
||||
return output_file
|
||||
|
||||
|
||||
def describe_rotation(page_context: PageContext, orient_conf, correction: int):
|
||||
"""
|
||||
Describe the page rotation we are going to perform.
|
||||
"""
|
||||
def describe_rotation(
|
||||
page_context: PageContext, orient_conf: OrientationConfidence, correction: int
|
||||
) -> str:
|
||||
"""Describe the page rotation we are going to perform (or not perform)."""
|
||||
direction = {0: '⇧', 90: '⇨', 180: '⇩', 270: '⇦'}
|
||||
turns = {0: ' ', 90: '⬏', 180: '↻', 270: '⬑'}
|
||||
|
||||
@@ -383,8 +403,8 @@ def describe_rotation(page_context: PageContext, orient_conf, correction: int):
|
||||
return f"{facing}, confidence {orient_conf.confidence:.2f} - {action}"
|
||||
|
||||
|
||||
def get_orientation_correction(preview: Path, page_context: PageContext):
|
||||
"""Work out orientation correct for each page.
|
||||
def get_orientation_correction(preview: Path, page_context: PageContext) -> int:
|
||||
"""Work out orientation correction for each page.
|
||||
|
||||
We ask Ghostscript to draw a preview page, which will rasterize with the
|
||||
current /Rotate applied, and then ask OCR which way the page is
|
||||
@@ -398,7 +418,6 @@ def get_orientation_correction(preview: Path, page_context: PageContext):
|
||||
which points it (hopefully) upright. _graft.py takes care of the orienting
|
||||
the image and text layers.
|
||||
"""
|
||||
|
||||
orient_conf = page_context.plugin_manager.hook.get_ocr_engine().get_orientation(
|
||||
preview, page_context.options
|
||||
)
|
||||
@@ -419,8 +438,22 @@ def rasterize(
|
||||
page_context: PageContext,
|
||||
correction: int = 0,
|
||||
output_tag: str = '',
|
||||
remove_vectors=None,
|
||||
):
|
||||
remove_vectors: bool | None = None,
|
||||
) -> Path:
|
||||
"""Rasterize a PDF page to a PNG image.
|
||||
|
||||
Args:
|
||||
input_file: The input PDF file path.
|
||||
page_context: The page context object.
|
||||
correction: The orientation correction angle. Defaults to 0.
|
||||
output_tag: The output tag. Defaults to ''.
|
||||
remove_vectors: Whether to remove vectors. Defaults to None, which means
|
||||
the value from the page context options will be used. If the value
|
||||
is True or False, it will override the page context options.
|
||||
|
||||
Returns:
|
||||
Path: The output PNG file path.
|
||||
"""
|
||||
colorspaces = ['pngmono', 'pnggray', 'png256', 'png16m']
|
||||
device_idx = 0
|
||||
|
||||
@@ -465,22 +498,31 @@ def rasterize(
|
||||
pageno=pageinfo.pageno + 1,
|
||||
rotation=correction,
|
||||
filter_vector=remove_vectors,
|
||||
stop_on_soft_error=not page_context.options.continue_on_soft_render_error,
|
||||
)
|
||||
return output_file
|
||||
|
||||
|
||||
def preprocess_remove_background(input_file: Path, page_context: PageContext):
|
||||
def preprocess_remove_background(input_file: Path, page_context: PageContext) -> Path:
|
||||
if any(image.bpc > 1 for image in page_context.pageinfo.images):
|
||||
raise NotImplementedError("--remove-background is temporarily not implemented")
|
||||
# output_file = page_context.get_path('pp_rm_bg.png')
|
||||
# leptonica.remove_background(input_file, output_file)
|
||||
# return output_file
|
||||
else:
|
||||
log.info("background removal skipped on mono page")
|
||||
return input_file
|
||||
log.info("background removal skipped on mono page")
|
||||
return input_file
|
||||
|
||||
|
||||
def preprocess_deskew(input_file: Path, page_context: PageContext):
|
||||
def preprocess_deskew(input_file: Path, page_context: PageContext) -> Path:
|
||||
"""Deskews the input image using the OCR engine and saves the output to a file.
|
||||
|
||||
Args:
|
||||
input_file: The input image file to deskew.
|
||||
page_context: The context of the page being processed.
|
||||
|
||||
Returns:
|
||||
Path: The path to the deskewed image file.
|
||||
"""
|
||||
output_file = page_context.get_path('pp_deskew.png')
|
||||
dpi = get_page_square_dpi(page_context.pageinfo, page_context.options)
|
||||
|
||||
@@ -500,7 +542,7 @@ def preprocess_deskew(input_file: Path, page_context: PageContext):
|
||||
return output_file
|
||||
|
||||
|
||||
def preprocess_clean(input_file: Path, page_context: PageContext):
|
||||
def preprocess_clean(input_file: Path, page_context: PageContext) -> Path:
|
||||
output_file = page_context.get_path('pp_clean.png')
|
||||
dpi = get_page_square_dpi(page_context.pageinfo, page_context.options)
|
||||
return unpaper.clean(
|
||||
@@ -511,11 +553,12 @@ def preprocess_clean(input_file: Path, page_context: PageContext):
|
||||
)
|
||||
|
||||
|
||||
def create_ocr_image(image: Path, page_context: PageContext):
|
||||
"""Create the image we send for OCR. May not be the same as the display
|
||||
image depending on preprocessing. This image will never be shown to the
|
||||
user."""
|
||||
def create_ocr_image(image: Path, page_context: PageContext) -> Path:
|
||||
"""Create the image we send for OCR.
|
||||
|
||||
Might not be the same as the display image depending on preprocessing.
|
||||
This image will never be shown to the user.
|
||||
"""
|
||||
output_file = page_context.get_path('ocr.png')
|
||||
options = page_context.options
|
||||
with Image.open(image) as im:
|
||||
@@ -559,7 +602,7 @@ def create_ocr_image(image: Path, page_context: PageContext):
|
||||
return output_file
|
||||
|
||||
|
||||
def ocr_engine_hocr(input_file: Path, page_context: PageContext):
|
||||
def ocr_engine_hocr(input_file: Path, page_context: PageContext) -> tuple[Path, Path]:
|
||||
hocr_out = page_context.get_path('ocr_hocr.hocr')
|
||||
hocr_text_out = page_context.get_path('ocr_hocr.txt')
|
||||
options = page_context.options
|
||||
@@ -574,9 +617,20 @@ def ocr_engine_hocr(input_file: Path, page_context: PageContext):
|
||||
return (hocr_out, hocr_text_out)
|
||||
|
||||
|
||||
def should_visible_page_image_use_jpg(pageinfo):
|
||||
# If all images were JPEGs originally, produce a JPEG as output
|
||||
return pageinfo.images and all(im.enc == Encoding.jpeg for im in pageinfo.images)
|
||||
def should_visible_page_image_use_jpg(pageinfo: PageInfo) -> bool:
|
||||
"""Determines whether the visible page image should be saved as a JPEG.
|
||||
|
||||
If all images were JPEGs originally, permit a JPEG as output.
|
||||
|
||||
Args:
|
||||
pageinfo: The PageInfo object containing information about the page.
|
||||
|
||||
Returns:
|
||||
A boolean indicating whether the visible page image should be saved as a JPEG.
|
||||
"""
|
||||
return bool(pageinfo.images) and all(
|
||||
im.enc == Encoding.jpeg for im in pageinfo.images
|
||||
)
|
||||
|
||||
|
||||
def create_visible_page_jpg(image: Path, page_context: PageContext) -> Path:
|
||||
@@ -599,8 +653,8 @@ def create_visible_page_jpg(image: Path, page_context: PageContext) -> Path:
|
||||
|
||||
|
||||
def create_pdf_page_from_image(
|
||||
image: Path, page_context: PageContext, orientation_correction
|
||||
):
|
||||
image: Path, page_context: PageContext, orientation_correction: int
|
||||
) -> Path:
|
||||
# We rasterize a square DPI version of each page because most image
|
||||
# processing tools don't support rectangular DPI. Use the square DPI as it
|
||||
# accurately describes the image. It would be possible to resample the image
|
||||
@@ -628,11 +682,10 @@ def create_pdf_page_from_image(
|
||||
output_file = page_context.plugin_manager.hook.filter_pdf_page(
|
||||
page=page_context, image_filename=image, output_pdf=output_file
|
||||
)
|
||||
|
||||
return output_file
|
||||
|
||||
|
||||
def render_hocr_page(hocr: Path, page_context: PageContext):
|
||||
def render_hocr_page(hocr: Path, page_context: PageContext) -> Path:
|
||||
options = page_context.options
|
||||
output_file = page_context.get_path('ocr_hocr.pdf')
|
||||
dpi = get_page_square_dpi(page_context.pageinfo, options)
|
||||
@@ -649,7 +702,9 @@ def render_hocr_page(hocr: Path, page_context: PageContext):
|
||||
return output_file
|
||||
|
||||
|
||||
def ocr_engine_textonly_pdf(input_image: Path, page_context: PageContext):
|
||||
def ocr_engine_textonly_pdf(
|
||||
input_image: Path, page_context: PageContext
|
||||
) -> tuple[Path, Path]:
|
||||
output_pdf = page_context.get_path('ocr_tess.pdf')
|
||||
output_text = page_context.get_path('ocr_tess.txt')
|
||||
options = page_context.options
|
||||
@@ -695,13 +750,13 @@ def get_docinfo(base_pdf: pikepdf.Pdf, context: PdfContext) -> dict[str, str]:
|
||||
return pdfmark
|
||||
|
||||
|
||||
def generate_postscript_stub(context: PdfContext):
|
||||
def generate_postscript_stub(context: PdfContext) -> Path:
|
||||
output_file = context.get_path('pdfa.ps')
|
||||
generate_pdfa_ps(output_file)
|
||||
return output_file
|
||||
|
||||
|
||||
def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext):
|
||||
def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext) -> Path:
|
||||
options = context.options
|
||||
input_pdfinfo = context.pdfinfo
|
||||
fix_docinfo_file = context.get_path('fix_docinfo.pdf')
|
||||
@@ -743,19 +798,20 @@ def convert_to_pdfa(input_pdf: Path, input_ps_stub: Path, context: PdfContext):
|
||||
if options.progress_bar
|
||||
else None
|
||||
),
|
||||
stop_on_soft_error=not options.continue_on_soft_render_error,
|
||||
)
|
||||
|
||||
return output_file
|
||||
|
||||
|
||||
def should_linearize(working_file: Path, context: PdfContext):
|
||||
def should_linearize(working_file: Path, context: PdfContext) -> bool:
|
||||
filesize = os.stat(working_file).st_size
|
||||
if filesize > (context.options.fast_web_view * 1_000_000):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def get_pdf_save_settings(output_type: str):
|
||||
def get_pdf_save_settings(output_type: str) -> dict[str, Any]:
|
||||
if output_type == 'pdfa-1':
|
||||
# Trigger recompression to ensure object streams are removed, because
|
||||
# Acrobat complains about them in PDF/A-1b validation.
|
||||
@@ -773,7 +829,7 @@ def get_pdf_save_settings(output_type: str):
|
||||
)
|
||||
|
||||
|
||||
def metadata_fixup(working_file: Path, context: PdfContext):
|
||||
def metadata_fixup(working_file: Path, context: PdfContext) -> Path:
|
||||
output_file = context.get_path('metafix.pdf')
|
||||
options = context.options
|
||||
|
||||
@@ -796,24 +852,43 @@ def metadata_fixup(working_file: Path, context: PdfContext):
|
||||
|
||||
with pikepdf.open(context.origin) as original, pikepdf.open(working_file) as pdf:
|
||||
docinfo = get_docinfo(original, context)
|
||||
with pdf.open_metadata() as meta:
|
||||
meta.load_from_docinfo(docinfo, delete_missing=False, raise_failure=False)
|
||||
with pdf.open_metadata() as meta_pdf:
|
||||
meta_pdf.load_from_docinfo(docinfo, delete_missing=False, raise_failure=False)
|
||||
# If xmp:CreateDate is missing, set it to the modify date to
|
||||
# match Ghostscript, for consistency
|
||||
if 'xmp:CreateDate' not in meta:
|
||||
meta['xmp:CreateDate'] = meta.get('xmp:ModifyDate', '')
|
||||
# ensure consistency with Ghostscript.
|
||||
if 'xmp:CreateDate' not in meta_pdf:
|
||||
meta_pdf['xmp:CreateDate'] = meta_pdf.get('xmp:ModifyDate', '')
|
||||
|
||||
with original.open_metadata(
|
||||
set_pikepdf_as_editor=False, update_docinfo=False, strict=False
|
||||
) as meta_original:
|
||||
if meta.get('dc:title') == 'Untitled':
|
||||
if meta_pdf.get('dc:title') == 'Untitled':
|
||||
# Ghostscript likes to set title to Untitled if omitted from input.
|
||||
# Reverse this, because PDF/A TechNote 0003:Metadata in PDF/A-1
|
||||
# and the XMP Spec do not make this recommendation.
|
||||
if 'dc:title' not in meta_original:
|
||||
del meta['dc:title']
|
||||
missing = set(meta_original.keys()) - set(meta.keys())
|
||||
report_on_metadata(missing)
|
||||
del meta_pdf['dc:title']
|
||||
# If the user explicitly specified an empty string for any of the
|
||||
# following, they should be unset and not reported as missing in
|
||||
# the output pdf. Note that some metadata fields use differing names
|
||||
# between PDF-A and PDF.
|
||||
for meta in [meta_pdf, meta_original]:
|
||||
if options.title == '' and 'dc:title' in meta:
|
||||
del meta['dc:title'] # PDF-A and PDF
|
||||
if options.author == '':
|
||||
if 'dc:creator' in meta:
|
||||
del meta['dc:creator'] # PDF-A (Not xmp:CreatorTool)
|
||||
if 'pdf:Author' in meta:
|
||||
del meta['pdf:Author'] # PDF
|
||||
if options.subject == '':
|
||||
if 'dc:description' in meta:
|
||||
del meta['dc:description'] # PDF-A
|
||||
if 'dc:subject' in meta:
|
||||
del meta['dc:subject'] # PDF
|
||||
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(
|
||||
context=context
|
||||
@@ -829,7 +904,21 @@ def metadata_fixup(working_file: Path, context: PdfContext):
|
||||
return output_file
|
||||
|
||||
|
||||
def optimize_pdf(input_file: Path, context: PdfContext, executor: Executor):
|
||||
def _file_size_ratio(
|
||||
input_file: Path, output_file: Path
|
||||
) -> tuple[float | None, float | None]:
|
||||
input_size = input_file.stat().st_size
|
||||
output_size = output_file.stat().st_size
|
||||
if output_size == 0:
|
||||
return None, None
|
||||
ratio = input_size / output_size
|
||||
savings = 1 - output_size / input_size
|
||||
return ratio, savings
|
||||
|
||||
|
||||
def optimize_pdf(
|
||||
input_file: Path, context: PdfContext, executor: Executor
|
||||
) -> tuple[Path, Sequence[str]]:
|
||||
output_file = context.get_path('optimize.pdf')
|
||||
output_pdf, messages = context.plugin_manager.hook.optimize_pdf(
|
||||
input_pdf=input_file,
|
||||
@@ -839,13 +928,12 @@ def optimize_pdf(input_file: Path, context: PdfContext, executor: Executor):
|
||||
linearize=should_linearize(input_file, context),
|
||||
)
|
||||
|
||||
input_size = input_file.stat().st_size
|
||||
output_size = output_file.stat().st_size
|
||||
if output_size > 0:
|
||||
ratio = input_size / output_size
|
||||
savings = 1 - output_size / input_size
|
||||
log.info(f"Optimize ratio: {ratio:.2f} savings: {(savings):.1%}")
|
||||
|
||||
ratio, savings = _file_size_ratio(input_file, output_file)
|
||||
if ratio:
|
||||
log.info(f"Image optimization ratio: {ratio:.2f} savings: {(savings):.1%}")
|
||||
ratio, savings = _file_size_ratio(context.origin, output_file)
|
||||
if ratio:
|
||||
log.info(f"Total file size ratio: {ratio:.2f} savings: {(savings):.1%}")
|
||||
return output_pdf, messages
|
||||
|
||||
|
||||
@@ -865,7 +953,7 @@ def enumerate_compress_ranges(iterable):
|
||||
yield (skipped_from, index), None
|
||||
|
||||
|
||||
def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext):
|
||||
def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext) -> Path:
|
||||
output_file = context.get_path('sidecar.txt')
|
||||
with open(output_file, 'w', encoding="utf-8") as stream:
|
||||
for (from_, to_), txt_file in enumerate_compress_ranges(txt_files):
|
||||
@@ -890,15 +978,17 @@ def merge_sidecars(txt_files: Iterable[Path | None], context: PdfContext):
|
||||
return output_file
|
||||
|
||||
|
||||
def copy_final(input_file, output_file, _context: PdfContext):
|
||||
def copy_final(
|
||||
input_file: Path, output_file: str | Path | BinaryIO, _context: PdfContext
|
||||
) -> None:
|
||||
log.debug('%s -> %s', input_file, output_file)
|
||||
with open(input_file, 'rb') as input_stream:
|
||||
with input_file.open('rb') as input_stream:
|
||||
if output_file == '-':
|
||||
copyfileobj(input_stream, sys.stdout.buffer)
|
||||
copyfileobj(input_stream, sys.stdout.buffer) # type: ignore[misc]
|
||||
sys.stdout.flush()
|
||||
elif hasattr(output_file, 'writable'):
|
||||
output_stream = output_file
|
||||
copyfileobj(input_stream, output_stream)
|
||||
output_stream = cast(BinaryIO, output_file)
|
||||
copyfileobj(input_stream, output_stream) # type: ignore[misc]
|
||||
with suppress(AttributeError):
|
||||
output_stream.flush()
|
||||
else:
|
||||
|
||||
+9
-10
@@ -1,4 +1,5 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-FileCopyrightText: 2019-2022 James R. Barlow
|
||||
# SPDX-FileCopyrightText: 2019 Martin Wind
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""Implements the concurrent and page synchronous parts of the pipeline."""
|
||||
@@ -206,7 +207,7 @@ def exec_page_sync(page_context: PageContext) -> PageResult:
|
||||
filtered_image = page_context.plugin_manager.hook.filter_page_image(
|
||||
page=page_context, image_filename=visible_image_out
|
||||
)
|
||||
if filtered_image:
|
||||
if filtered_image is not None: # None if no hook is present
|
||||
visible_image_out = filtered_image
|
||||
pdf_page_from_image_out = create_pdf_page_from_image(
|
||||
visible_image_out, page_context, orientation_correction
|
||||
@@ -250,8 +251,7 @@ def worker_init(max_pixels: int) -> None:
|
||||
|
||||
|
||||
def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
||||
"""Execute the pipeline concurrently"""
|
||||
|
||||
"""Execute the pipeline concurrently."""
|
||||
# Run exec_page_sync on every page context
|
||||
options = context.options
|
||||
max_workers = min(len(context.pdfinfo), options.jobs)
|
||||
@@ -315,8 +315,7 @@ def exec_concurrent(context: PdfContext, executor: Executor) -> Sequence[str]:
|
||||
def configure_debug_logging(
|
||||
log_filename: Path, prefix: str = ''
|
||||
) -> logging.FileHandler:
|
||||
"""
|
||||
Create a debug log file at a specified location.
|
||||
"""Create a debug log file at a specified location.
|
||||
|
||||
Arguments:
|
||||
log_filename: Where to the put the log file.
|
||||
@@ -419,13 +418,13 @@ def run_pipeline(
|
||||
options, start_input_file, options.output_file, optimize_messages
|
||||
)
|
||||
|
||||
except (KeyboardInterrupt if not api else NeverRaise):
|
||||
except KeyboardInterrupt if not api else NeverRaise:
|
||||
if options.verbose >= 1:
|
||||
log.exception("KeyboardInterrupt")
|
||||
else:
|
||||
log.error("KeyboardInterrupt")
|
||||
return ExitCode.ctrl_c
|
||||
except (ExitCodeException if not api else NeverRaise) as e:
|
||||
except ExitCodeException if not api else NeverRaise as e:
|
||||
e = cast(ExitCodeException, e)
|
||||
if options.verbose >= 1:
|
||||
log.exception("ExitCodeException")
|
||||
@@ -434,7 +433,7 @@ def run_pipeline(
|
||||
else:
|
||||
log.error(type(e).__name__)
|
||||
return e.exit_code
|
||||
except (PIL.Image.DecompressionBombError if not api else NeverRaise):
|
||||
except PIL.Image.DecompressionBombError if not api else NeverRaise:
|
||||
log.exception(
|
||||
"A decompression bomb error was encountered while executing the "
|
||||
"pipeline. Use the argument --max-image-mpixels to raise the maximum "
|
||||
@@ -452,7 +451,7 @@ def run_pipeline(
|
||||
"argument."
|
||||
)
|
||||
return ExitCode.child_process_error
|
||||
except (Exception if not api else NeverRaise): # pylint: disable=broad-except
|
||||
except Exception if not api else NeverRaise: # pylint: disable=broad-except
|
||||
log.exception("An exception occurred while executing the pipeline")
|
||||
return ExitCode.other_error
|
||||
finally:
|
||||
|
||||
+24
-14
@@ -51,27 +51,38 @@ def check_platform() -> None:
|
||||
)
|
||||
|
||||
|
||||
def check_options_languages(options: Namespace, ocr_engine_languages: set[str]) -> None:
|
||||
def check_options_languages(
|
||||
options: Namespace, ocr_engine_languages: list[str]
|
||||
) -> None:
|
||||
if not options.languages:
|
||||
options.languages = {DEFAULT_LANGUAGE}
|
||||
options.languages = [DEFAULT_LANGUAGE]
|
||||
system_lang = locale.getlocale()[0]
|
||||
if system_lang and not system_lang.startswith('en'):
|
||||
log.debug("No language specified; assuming --language %s", DEFAULT_LANGUAGE)
|
||||
if not ocr_engine_languages:
|
||||
return
|
||||
missing_languages = options.languages - ocr_engine_languages
|
||||
missing_languages = set(options.languages) - set(ocr_engine_languages)
|
||||
if missing_languages:
|
||||
lang_text = '\n'.join(lang for lang in missing_languages)
|
||||
msg = (
|
||||
"OCR engine does not have language data for the following "
|
||||
"requested languages: \n"
|
||||
f"{lang_text}\n"
|
||||
"Please install the appropriate language data for your OCR engine.\n"
|
||||
"\n"
|
||||
"See the online documentation for instructions:\n"
|
||||
" https://ocrmypdf.readthedocs.io/en/latest/languages.html\n"
|
||||
"\n"
|
||||
"Note: most languages are identified by a 3-letter ISO 639-2 Code.\n"
|
||||
"For example, English is 'eng', German is 'deu', and Spanish is 'spa'.\n"
|
||||
"Simplified Chinese is 'chi_sim' and Traditional Chinese is 'chi_tra'."
|
||||
"\n"
|
||||
)
|
||||
msg += '\n'.join(lang for lang in missing_languages)
|
||||
msg += '\nNote: most languages are identified by a 3-digit ISO 639-2 Code'
|
||||
raise MissingDependencyError(msg)
|
||||
|
||||
|
||||
def check_options_output(options: Namespace) -> None:
|
||||
is_latin = options.languages.issubset(HOCR_OK_LANGS)
|
||||
is_latin = set(options.languages).issubset(HOCR_OK_LANGS)
|
||||
|
||||
if options.pdf_renderer.startswith('hocr') and not is_latin:
|
||||
log.warning(
|
||||
@@ -109,12 +120,10 @@ def check_options_output(options: Namespace) -> None:
|
||||
def check_options_sidecar(options: Namespace) -> None:
|
||||
if options.sidecar == '\0':
|
||||
if options.output_file == '-':
|
||||
raise BadArgsError(
|
||||
"--sidecar filename must be specified when output file is stdout."
|
||||
)
|
||||
raise BadArgsError("--sidecar filename needed when output file is stdout.")
|
||||
elif options.output_file == os.devnull:
|
||||
raise BadArgsError(
|
||||
"--sidecar filename must be specified when output file is /dev/null or NUL."
|
||||
"--sidecar filename needed when output file is /dev/null or NUL."
|
||||
)
|
||||
options.sidecar = options.output_file + '.txt'
|
||||
if options.sidecar == options.input_file or options.sidecar == options.output_file:
|
||||
@@ -134,7 +143,7 @@ def check_options_preprocessing(options: Namespace) -> None:
|
||||
package='unpaper',
|
||||
version_checker=unpaper.version,
|
||||
need_version='6.1',
|
||||
required_for=['--clean, --clean-final'],
|
||||
required_for="--clean, --clean-final", # Problem arguments
|
||||
)
|
||||
try:
|
||||
if options.unpaper_args:
|
||||
@@ -221,7 +230,7 @@ def check_options_metadata(options: Namespace) -> None:
|
||||
def check_options_pillow(options: Namespace) -> None:
|
||||
PIL.Image.MAX_IMAGE_PIXELS = int(options.max_image_mpixels * 1_000_000)
|
||||
if PIL.Image.MAX_IMAGE_PIXELS == 0:
|
||||
PIL.Image.MAX_IMAGE_PIXELS = None
|
||||
PIL.Image.MAX_IMAGE_PIXELS = None # type: ignore
|
||||
|
||||
|
||||
def _check_plugin_invariant_options(options: Namespace) -> None:
|
||||
@@ -276,7 +285,8 @@ def create_input_file(options: Namespace, work_folder: Path) -> tuple[Path, str]
|
||||
"permissions correctly.\n"
|
||||
"You may find it easier to use stdin/stdout:"
|
||||
"\n"
|
||||
"\tdocker run -i --rm jbarlow83/ocrmypdf - - <input.pdf >output.pdf\n"
|
||||
"\tdocker run -i --rm jbarlow83/ocrmypdf - - <input.pdf >output.pdf"
|
||||
"\n"
|
||||
)
|
||||
raise InputFileError(msg) from e
|
||||
|
||||
@@ -333,7 +343,7 @@ def report_output_file_size(
|
||||
for arg in image_preproc:
|
||||
if getattr(options, arg, False):
|
||||
reasons.append(
|
||||
f"The argument --{arg.replace('_', '-')} was issued, causing transcoding."
|
||||
f"--{arg.replace('_', '-')} was issued, causing transcoding."
|
||||
)
|
||||
|
||||
reasons.extend(optimize_messages)
|
||||
|
||||
@@ -8,10 +8,7 @@ OCRmyPDF uses setuptools_scm to derive version from git tags.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from importlib.metadata import version as _package_version
|
||||
except ImportError:
|
||||
from importlib_metadata import version as _package_version # type: ignore
|
||||
from importlib.metadata import version as _package_version
|
||||
|
||||
PROGRAM_NAME = 'ocrmypdf'
|
||||
|
||||
|
||||
+63
-56
@@ -15,6 +15,9 @@ from pathlib import Path
|
||||
from typing import AnyStr, BinaryIO, Iterable, Union
|
||||
from warnings import warn
|
||||
|
||||
import coloredlogs
|
||||
from humanfriendly.terminal import enable_ansi_support
|
||||
|
||||
from ocrmypdf._logging import PageNumberFilter, TqdmConsole
|
||||
from ocrmypdf._plugin_manager import get_plugin_manager
|
||||
from ocrmypdf._sync import run_pipeline
|
||||
@@ -22,15 +25,6 @@ from ocrmypdf._validation import check_options
|
||||
from ocrmypdf.cli import ArgumentParser, get_parser
|
||||
from ocrmypdf.helpers import is_iterable_notstr
|
||||
|
||||
try:
|
||||
import coloredlogs
|
||||
except ModuleNotFoundError:
|
||||
coloredlogs = None # pylint: disable=invalid-name
|
||||
|
||||
if coloredlogs:
|
||||
from humanfriendly.terminal import enable_ansi_support
|
||||
|
||||
|
||||
StrPath = Union[Path, AnyStr]
|
||||
PathOrIO = Union[BinaryIO, StrPath]
|
||||
|
||||
@@ -92,7 +86,6 @@ def configure_logging(
|
||||
Returns:
|
||||
The toplevel logger for ocrmypdf (or the root logger, if we are managing it).
|
||||
"""
|
||||
|
||||
prefix = '' if manage_root_logger else 'ocrmypdf'
|
||||
|
||||
log = logging.getLogger(prefix)
|
||||
@@ -121,7 +114,7 @@ def configure_logging(
|
||||
|
||||
use_colors = progress_bar_friendly
|
||||
formatter = None
|
||||
if coloredlogs and use_colors:
|
||||
if use_colors:
|
||||
use_colors = enable_ansi_support()
|
||||
if use_colors:
|
||||
use_colors = coloredlogs.terminal_supports_colors()
|
||||
@@ -149,6 +142,7 @@ def configure_logging(
|
||||
def create_options(
|
||||
*, input_file: PathOrIO, output_file: PathOrIO, parser: ArgumentParser, **kwargs
|
||||
):
|
||||
"""Construct an options object from the input/output files and keyword arguments."""
|
||||
cmdline = []
|
||||
deferred = []
|
||||
|
||||
@@ -209,59 +203,73 @@ def create_options(
|
||||
return options
|
||||
|
||||
|
||||
def ocr( # pylint: disable=unused-argument
|
||||
def ocr( # noqa: ruff: disable=D417
|
||||
input_file: PathOrIO,
|
||||
output_file: PathOrIO,
|
||||
*,
|
||||
language: Iterable[str] = None,
|
||||
image_dpi: int = None,
|
||||
output_type=None,
|
||||
language: Iterable[str] | None = None,
|
||||
image_dpi: int | None = None,
|
||||
output_type: str | None = None,
|
||||
sidecar: StrPath | None = None,
|
||||
jobs: int = None,
|
||||
use_threads: bool = None,
|
||||
title: str = None,
|
||||
author: str = None,
|
||||
subject: str = None,
|
||||
keywords: str = None,
|
||||
rotate_pages: bool = None,
|
||||
remove_background: bool = None,
|
||||
deskew: bool = None,
|
||||
clean: bool = None,
|
||||
clean_final: bool = None,
|
||||
unpaper_args: str = None,
|
||||
oversample: int = None,
|
||||
remove_vectors: bool = None,
|
||||
force_ocr: bool = None,
|
||||
skip_text: bool = None,
|
||||
redo_ocr: bool = None,
|
||||
skip_big: float = None,
|
||||
optimize: int = None,
|
||||
jpg_quality: int = None,
|
||||
png_quality: int = None,
|
||||
jbig2_lossy: bool = None,
|
||||
jbig2_page_group_size: int = None,
|
||||
pages: str = None,
|
||||
max_image_mpixels: float = None,
|
||||
tesseract_config: Iterable[str] = None,
|
||||
tesseract_pagesegmode: int = None,
|
||||
tesseract_oem: int = None,
|
||||
tesseract_thresholding: int = None,
|
||||
pdf_renderer=None,
|
||||
tesseract_timeout: float = None,
|
||||
rotate_pages_threshold: float = None,
|
||||
pdfa_image_compression=None,
|
||||
user_words: os.PathLike = None,
|
||||
user_patterns: os.PathLike = None,
|
||||
fast_web_view: float = None,
|
||||
plugins: Iterable[StrPath] = None,
|
||||
jobs: int | None = None,
|
||||
use_threads: bool | None = None,
|
||||
title: str | None = None,
|
||||
author: str | None = None,
|
||||
subject: str | None = None,
|
||||
keywords: str | None = None,
|
||||
rotate_pages: bool | None = None,
|
||||
remove_background: bool | None = None,
|
||||
deskew: bool | None = None,
|
||||
clean: bool | None = None,
|
||||
clean_final: bool | None = None,
|
||||
unpaper_args: str | None = None,
|
||||
oversample: int | None = None,
|
||||
remove_vectors: bool | None = None,
|
||||
force_ocr: bool | None = None,
|
||||
skip_text: bool | None = None,
|
||||
redo_ocr: bool | None = None,
|
||||
skip_big: float | None = None,
|
||||
optimize: int | None = None,
|
||||
jpg_quality: int | None = None,
|
||||
png_quality: int | None = None,
|
||||
jbig2_lossy: bool | None = None,
|
||||
jbig2_page_group_size: int | None = None,
|
||||
pages: str | None = None,
|
||||
max_image_mpixels: float | None = None,
|
||||
tesseract_config: Iterable[str] | None = None,
|
||||
tesseract_pagesegmode: int | None = None,
|
||||
tesseract_oem: int | None = None,
|
||||
tesseract_thresholding: int | None = None,
|
||||
pdf_renderer: str | None = None,
|
||||
tesseract_timeout: float | None = None,
|
||||
tesseract_non_ocr_timeout: float | None = None,
|
||||
rotate_pages_threshold: float | None = None,
|
||||
pdfa_image_compression: str | None = None,
|
||||
user_words: os.PathLike | None = None,
|
||||
user_patterns: os.PathLike | None = None,
|
||||
fast_web_view: float | None = None,
|
||||
continue_on_soft_render_error: bool | None = None,
|
||||
plugins: Iterable[StrPath] | None = None,
|
||||
plugin_manager=None,
|
||||
keep_temporary_files: bool = None,
|
||||
progress_bar: bool = None,
|
||||
keep_temporary_files: bool | None = None,
|
||||
progress_bar: bool | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Run OCRmyPDF on one PDF or image.
|
||||
|
||||
For most arguments, see documentation for the equivalent command line parameter.
|
||||
|
||||
This API takes a threading lock, because OCRmyPDF uses global state in particular
|
||||
for the plugin system. The jobs parameter will be used to create a pool of
|
||||
worker threads or processes at different times, subject to change. A Python
|
||||
process can only run one OCRmyPDF task at a time.
|
||||
|
||||
To run parallelize instances OCRmyPDF, use separate Python processes to scale
|
||||
horizontally. Generally speaking you should set jobs=sqrt(cpu_count) and run
|
||||
sqrt(cpu_count) processes as a starting point. If you have files with a high page
|
||||
count, run fewer processes and more jobs per process. If you have a lot of short
|
||||
files, run more processes and fewer jobs per process.
|
||||
|
||||
A few specific arguments are discussed here:
|
||||
|
||||
Args:
|
||||
@@ -283,9 +291,8 @@ def ocr( # pylint: disable=unused-argument
|
||||
When a stream is used as output, whether via a writable object or
|
||||
``"-"``, some final validation steps are not performed (we do not read
|
||||
back the stream after it is written).
|
||||
|
||||
Raises:
|
||||
ocrmypdf.PdfMergeFailedError: If the input PDF is malformed, preventing merging
|
||||
with the OCR layer.
|
||||
ocrmypdf.MissingDependencyError: If a required dependency program is missing or
|
||||
was not found on PATH.
|
||||
ocrmypdf.UnsupportedImageFormatError: If the input file type was an image that
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
from __future__ import annotations
|
||||
"""Plugins in this package are automatically loaded by ocrmypdf."""
|
||||
|
||||
# This file exists only mark builtin_plugins as a package.
|
||||
# The plugin manager will not load it, so anything defined here may not be
|
||||
# processed as a module.
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
from __future__ import annotations
|
||||
|
||||
"""OCRmyPDF's multiprocessing/multithreading abstraction layer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import multiprocessing
|
||||
@@ -30,7 +30,7 @@ WorkerInit = Callable[[Queue, UserInit, int], None]
|
||||
|
||||
|
||||
def log_listener(q: Queue):
|
||||
"""Listen to the worker processes and forward the messages to logging
|
||||
"""Listen to the worker processes and forward the messages to logging.
|
||||
|
||||
For simplicity this is a thread rather than a process. Only one process
|
||||
should actually write to sys.stderr or whatever we're using, so if this is
|
||||
@@ -39,7 +39,6 @@ def log_listener(q: Queue):
|
||||
See:
|
||||
https://docs.python.org/3/howto/logging-cookbook.html#logging-to-a-single-file-from-multiple-processes
|
||||
"""
|
||||
|
||||
while True:
|
||||
try:
|
||||
record = q.get()
|
||||
@@ -55,12 +54,12 @@ def log_listener(q: Queue):
|
||||
|
||||
|
||||
def process_sigbus(*args):
|
||||
"""Handle SIGBUS signal at the worker level."""
|
||||
raise InputFileError("A worker process lost access to an input file")
|
||||
|
||||
|
||||
def process_init(q: Queue, user_init: UserInit, loglevel) -> None:
|
||||
"""Initialize a process pool worker"""
|
||||
|
||||
"""Initialize a process pool worker."""
|
||||
# Ignore SIGINT (our parent process will kill us gracefully)
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||
|
||||
@@ -82,6 +81,7 @@ def process_init(q: Queue, user_init: UserInit, loglevel) -> None:
|
||||
|
||||
|
||||
def thread_init(q: Queue, user_init: UserInit, loglevel) -> None:
|
||||
"""Begin a thread pool worker."""
|
||||
del q # unused but required argument
|
||||
del loglevel # unused but required argument
|
||||
# As a thread, block SIGBUS so the main thread deals with it...
|
||||
@@ -164,14 +164,17 @@ class StandardExecutor(Executor):
|
||||
|
||||
@hookimpl
|
||||
def get_executor(progressbar_class):
|
||||
"""Return the default executor."""
|
||||
return StandardExecutor(pbar_class=progressbar_class)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def get_progressbar_class():
|
||||
"""Return the default progress bar class."""
|
||||
return tqdm
|
||||
|
||||
|
||||
@hookimpl
|
||||
def get_logging_console():
|
||||
"""Return the default logging console handler."""
|
||||
return logging.StreamHandler(stream=TqdmConsole(sys.stderr))
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
|
||||
from ocrmypdf import hookimpl
|
||||
from ocrmypdf._exec import ghostscript
|
||||
from ocrmypdf._validation import HOCR_OK_LANGS
|
||||
from ocrmypdf.exceptions import MissingDependencyError
|
||||
from ocrmypdf.subprocess import check_external_program
|
||||
|
||||
@@ -17,41 +16,24 @@ log = logging.getLogger(__name__)
|
||||
|
||||
@hookimpl
|
||||
def check_options(options):
|
||||
"""Check that the options are valid for this plugin."""
|
||||
check_external_program(
|
||||
program='gs',
|
||||
package='ghostscript',
|
||||
version_checker=ghostscript.version,
|
||||
need_version='9.15', # limited by Travis CI / Ubuntu 14.04 backports
|
||||
need_version='9.50', # Ubuntu 20.04's version
|
||||
)
|
||||
gs_version = ghostscript.version()
|
||||
if gs_version in ('9.24', '9.51'):
|
||||
if gs_version in ('9.51',):
|
||||
raise MissingDependencyError(
|
||||
f"Ghostscript {gs_version} contains serious regressions and is not "
|
||||
"supported. Please upgrade to a newer version, or downgrade to the "
|
||||
"previous version."
|
||||
)
|
||||
|
||||
# We have these constraints to check for.
|
||||
# 1. Ghostscript < 9.20 mangles multibyte Unicode
|
||||
# 2. hocr doesn't work on non-Latin languages (so don't select it)
|
||||
is_latin = options.languages.issubset(HOCR_OK_LANGS)
|
||||
if gs_version < '9.20' and options.output_type != 'pdf' and not is_latin:
|
||||
# https://bugs.ghostscript.com/show_bug.cgi?id=696874
|
||||
# Ghostscript < 9.20 fails to encode multibyte characters properly
|
||||
log.warning(
|
||||
f"The installed version of Ghostscript ({gs_version}) does not work "
|
||||
"correctly with the OCR languages you specified. Use --output-type pdf or "
|
||||
"upgrade to Ghostscript 9.20 or later to avoid this issue."
|
||||
)
|
||||
|
||||
if options.output_type == 'pdfa':
|
||||
options.output_type = 'pdfa-2'
|
||||
|
||||
if options.output_type == 'pdfa-3' and ghostscript.version() < '9.19':
|
||||
raise MissingDependencyError(
|
||||
"--output-type pdfa-3 requires Ghostscript 9.19 or later"
|
||||
)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def rasterize_pdf_page(
|
||||
@@ -63,7 +45,9 @@ def rasterize_pdf_page(
|
||||
page_dpi,
|
||||
rotation,
|
||||
filter_vector,
|
||||
stop_on_soft_error,
|
||||
):
|
||||
"""Rasterize a single page of a PDF file using Ghostscript."""
|
||||
ghostscript.rasterize_pdf(
|
||||
input_file,
|
||||
output_file,
|
||||
@@ -73,6 +57,7 @@ def rasterize_pdf_page(
|
||||
page_dpi=page_dpi,
|
||||
rotation=rotation,
|
||||
filter_vector=filter_vector,
|
||||
stop_on_error=stop_on_soft_error,
|
||||
)
|
||||
return output_file
|
||||
|
||||
@@ -86,7 +71,9 @@ def generate_pdfa(
|
||||
pdf_version,
|
||||
pdfa_part,
|
||||
progressbar_class,
|
||||
stop_on_soft_error,
|
||||
):
|
||||
"""Generate a PDF/A from the list of PDF pages and PDF/A metadata."""
|
||||
ghostscript.generate_pdfa(
|
||||
pdf_pages=[*pdf_pages, pdfmark],
|
||||
output_file=output_file,
|
||||
@@ -94,5 +81,6 @@ def generate_pdfa(
|
||||
pdf_version=pdf_version,
|
||||
pdfa_part=pdfa_part,
|
||||
progressbar_class=progressbar_class,
|
||||
stop_on_error=stop_on_soft_error,
|
||||
)
|
||||
return output_file
|
||||
|
||||
@@ -8,10 +8,14 @@ from __future__ import annotations
|
||||
import logging
|
||||
import os
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from ocrmypdf import hookimpl
|
||||
from ocrmypdf._exec import tesseract
|
||||
from ocrmypdf._jobcontext import PageContext
|
||||
from ocrmypdf.cli import numeric, str_to_int
|
||||
from ocrmypdf.helpers import clamp
|
||||
from ocrmypdf.imageops import calculate_downsample, downsample_image
|
||||
from ocrmypdf.pluginspec import OcrEngine
|
||||
from ocrmypdf.subprocess import check_external_program
|
||||
|
||||
@@ -43,7 +47,7 @@ def add_options(parser):
|
||||
metavar='MODE',
|
||||
choices=range(0, 4),
|
||||
help=(
|
||||
"Set Tesseract 4.0+ OCR engine mode: "
|
||||
"Set Tesseract 4+ OCR engine mode: "
|
||||
"0 - original Tesseract only; "
|
||||
"1 - neural nets LSTM only; "
|
||||
"2 - Tesseract + LSTM; "
|
||||
@@ -58,7 +62,7 @@ def add_options(parser):
|
||||
metavar='METHOD',
|
||||
help=(
|
||||
"Set Tesseract 5.0+ input image thresholding mode. This may improve OCR "
|
||||
"results on low quality images or those that contain high constrast color. "
|
||||
"results on low quality images or those that contain high contrast color. "
|
||||
"legacy-otsu is the Tesseract default; adaptive-otsu is an improved Otsu "
|
||||
"algorithm with improved sort for background color changes; sauvola is "
|
||||
"based on local standard deviation."
|
||||
@@ -69,8 +73,48 @@ def add_options(parser):
|
||||
default=180.0,
|
||||
type=numeric(float, 0),
|
||||
metavar='SECONDS',
|
||||
help='Give up on OCR after the timeout, but copy the preprocessed page '
|
||||
'into the final output',
|
||||
help=(
|
||||
"Give up on OCR after the timeout, but copy the preprocessed page "
|
||||
"into the final output."
|
||||
),
|
||||
)
|
||||
tess.add_argument(
|
||||
'--tesseract-non-ocr-timeout',
|
||||
default=180.0,
|
||||
type=numeric(float, 0),
|
||||
metavar='SECONDS',
|
||||
help=(
|
||||
"Give up on non-OCR operations such as deskewing and orientation "
|
||||
"after timeout. This is a separate timeout from --tesseract-timeout "
|
||||
"because these operations are not as expensive as OCR."
|
||||
),
|
||||
)
|
||||
tess.add_argument(
|
||||
'--tesseract-downsample-large-images',
|
||||
action='store_true',
|
||||
help=(
|
||||
"Downsample large images before OCR. Tesseract has an upper limit on the "
|
||||
"size images it will support. If this argument is given, OCRmyPDF will "
|
||||
"downsample large images to fit Tesseract. This may reduce OCR quality, "
|
||||
"on large images the most desirable text is usually larger. If this "
|
||||
"parameter is not supplied, Tesseract will error out and produce no OCR "
|
||||
"on the page in question. This argument should be used with a high value "
|
||||
"of --tesseract-timeout to ensure Tesseract has enough to time."
|
||||
),
|
||||
)
|
||||
tess.add_argument(
|
||||
'--tesseract-downsample-above',
|
||||
action='store',
|
||||
type=numeric(int, 100, 32767),
|
||||
default=32767,
|
||||
help=(
|
||||
"Downsample images larger than this size pixel size in either dimension "
|
||||
"before OCR. --tesseract-downsample-large-images downsamples only when "
|
||||
"an image exceeds Tesseract's internal limits. This argument causes "
|
||||
"downsampling to occur when an image exceeds the given size. This may "
|
||||
"reduce OCR quality, but on large images the most desirable text is "
|
||||
"usually larger."
|
||||
),
|
||||
)
|
||||
tess.add_argument(
|
||||
'--user-words',
|
||||
@@ -93,7 +137,7 @@ def check_options(options):
|
||||
program='tesseract',
|
||||
package={'linux': 'tesseract-ocr'},
|
||||
version_checker=tesseract.version,
|
||||
need_version='4.0.0-beta.1', # using backport for Travis CI
|
||||
need_version='4.1.1', # Ubuntu 20.04 version
|
||||
version_parser=tesseract.TesseractVersion,
|
||||
)
|
||||
|
||||
@@ -101,11 +145,6 @@ def check_options(options):
|
||||
if options.pdf_renderer == 'auto':
|
||||
options.pdf_renderer = 'sandwich'
|
||||
|
||||
if not tesseract.has_user_words() and (options.user_words or options.user_patterns):
|
||||
log.warning(
|
||||
"Tesseract 4.0 (which you have installed) ignores --user-words and "
|
||||
"--user-patterns, so these arguments have no effect."
|
||||
)
|
||||
if not tesseract.has_thresholding() and options.tesseract_thresholding != 0:
|
||||
log.warning(
|
||||
"The installed version of Tesseract does not support changes to its "
|
||||
@@ -137,6 +176,25 @@ def validate(pdfinfo, options):
|
||||
log.debug("Using Tesseract OpenMP thread limit %d", tess_threads)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
|
||||
"""Filter the image before OCR.
|
||||
|
||||
Tesseract cannot handle images with more than 32767 pixels in either axis,
|
||||
or more than 2**31 bytes. This function resizes the image to fit within
|
||||
those limits.
|
||||
"""
|
||||
threshold = min(page.options.tesseract_downsample_above, 32767)
|
||||
|
||||
options = page.options
|
||||
if options.tesseract_downsample_large_images:
|
||||
size = calculate_downsample(
|
||||
image, max_size=(threshold, threshold), max_bytes=(2**31) - 1
|
||||
)
|
||||
image = downsample_image(image, size)
|
||||
return image
|
||||
|
||||
|
||||
class TesseractOcrEngine(OcrEngine):
|
||||
"""Implements OCR with Tesseract."""
|
||||
|
||||
@@ -161,7 +219,7 @@ class TesseractOcrEngine(OcrEngine):
|
||||
return tesseract.get_orientation(
|
||||
input_file,
|
||||
engine_mode=options.tesseract_oem,
|
||||
timeout=options.tesseract_timeout,
|
||||
timeout=options.tesseract_non_ocr_timeout,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -170,7 +228,7 @@ class TesseractOcrEngine(OcrEngine):
|
||||
input_file,
|
||||
languages=options.languages,
|
||||
engine_mode=options.tesseract_oem,
|
||||
timeout=options.tesseract_timeout,
|
||||
timeout=options.tesseract_non_ocr_timeout,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
+24
-5
@@ -15,7 +15,7 @@ T = TypeVar('T', int, float)
|
||||
|
||||
|
||||
def numeric(basetype: Callable[[Any], T], min_: T | None = None, max_: T | None = None):
|
||||
"""Validator for numeric params"""
|
||||
"""Validator for numeric params."""
|
||||
min_ = basetype(min_) if min_ is not None else None
|
||||
max_ = basetype(max_) if max_ is not None else None
|
||||
|
||||
@@ -46,7 +46,7 @@ def str_to_int(mapping: Mapping[str, int]):
|
||||
|
||||
|
||||
class ArgumentParser(argparse.ArgumentParser):
|
||||
"""Override parser's default behavior of calling sys.exit()
|
||||
"""Override parser's default behavior of calling sys.exit().
|
||||
|
||||
https://stackoverflow.com/questions/5943249/python-argparse-and-controlling-overriding-the-exit-status-code
|
||||
|
||||
@@ -57,13 +57,21 @@ class ArgumentParser(argparse.ArgumentParser):
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the parser."""
|
||||
super().__init__(*args, **kwargs)
|
||||
self._api_mode = False
|
||||
|
||||
def enable_api_mode(self):
|
||||
"""Enable API mode.
|
||||
|
||||
When set, the parser will not call sys.exit() on error. OCRmyPDF was originally
|
||||
a command line program, but now it has an API. The API works by synthesizing
|
||||
command line arguments.
|
||||
"""
|
||||
self._api_mode = True
|
||||
|
||||
def error(self, message):
|
||||
"""Override the default argparse error behavior."""
|
||||
if not self._api_mode:
|
||||
super().error(message)
|
||||
return
|
||||
@@ -74,19 +82,22 @@ class LanguageSetAction(argparse.Action):
|
||||
"""Manages a list of languages."""
|
||||
|
||||
def __init__(self, option_strings, dest, default=None, **kwargs):
|
||||
"""Initialize the action."""
|
||||
if default is None:
|
||||
default = set()
|
||||
default = list()
|
||||
super().__init__(option_strings, dest, default=default, **kwargs)
|
||||
|
||||
def __call__(self, parser, namespace, values, option_string=None):
|
||||
"""Add a language to the set."""
|
||||
dest = getattr(namespace, self.dest)
|
||||
if '+' in values:
|
||||
dest.update(lang for lang in values.split('+'))
|
||||
[dest.append(lang) for lang in values.split('+')]
|
||||
else:
|
||||
dest.add(values)
|
||||
dest.append(values)
|
||||
|
||||
|
||||
def get_parser():
|
||||
"""Get the main CLI parser."""
|
||||
parser = ArgumentParser(
|
||||
prog=_PROGRAM_NAME,
|
||||
allow_abbrev=True,
|
||||
@@ -409,6 +420,14 @@ Online documentation is located at:
|
||||
"which do not benefit. If the threshold is 0 it will be apply to all files. "
|
||||
"Set the threshold very high to disable.",
|
||||
)
|
||||
advanced.add_argument(
|
||||
'--continue-on-soft-render-error',
|
||||
action='store_true',
|
||||
help="Continue processing pages after a recoverable PDF rendering error. "
|
||||
"A recoverable error is one that does not prevent the page from being "
|
||||
"rendered, but may result in visual differences compared to the input "
|
||||
"file. Missing fonts are a typical source of these errors.",
|
||||
)
|
||||
advanced.add_argument(
|
||||
'--plugin',
|
||||
dest='plugins',
|
||||
|
||||
@@ -35,6 +35,7 @@ class ExitCodeException(Exception):
|
||||
message = ""
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the exception."""
|
||||
super_msg = super().__str__() # Don't do str(super())
|
||||
if self.message:
|
||||
return self.message.format(super_msg)
|
||||
@@ -47,26 +48,6 @@ class BadArgsError(ExitCodeException):
|
||||
exit_code = ExitCode.bad_args
|
||||
|
||||
|
||||
class PdfMergeFailedError(ExitCodeException): # deprecated
|
||||
"""An intermediate PDF can't be merged.
|
||||
|
||||
No longer in use.
|
||||
"""
|
||||
|
||||
exit_code = ExitCode.input_file
|
||||
message = dedent(
|
||||
'''\
|
||||
Failed to merge PDF image layer with OCR layer
|
||||
|
||||
Usually this happens because the input PDF file is malformed and
|
||||
ocrmypdf cannot correct the problem on its own.
|
||||
|
||||
Try using
|
||||
ocrmypdf --pdf-renderer sandwich [..other args..]
|
||||
'''
|
||||
)
|
||||
|
||||
|
||||
class MissingDependencyError(ExitCodeException):
|
||||
"""A third-party dependency is missing."""
|
||||
|
||||
@@ -114,7 +95,7 @@ class EncryptedPdfError(ExitCodeException):
|
||||
|
||||
exit_code = ExitCode.encrypted_pdf
|
||||
message = dedent(
|
||||
'''\
|
||||
"""\
|
||||
Input PDF is encrypted. The encryption must be removed to
|
||||
perform OCR.
|
||||
|
||||
@@ -123,7 +104,7 @@ class EncryptedPdfError(ExitCodeException):
|
||||
|
||||
You can remove the encryption using
|
||||
qpdf --decrypt [--password=[password]] infilename
|
||||
'''
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
#
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""Extra plugins. These are not automatically inserted when ocrmypdf is run.
|
||||
|
||||
You can use these plugins by specifying them on the command line, e.g.:
|
||||
ocrmypdf --plugin ocrmypdf.extra_plugins.semfree ...
|
||||
"""
|
||||
|
||||
@@ -54,6 +54,7 @@ def split_every(n: int, iterable: Iterable) -> Iterator:
|
||||
|
||||
|
||||
def process_sigbus(*args):
|
||||
"""Handle SIGBUS signal at the worker level."""
|
||||
raise InputFileError("A worker process lost access to an input file")
|
||||
|
||||
|
||||
@@ -61,20 +62,21 @@ class ConnectionLogHandler(logging.handlers.QueueHandler):
|
||||
"""Handler used by child processes to forward log messages to parent."""
|
||||
|
||||
def __init__(self, conn: Connection) -> None:
|
||||
"""Initialize the handler."""
|
||||
# sets the parent's queue to None - parent only touches queue
|
||||
# in enqueue() which we override
|
||||
super().__init__(None) # type: ignore
|
||||
self.conn = conn
|
||||
|
||||
def enqueue(self, record):
|
||||
"""Enqueue a log message."""
|
||||
self.conn.send(('log', record))
|
||||
|
||||
|
||||
def process_loop(
|
||||
conn: Connection, user_init: Callable[[], None], loglevel, task, task_args
|
||||
):
|
||||
"""Initialize a process pool worker"""
|
||||
|
||||
"""Initialize a process pool worker."""
|
||||
# Install SIGBUS handler (so our parent process can abort somewhat gracefully)
|
||||
with suppress(AttributeError): # Windows and Cygwin do not have SIGBUS
|
||||
# Windows and Cygwin do not have pthread_sigmask or SIGBUS
|
||||
@@ -166,8 +168,7 @@ class LambdaExecutor(Executor):
|
||||
continue
|
||||
|
||||
if msg_type == MessageType.result:
|
||||
if task_finished:
|
||||
task_finished(msg, pbar)
|
||||
task_finished(msg, pbar)
|
||||
elif msg_type == 'log':
|
||||
record = msg
|
||||
logger = logging.getLogger(record.name)
|
||||
@@ -185,14 +186,20 @@ class LambdaExecutor(Executor):
|
||||
|
||||
@hookimpl
|
||||
def get_executor(progressbar_class):
|
||||
"""Return a LambdaExecutor instance."""
|
||||
return LambdaExecutor(pbar_class=progressbar_class)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def get_logging_console():
|
||||
"""Return a logging.StreamHandler instance."""
|
||||
return logging.StreamHandler()
|
||||
|
||||
|
||||
@hookimpl
|
||||
def get_progressbar_class():
|
||||
"""Return a NullProgressBar instance.
|
||||
|
||||
This executor cannot use a progress bar.
|
||||
"""
|
||||
return NullProgressBar
|
||||
|
||||
+51
-31
@@ -10,14 +10,12 @@ import multiprocessing
|
||||
import os
|
||||
import shutil
|
||||
import warnings
|
||||
from collections import namedtuple
|
||||
from collections.abc import Iterable
|
||||
from contextlib import suppress
|
||||
from functools import wraps
|
||||
from io import StringIO
|
||||
from math import isclose, isfinite
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
from typing import Any, Generic, Sequence, SupportsFloat, SupportsRound, TypeVar
|
||||
|
||||
import img2pdf
|
||||
import pikepdf
|
||||
@@ -35,23 +33,36 @@ else:
|
||||
)
|
||||
|
||||
|
||||
class Resolution(namedtuple('Resolution', ('x', 'y'))):
|
||||
T = TypeVar('T', bound=SupportsRound[Any])
|
||||
|
||||
|
||||
class Resolution(Generic[T]):
|
||||
"""The number of pixels per inch in each 2D direction.
|
||||
|
||||
Resolution objects are considered "equal" for == purposes if they are
|
||||
equal to a reasonable tolerance.
|
||||
"""
|
||||
|
||||
__slots__ = ()
|
||||
x: T
|
||||
y: T
|
||||
|
||||
__slots__ = ('x', 'y')
|
||||
|
||||
def __init__(self, x: T, y: T):
|
||||
"""Construct a Resolution object."""
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
# rel_tol after converting from dpi to pixels per meter and saving
|
||||
# as integer with rounding, as many file formats
|
||||
CONVERSION_ERROR = 0.002
|
||||
|
||||
def round(self, ndigits: int):
|
||||
def round(self, ndigits: int) -> Resolution:
|
||||
"""Round to ndigits after the decimal point."""
|
||||
return Resolution(round(self.x, ndigits), round(self.y, ndigits))
|
||||
|
||||
def to_int(self):
|
||||
def to_int(self) -> Resolution[int]:
|
||||
"""Round to nearest integer."""
|
||||
return Resolution(int(round(self.x)), int(round(self.y)))
|
||||
|
||||
@classmethod
|
||||
@@ -60,13 +71,20 @@ class Resolution(namedtuple('Resolution', ('x', 'y'))):
|
||||
|
||||
@property
|
||||
def is_square(self) -> bool:
|
||||
"""True if the resolution is square (x == y)."""
|
||||
return self._isclose(self.x, self.y)
|
||||
|
||||
@property
|
||||
def is_finite(self) -> bool:
|
||||
return isfinite(self.x) and isfinite(self.y)
|
||||
"""True if both x and y are finite numbers."""
|
||||
if isinstance(self.x, SupportsFloat) and isinstance(self.y, SupportsFloat):
|
||||
return isfinite(self.x) and isfinite(self.y)
|
||||
return True
|
||||
|
||||
def take_max(self, vals, yvals=None):
|
||||
def take_max(
|
||||
self, vals: Iterable[Any], yvals: Iterable[Any] | None = None
|
||||
) -> Resolution:
|
||||
"""Return a new Resolution object with the maximum resolution of inputs."""
|
||||
if yvals is not None:
|
||||
return Resolution(max(self.x, *vals), max(self.y, *yvals))
|
||||
max_x, max_y = self.x, self.y
|
||||
@@ -75,16 +93,24 @@ class Resolution(namedtuple('Resolution', ('x', 'y'))):
|
||||
max_y = max(y, max_y)
|
||||
return Resolution(max_x, max_y)
|
||||
|
||||
def flip_axis(self):
|
||||
def flip_axis(self) -> Resolution[T]:
|
||||
"""Return a new Resolution object with x and y swapped."""
|
||||
return Resolution(self.y, self.x)
|
||||
|
||||
def __getitem__(self, idx: int | slice) -> T:
|
||||
"""Support [0] and [1] indexing."""
|
||||
return (self.x, self.y)[idx]
|
||||
|
||||
def __str__(self):
|
||||
"""Return a string representation of the resolution."""
|
||||
return f"{self.x:f}x{self.y:f}"
|
||||
|
||||
def __repr__(self): # pragma: no cover
|
||||
"""Return a repr() of the resolution."""
|
||||
return f"Resolution({self.x}x{self.y} dpi)"
|
||||
|
||||
def __eq__(self, other):
|
||||
"""Return True if the resolution is equal to another resolution."""
|
||||
if isinstance(other, tuple) and len(other) == 2:
|
||||
other = Resolution(*other)
|
||||
if not isinstance(other, Resolution):
|
||||
@@ -93,7 +119,7 @@ class Resolution(namedtuple('Resolution', ('x', 'y'))):
|
||||
|
||||
|
||||
class NeverRaise(Exception):
|
||||
"""An exception that is never raised"""
|
||||
"""An exception that is never raised."""
|
||||
|
||||
|
||||
def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike):
|
||||
@@ -138,6 +164,10 @@ def safe_symlink(input_file: os.PathLike, soft_link_name: os.PathLike):
|
||||
|
||||
|
||||
def samefile(file1: os.PathLike, file2: os.PathLike):
|
||||
"""Return True if two files are the same file.
|
||||
|
||||
Attempts to account for different relative paths to the same file.
|
||||
"""
|
||||
if os.name == 'nt':
|
||||
return file1 == file2
|
||||
else:
|
||||
@@ -155,7 +185,7 @@ def monotonic(seq: Sequence) -> bool:
|
||||
|
||||
|
||||
def page_number(input_file: os.PathLike) -> int:
|
||||
"""Get one-based page number implied by filename (000002.pdf -> 2)"""
|
||||
"""Get one-based page number implied by filename (000002.pdf -> 2)."""
|
||||
return int(os.path.basename(os.fspath(input_file))[0:6])
|
||||
|
||||
|
||||
@@ -184,7 +214,7 @@ def is_file_writable(test_file: os.PathLike) -> bool:
|
||||
p = p.resolve(strict=False)
|
||||
|
||||
# p.is_file() throws an exception in some cases
|
||||
if p.exists() and p.is_file():
|
||||
if p.exists() and (p.is_file() or p.samefile(os.devnull)):
|
||||
return os.access(
|
||||
os.fspath(p),
|
||||
os.W_OK,
|
||||
@@ -258,13 +288,20 @@ def clamp(n, smallest, largest): # mypy doesn't understand types for this
|
||||
|
||||
|
||||
def remove_all_log_handlers(logger):
|
||||
"Remove all log handlers, usually used in a child process."
|
||||
"""Remove all log handlers, usually used in a child process.
|
||||
|
||||
The child process inherits the log handlers from the parent process when
|
||||
a fork occurs. Typically we want to remove all log handlers in the child
|
||||
process so that the child process can set up a single queue handler to
|
||||
forward log messages to the parent process.
|
||||
"""
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
handler.close() # To ensure handlers with opened resources are released
|
||||
|
||||
|
||||
def pikepdf_enable_mmap():
|
||||
"""Enable pikepdf mmap."""
|
||||
# try:
|
||||
# if pikepdf._qpdf.set_access_default_mmap(True):
|
||||
# log.debug("pikepdf mmap enabled")
|
||||
@@ -275,20 +312,3 @@ def pikepdf_enable_mmap():
|
||||
# Fix is not in pybind11 2.6.0
|
||||
# log.debug("pikepdf mmap disabled")
|
||||
return
|
||||
|
||||
|
||||
def deprecated(func):
|
||||
"""Warn that function is deprecated."""
|
||||
|
||||
@wraps(func)
|
||||
def new_func(*args, **kwargs):
|
||||
warnings.simplefilter('always', DeprecationWarning) # turn off filter
|
||||
warnings.warn(
|
||||
f"Call to deprecated function {func.__name__}.",
|
||||
category=DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
warnings.simplefilter('default', DeprecationWarning) # reset filter
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return new_func
|
||||
|
||||
@@ -14,10 +14,11 @@ import re
|
||||
import warnings
|
||||
from math import atan, cos, sin
|
||||
from pathlib import Path
|
||||
from typing import Any, NamedTuple, Optional, Tuple, Union
|
||||
from typing import Any, NamedTuple
|
||||
from xml.etree import ElementTree
|
||||
|
||||
with warnings.catch_warnings():
|
||||
# reportlab uses deprecated load_module
|
||||
warnings.filterwarnings(
|
||||
'ignore', category=DeprecationWarning, message=r".*load_module.*"
|
||||
)
|
||||
@@ -98,11 +99,10 @@ class HocrTransformError(Exception):
|
||||
|
||||
|
||||
class HocrTransform:
|
||||
"""A class for converting documents from the hOCR format.
|
||||
|
||||
"""
|
||||
A class for converting documents from the hOCR format.
|
||||
For details of the hOCR format, see:
|
||||
http://kba.cloud/hocr-spec/
|
||||
http://kba.cloud/hocr-spec/.
|
||||
"""
|
||||
|
||||
box_pattern = re.compile(r'bbox((\s+\d+){4})')
|
||||
@@ -118,6 +118,7 @@ class HocrTransform:
|
||||
)
|
||||
|
||||
def __init__(self, *, hocr_filename: str | Path, dpi: float):
|
||||
"""Initialize the HocrTransform object."""
|
||||
self.dpi = dpi
|
||||
self.hocr = ElementTree.parse(os.fspath(hocr_filename))
|
||||
|
||||
@@ -142,9 +143,7 @@ class HocrTransform:
|
||||
raise HocrTransformError("hocr file is missing page dimensions")
|
||||
|
||||
def __str__(self): # pragma: no cover
|
||||
"""
|
||||
Return the textual content of the HTML body
|
||||
"""
|
||||
"""Return the textual content of the HTML body."""
|
||||
if self.hocr is None:
|
||||
return ''
|
||||
body = self.hocr.find(self._child_xpath('body'))
|
||||
@@ -154,9 +153,7 @@ class HocrTransform:
|
||||
return ''
|
||||
|
||||
def _get_element_text(self, element: Element):
|
||||
"""
|
||||
Return the textual content of the element and its children
|
||||
"""
|
||||
"""Return the textual content of the element and its children."""
|
||||
text = ''
|
||||
if element.text is not None:
|
||||
text += element.text
|
||||
@@ -168,10 +165,7 @@ class HocrTransform:
|
||||
|
||||
@classmethod
|
||||
def element_coordinates(cls, element: Element) -> Rect:
|
||||
"""
|
||||
Returns a tuple containing the coordinates of the bounding box around
|
||||
an element
|
||||
"""
|
||||
"""Get coordinates of the bounding box around an element."""
|
||||
out = Rect._make(0 for _ in range(4))
|
||||
if 'title' in element.attrib:
|
||||
matches = cls.box_pattern.search(element.attrib['title'])
|
||||
@@ -182,9 +176,7 @@ class HocrTransform:
|
||||
|
||||
@classmethod
|
||||
def baseline(cls, element: Element) -> tuple[float, float]:
|
||||
"""
|
||||
Returns a tuple containing the baseline slope and intercept.
|
||||
"""
|
||||
"""Get baseline's slope and intercept."""
|
||||
if 'title' in element.attrib:
|
||||
matches = cls.baseline_pattern.search(element.attrib['title'])
|
||||
if matches:
|
||||
@@ -192,9 +184,7 @@ class HocrTransform:
|
||||
return (0.0, 0.0)
|
||||
|
||||
def pt_from_pixel(self, pxl) -> Rect:
|
||||
"""
|
||||
Returns the quantity in PDF units (pt) given quantity in pixels
|
||||
"""
|
||||
"""Returns the quantity in PDF units (pt) given quantity in pixels."""
|
||||
return Rect._make((c / self.dpi * inch) for c in pxl)
|
||||
|
||||
def _child_xpath(self, html_tag: str, html_class: str | None = None) -> str:
|
||||
@@ -205,21 +195,9 @@ class HocrTransform:
|
||||
|
||||
@classmethod
|
||||
def replace_unsupported_chars(cls, s: str) -> str:
|
||||
"""
|
||||
Given an input string, returns the corresponding string that:
|
||||
* is available in the Helvetica facetype
|
||||
* does not contain any ligature (to allow easy search in the PDF file)
|
||||
"""
|
||||
"""Replaces characters with those available in the Helvetica typeface."""
|
||||
return s.translate(cls.ligatures)
|
||||
|
||||
def topdown_position(self, element):
|
||||
pxl_line_coords = self.element_coordinates(element)
|
||||
line_box = self.pt_from_pixel(pxl_line_coords)
|
||||
# Coordinates here are still in the hocr coordinate system, so 0 on the y axis
|
||||
# is the top of the page and increasing values of y will move towards the
|
||||
# bottom of the page.
|
||||
return line_box.y2
|
||||
|
||||
def to_pdf(
|
||||
self,
|
||||
*,
|
||||
@@ -230,8 +208,8 @@ class HocrTransform:
|
||||
invisible_text: bool = False,
|
||||
interword_spaces: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Creates a PDF file with an image superimposed on top of the text.
|
||||
"""Creates a PDF file with an image superimposed on top of the text.
|
||||
|
||||
Text is positioned according to the bounding box of the lines in
|
||||
the hOCR file.
|
||||
The image need not be identical to the image used to create the hOCR
|
||||
@@ -322,6 +300,7 @@ class HocrTransform:
|
||||
|
||||
@classmethod
|
||||
def polyval(cls, poly, x): # pragma: no cover
|
||||
"""Calculate the value of a polynomial at a point."""
|
||||
return x * poly[0] + poly[1]
|
||||
|
||||
def _do_line(
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
# SPDX-FileCopyrightText: 2023 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""OCR-related image manipulation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from functools import singledispatch
|
||||
from math import floor, sqrt
|
||||
from typing import Optional, Tuple
|
||||
|
||||
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
|
||||
# does not support annotations. Disable check about using old-style typing
|
||||
# until Python 3.10, OR when drop singledispatch in ocrmypdf 15.
|
||||
# ruff: noqa: UP006
|
||||
# ruff: noqa: UP007
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def bytes_per_pixel(mode: str) -> int:
|
||||
"""Return the number of padded bytes per pixel for a given PIL image mode.
|
||||
|
||||
In RGB mode we assume 4 bytes per pixel, which is the case for most
|
||||
consumers.
|
||||
"""
|
||||
if mode in ('1', 'L', 'P'):
|
||||
return 1
|
||||
if mode in ('LA', 'PA', 'La') or mode.startswith('I;16'):
|
||||
return 2
|
||||
return 4
|
||||
|
||||
|
||||
@singledispatch
|
||||
def calculate_downsample(
|
||||
image_size: Tuple[int, int],
|
||||
bytes_per_pixel: int,
|
||||
*,
|
||||
max_size: Optional[Tuple[int, int]] = None,
|
||||
max_pixels: Optional[int] = None,
|
||||
max_bytes: Optional[int] = None,
|
||||
) -> Tuple[int, int]:
|
||||
"""Calculate image size required to downsample an image to fit limits.
|
||||
|
||||
If no limit is exceeded, the input image's size is returned.
|
||||
|
||||
Args:
|
||||
image_size: Dimensions of image.
|
||||
bytes_per_pixel: Number of bytes per pixel.
|
||||
max_size: The maximum width and height of the image.
|
||||
max_pixels: The maximum number of pixels in the image. Some image consumers
|
||||
limit the total number of pixels as some value other than width*height.
|
||||
max_bytes: The maximum number of bytes in the image. RGB is counted as 4
|
||||
bytes; all other modes are counted as 1 byte.
|
||||
"""
|
||||
size = image_size
|
||||
|
||||
if max_size is not None:
|
||||
overage = max_size[0] / size[0], max_size[1] / size[1]
|
||||
size_factor = min(overage)
|
||||
if size_factor < 1.0:
|
||||
log.debug("Resizing image to fit image dimensions limit")
|
||||
size = floor(size[0] * size_factor), floor(size[1] * size_factor)
|
||||
if size[0] == 0:
|
||||
size = 1, min(size[1], max_size[1])
|
||||
elif size[1] == 0:
|
||||
size = min(size[0], max_size[0]), 1
|
||||
|
||||
if max_pixels is not None:
|
||||
if size[0] * size[1] > max_pixels:
|
||||
log.debug("Resizing image to fit image pixel limit")
|
||||
pixels_factor = sqrt(max_pixels / (size[0] * size[1]))
|
||||
size = floor(size[0] * pixels_factor), floor(size[1] * pixels_factor)
|
||||
|
||||
if max_bytes is not None:
|
||||
bpp = bytes_per_pixel
|
||||
# stride = bytes per line
|
||||
stride = size[0] * bpp
|
||||
height = size[1]
|
||||
if stride * height > max_bytes:
|
||||
log.debug("Resizing image to fit image byte size limit")
|
||||
bytes_factor = sqrt(max_bytes / (stride * height))
|
||||
scaled_stride = floor(stride * bytes_factor)
|
||||
scaled_height = floor(height * bytes_factor)
|
||||
if scaled_stride == 0:
|
||||
scaled_stride = bpp
|
||||
scaled_height = min(max_bytes // bpp, scaled_height)
|
||||
if scaled_height == 0:
|
||||
scaled_height = 1
|
||||
scaled_stride = min(max_bytes // scaled_height, scaled_stride)
|
||||
size = floor(scaled_stride / bpp), scaled_height
|
||||
|
||||
return size
|
||||
|
||||
|
||||
@calculate_downsample.register
|
||||
def _(
|
||||
image: Image.Image,
|
||||
arg: None = None,
|
||||
*,
|
||||
max_size: Optional[Tuple[int, int]] = None,
|
||||
max_pixels: Optional[int] = None,
|
||||
max_bytes: Optional[int] = None,
|
||||
) -> Tuple[int, int]:
|
||||
"""Calculate image size required to downsample an image to fit limits.
|
||||
|
||||
If no limit is exceeded, the input image's size is returned.
|
||||
|
||||
Args:
|
||||
image: The image to downsample.
|
||||
max_size: The maximum width and height of the image.
|
||||
max_pixels: The maximum number of pixels in the image. Some image consumers
|
||||
limit the total number of pixels as some value other than width*height.
|
||||
max_bytes: The maximum number of bytes in the image. RGB is counted as 4
|
||||
bytes; all other modes are counted as 1 byte.
|
||||
"""
|
||||
return calculate_downsample(
|
||||
image.size,
|
||||
bytes_per_pixel(image.mode),
|
||||
max_size=max_size,
|
||||
max_pixels=max_pixels,
|
||||
max_bytes=max_bytes,
|
||||
)
|
||||
|
||||
|
||||
def downsample_image(
|
||||
image: Image.Image,
|
||||
new_size: tuple[int, int],
|
||||
*,
|
||||
resample_mode: Image.Resampling = Resampling.BICUBIC,
|
||||
reducing_gap: int = 3,
|
||||
) -> Image.Image:
|
||||
"""Downsample an image to fit within the given limits.
|
||||
|
||||
The DPI is adjusted to match the new size, which is how we can ensure the
|
||||
OCR is positioned correctly.
|
||||
|
||||
Args:
|
||||
image: The image to downsample
|
||||
new_size: The new size of the image.
|
||||
resample_mode: The resampling mode to use when downsampling.
|
||||
reducing_gap: The reducing gap to use when downsampling (for larger
|
||||
reductions).
|
||||
"""
|
||||
if new_size == image.size:
|
||||
return image
|
||||
|
||||
original_size = image.size
|
||||
original_dpi = image.info['dpi']
|
||||
image = image.resize(
|
||||
new_size,
|
||||
resample=resample_mode,
|
||||
reducing_gap=reducing_gap,
|
||||
)
|
||||
image.info['dpi'] = (
|
||||
round(original_dpi[0] * new_size[0] / original_size[0]),
|
||||
round(original_dpi[1] * new_size[1] / original_size[1]),
|
||||
)
|
||||
log.debug(f"Rescaled image to {image.size} pixels and {image.info['dpi']} dpi")
|
||||
return image
|
||||
+82
-31
@@ -53,20 +53,24 @@ class XrefExt(NamedTuple):
|
||||
|
||||
|
||||
def img_name(root: Path, xref: Xref, ext: str) -> Path:
|
||||
"""Return the name of an image file for a given xref and extension."""
|
||||
return root / f'{xref:08d}{ext}'
|
||||
|
||||
|
||||
def png_name(root: Path, xref: Xref) -> Path:
|
||||
"""Return the name of a PNG file for a given xref."""
|
||||
return img_name(root, xref, '.png')
|
||||
|
||||
|
||||
def jpg_name(root: Path, xref: Xref) -> Path:
|
||||
"""Return the name of a JPEG file for a given xref."""
|
||||
return img_name(root, xref, '.jpg')
|
||||
|
||||
|
||||
def extract_image_filter(
|
||||
pike: Pdf, root: Path, image: Stream, xref: Xref
|
||||
) -> tuple[PdfImage, tuple[Name, Object]] | None:
|
||||
"""Determine if an image is extractable."""
|
||||
del pike # unused args
|
||||
del root
|
||||
|
||||
@@ -124,6 +128,7 @@ def extract_image_filter(
|
||||
def extract_image_jbig2(
|
||||
*, pike: Pdf, root: Path, image: Stream, xref: Xref, options
|
||||
) -> XrefExt | None:
|
||||
"""Extract an image, saving it as a JBIG2 file."""
|
||||
del options # unused arg
|
||||
|
||||
result = extract_image_filter(pike, root, image, xref)
|
||||
@@ -165,6 +170,7 @@ def extract_image_jbig2(
|
||||
def extract_image_generic(
|
||||
*, pike: Pdf, root: Path, image: Stream, xref: Xref, options
|
||||
) -> XrefExt | None:
|
||||
"""Generic image extraction."""
|
||||
result = extract_image_filter(pike, root, image, xref)
|
||||
if result is None:
|
||||
return None
|
||||
@@ -224,13 +230,72 @@ def extract_image_generic(
|
||||
return None
|
||||
|
||||
|
||||
def _find_image_xrefs_container(
|
||||
pdf: Pdf,
|
||||
container: Object,
|
||||
pageno: int,
|
||||
include_xrefs: MutableSet[Xref],
|
||||
exclude_xrefs: MutableSet[Xref],
|
||||
pageno_for_xref: dict[Xref, int],
|
||||
depth: int = 0,
|
||||
):
|
||||
"""Find all image XRefs in a page or Form XObject and add to the include/exclude sets."""
|
||||
if depth > 10:
|
||||
log.warning("Recursion depth exceeded in _find_image_xrefs_page")
|
||||
return
|
||||
try:
|
||||
xobjs = container.Resources.XObject
|
||||
except AttributeError:
|
||||
return
|
||||
for _imname, image in dict(xobjs).items():
|
||||
if image.objgen[1] != 0:
|
||||
continue # Ignore images in an incremental PDF
|
||||
if Name.Subtype in image and image.Subtype == Name.Form:
|
||||
# Recurse into Form XObjects
|
||||
log.debug(f"Recursing into Form XObject {_imname} in page {pageno}")
|
||||
_find_image_xrefs_container(
|
||||
pdf,
|
||||
image,
|
||||
pageno,
|
||||
include_xrefs,
|
||||
exclude_xrefs,
|
||||
pageno_for_xref,
|
||||
depth + 1,
|
||||
)
|
||||
continue
|
||||
xref = Xref(image.objgen[0])
|
||||
if Name.SMask in image:
|
||||
# Ignore soft masks
|
||||
smask_xref = Xref(image.SMask.objgen[0])
|
||||
exclude_xrefs.add(smask_xref)
|
||||
log.debug(f"xref {smask_xref}: skipping image because it is an SMask")
|
||||
include_xrefs.add(xref)
|
||||
log.debug(f"xref {xref}: treating as an optimization candidate")
|
||||
if xref not in pageno_for_xref:
|
||||
pageno_for_xref[xref] = pageno
|
||||
|
||||
|
||||
def _find_image_xrefs(pdf: Pdf):
|
||||
include_xrefs: MutableSet[Xref] = set()
|
||||
exclude_xrefs: MutableSet[Xref] = set()
|
||||
pageno_for_xref: dict[Xref, int] = {}
|
||||
|
||||
for pageno, page in enumerate(pdf.pages):
|
||||
_find_image_xrefs_container(
|
||||
pdf, page, pageno, include_xrefs, exclude_xrefs, pageno_for_xref
|
||||
)
|
||||
|
||||
working_xrefs = include_xrefs - exclude_xrefs
|
||||
return working_xrefs, pageno_for_xref
|
||||
|
||||
|
||||
def extract_images(
|
||||
pike: Pdf,
|
||||
root: Path,
|
||||
options,
|
||||
extract_fn: Callable[..., XrefExt | None],
|
||||
) -> Iterator[tuple[int, XrefExt]]:
|
||||
"""Extract image using extract_fn
|
||||
"""Extract image using extract_fn.
|
||||
|
||||
Enumerate images on each page, lookup their xref/ID number in the PDF.
|
||||
Exclude images that are soft masks (i.e. alpha transparency related).
|
||||
@@ -244,31 +309,8 @@ def extract_images(
|
||||
it does a tuple should be returned: (xref, ext) where .ext is the file
|
||||
extension. extract_fn must also extract the file it finds interesting.
|
||||
"""
|
||||
|
||||
include_xrefs: MutableSet[Xref] = set()
|
||||
exclude_xrefs: MutableSet[Xref] = set()
|
||||
pageno_for_xref = {}
|
||||
errors = 0
|
||||
for pageno, page in enumerate(pike.pages):
|
||||
try:
|
||||
xobjs = page.Resources.XObject
|
||||
except AttributeError:
|
||||
continue
|
||||
for _imname, image in dict(xobjs).items():
|
||||
if image.objgen[1] != 0:
|
||||
continue # Ignore images in an incremental PDF
|
||||
xref = Xref(image.objgen[0])
|
||||
if Name.SMask in image:
|
||||
# Ignore soft masks
|
||||
smask_xref = Xref(image.SMask.objgen[0])
|
||||
exclude_xrefs.add(smask_xref)
|
||||
log.debug(f"xref {smask_xref}: skipping image because it is an SMask")
|
||||
include_xrefs.add(xref)
|
||||
log.debug(f"xref {xref}: treating as an optimization candidate")
|
||||
if xref not in pageno_for_xref:
|
||||
pageno_for_xref[xref] = pageno
|
||||
|
||||
working_xrefs = include_xrefs - exclude_xrefs
|
||||
working_xrefs, pageno_for_xref = _find_image_xrefs(pike)
|
||||
for xref in working_xrefs:
|
||||
image = pike.get_object((xref, 0))
|
||||
try:
|
||||
@@ -289,8 +331,7 @@ def extract_images(
|
||||
def extract_images_generic(
|
||||
pike: Pdf, root: Path, options
|
||||
) -> tuple[list[Xref], list[Xref]]:
|
||||
"""Extract any >=2bpp image we think we can improve"""
|
||||
|
||||
"""Extract any >=2bpp image we think we can improve."""
|
||||
jpegs = []
|
||||
pngs = []
|
||||
for _, xref_ext in extract_images(pike, root, options, extract_image_generic):
|
||||
@@ -304,8 +345,7 @@ def extract_images_generic(
|
||||
|
||||
|
||||
def extract_images_jbig2(pike: Pdf, root: Path, options) -> dict[int, list[XrefExt]]:
|
||||
"""Extract any bitonal image that we think we can improve as JBIG2"""
|
||||
|
||||
"""Extract any bitonal image that we think we can improve as JBIG2."""
|
||||
jbig2_groups = defaultdict(list)
|
||||
for pageno, xref_ext in extract_images(pike, root, options, extract_image_jbig2):
|
||||
group = pageno // options.jbig2_page_group_size
|
||||
@@ -318,7 +358,7 @@ def extract_images_jbig2(pike: Pdf, root: Path, options) -> dict[int, list[XrefE
|
||||
def _produce_jbig2_images(
|
||||
jbig2_groups: dict[int, list[XrefExt]], root: Path, options, executor: Executor
|
||||
) -> None:
|
||||
"""Produce JBIG2 images from their groups"""
|
||||
"""Produce JBIG2 images from their groups."""
|
||||
|
||||
def jbig2_group_args(root: Path, groups: dict[int, list[XrefExt]]):
|
||||
for group, xref_exts in groups.items():
|
||||
@@ -423,6 +463,8 @@ def _optimize_jpeg(args: tuple[Xref, Path, Path, int]) -> tuple[Xref, Path | Non
|
||||
def transcode_jpegs(
|
||||
pike: Pdf, jpegs: Sequence[Xref], root: Path, options, executor: Executor
|
||||
) -> None:
|
||||
"""Optimize JPEGs according to optimization settings."""
|
||||
|
||||
def jpeg_args() -> Iterator[tuple[Xref, Path, Path, int]]:
|
||||
for xref in jpegs:
|
||||
in_jpg = jpg_name(root, xref)
|
||||
@@ -481,6 +523,12 @@ def _deflate_jpeg(args: tuple[Pdf, threading.Lock, Xref, int]) -> tuple[Xref, by
|
||||
|
||||
|
||||
def deflate_jpegs(pike: Pdf, root: Path, options, executor: Executor) -> None:
|
||||
"""Apply FlateDecode to JPEGs.
|
||||
|
||||
This is a lossless compression method that is supported by all PDF viewers,
|
||||
and generally results in a smaller file size compared to straight DCTDecode
|
||||
images.
|
||||
"""
|
||||
jpegs = []
|
||||
for _pageno, xref_ext in extract_images(pike, root, options, _find_deflatable_jpeg):
|
||||
xref = xref_ext.xref
|
||||
@@ -568,6 +616,7 @@ def transcode_pngs(
|
||||
options,
|
||||
executor,
|
||||
) -> None:
|
||||
"""Apply lossy transcoding to PNGs."""
|
||||
modified: MutableSet[Xref] = set()
|
||||
if options.optimize >= 2:
|
||||
png_quality = (
|
||||
@@ -614,6 +663,7 @@ def optimize(
|
||||
save_settings,
|
||||
executor: Executor = DEFAULT_EXECUTOR,
|
||||
) -> Path:
|
||||
"""Optimize images in a PDF file."""
|
||||
options = context.options
|
||||
if options.optimize == 0:
|
||||
safe_symlink(input_file, output_file)
|
||||
@@ -670,11 +720,12 @@ def optimize(
|
||||
|
||||
|
||||
def main(infile, outfile, level, jobs=1):
|
||||
"""Entry point for direct optimization of a file."""
|
||||
from shutil import copy # pylint: disable=import-outside-toplevel
|
||||
from tempfile import TemporaryDirectory # pylint: disable=import-outside-toplevel
|
||||
|
||||
class OptimizeOptions:
|
||||
"""Emulate ocrmypdf's options"""
|
||||
"""Emulate ocrmypdf's options."""
|
||||
|
||||
def __init__(
|
||||
self, input_file, jobs, optimize_, jpeg_quality, png_quality, jb2lossy
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""
|
||||
Utilities for PDF/A production and confirmation with Ghostspcript.
|
||||
"""
|
||||
"""Utilities for PDF/A production and confirmation with Ghostspcript."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,8 +23,8 @@ def _postscript_objdef(
|
||||
alias: str,
|
||||
dictionary: dict[str, str],
|
||||
*,
|
||||
stream_name: str = None,
|
||||
stream_data: bytes = None,
|
||||
stream_name: str | None = None,
|
||||
stream_data: bytes | None = None,
|
||||
) -> Iterator[str]:
|
||||
assert (stream_name is None) == (stream_data is None)
|
||||
|
||||
@@ -75,7 +73,7 @@ def _make_postscript(icc_name: str, icc_data: bytes, colors: int) -> Iterator[st
|
||||
|
||||
|
||||
def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'):
|
||||
"""Create a Postscript PDFMARK file for Ghostscript PDF/A conversion
|
||||
"""Create a Postscript PDFMARK file for Ghostscript PDF/A conversion.
|
||||
|
||||
pdfmark is an extension to the Postscript language that describes some PDF
|
||||
features like bookmarks and annotations. It was originally specified Adobe
|
||||
@@ -84,7 +82,7 @@ def generate_pdfa_ps(target_filename: Path, icc: str = 'sRGB'):
|
||||
Ghostscript uses pdfmark for PDF to PDF/A conversion as well. To use Ghostscript
|
||||
to create a PDF/A, we need to create a pdfmark file with the necessary metadata.
|
||||
|
||||
This function takes care of the many version-specific bugs and pecularities in
|
||||
This function takes care of the many version-specific bugs and peculiarities in
|
||||
Ghostscript's handling of pdfmark.
|
||||
|
||||
The only information we put in specifies that we want the file to be a
|
||||
@@ -118,7 +116,6 @@ def file_claims_pdfa(filename: Path):
|
||||
This only checks if the XMP metadata contains a PDF/A marker. It does not
|
||||
do full PDF/A validation.
|
||||
"""
|
||||
|
||||
with pikepdf.open(filename) as pdf:
|
||||
pdfmeta = pdf.open_metadata()
|
||||
if not pdfmeta.pdfa_status:
|
||||
|
||||
@@ -6,4 +6,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PdfInfo
|
||||
from ocrmypdf.pdfinfo.info import Colorspace, Encoding, PageInfo, PdfInfo
|
||||
|
||||
__all__ = ["Colorspace", "Encoding", "PageInfo", "PdfInfo"]
|
||||
|
||||
@@ -17,16 +17,7 @@ from functools import partial
|
||||
from math import hypot, inf, isclose
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import (
|
||||
Container,
|
||||
Iterable,
|
||||
Iterator,
|
||||
Mapping,
|
||||
NamedTuple,
|
||||
Optional,
|
||||
Sequence,
|
||||
Tuple,
|
||||
)
|
||||
from typing import Container, Iterable, Iterator, Mapping, NamedTuple, Sequence, Tuple
|
||||
from warnings import warn
|
||||
|
||||
from pikepdf import (
|
||||
@@ -173,7 +164,7 @@ class TextMarker:
|
||||
|
||||
|
||||
def _normalize_stack(graphobjs):
|
||||
"""Convert runs of qQ's in the stack into single graphobjs"""
|
||||
"""Convert runs of qQ's in the stack into single graphobjs."""
|
||||
for operands, operator in graphobjs:
|
||||
operator = str(operator)
|
||||
if re.match(r'Q*q+$', operator): # Zero or more Q, one or more q
|
||||
@@ -209,7 +200,6 @@ def _interpret_contents(contentstream: Object, initial_shorthand=UNIT_SQUARE):
|
||||
undefined in the spec, but we just pretend nothing happened and leave the
|
||||
CTM unchanged.
|
||||
"""
|
||||
|
||||
stack = []
|
||||
ctm = PdfMatrix(initial_shorthand)
|
||||
xobject_settings: list[XobjectSettings] = []
|
||||
@@ -316,7 +306,6 @@ def _get_dpi(ctm_shorthand, image_size) -> Resolution:
|
||||
/MediaBox.
|
||||
|
||||
"""
|
||||
|
||||
a, b, c, d, _, _ = ctm_shorthand # pylint: disable=invalid-name
|
||||
|
||||
# Calculate the width and height of the image in PDF units
|
||||
@@ -333,7 +322,12 @@ def _get_dpi(ctm_shorthand, image_size) -> Resolution:
|
||||
|
||||
|
||||
class ImageInfo:
|
||||
"""Information about an image found in a PDF."""
|
||||
"""Information about an image found in a PDF.
|
||||
|
||||
This gathers information from pikepdf and pdfminer.six, and is pickle-able
|
||||
so that it can be passed to a worker process, unlike objects from those
|
||||
libraries.
|
||||
"""
|
||||
|
||||
DPI_PREC = Decimal('1.000')
|
||||
|
||||
@@ -348,6 +342,7 @@ class ImageInfo:
|
||||
inline: PdfInlineImage | None = None,
|
||||
shorthand=None,
|
||||
):
|
||||
"""Initialize an ImageInfo."""
|
||||
self._name = str(name)
|
||||
self._shorthand = shorthand
|
||||
|
||||
@@ -414,45 +409,62 @@ class ImageInfo:
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Name of the image as it appears in the PDF."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def type_(self):
|
||||
"""Type of image, either 'image' or 'stencil'."""
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
"""Width of the image in pixels."""
|
||||
return self._width
|
||||
|
||||
@property
|
||||
def height(self):
|
||||
"""Height of the image in pixels."""
|
||||
return self._height
|
||||
|
||||
@property
|
||||
def bpc(self):
|
||||
"""Bits per component."""
|
||||
return self._bpc
|
||||
|
||||
@property
|
||||
def color(self):
|
||||
"""Colorspace of the image."""
|
||||
return self._color if self._color is not None else '?'
|
||||
|
||||
@property
|
||||
def comp(self):
|
||||
"""Number of components/channels in the image."""
|
||||
return self._comp if self._comp is not None else '?'
|
||||
|
||||
@property
|
||||
def enc(self):
|
||||
"""Encoding of the image."""
|
||||
return self._enc if self._enc is not None else 'image'
|
||||
|
||||
@property
|
||||
def renderable(self):
|
||||
def renderable(self) -> bool:
|
||||
"""Whether the image is renderable.
|
||||
|
||||
Some PDFs in the wild have invalid images that are not renderable.
|
||||
"""
|
||||
return self.dpi.is_finite and self.width >= 0 and self.height >= 0
|
||||
|
||||
@property
|
||||
def dpi(self):
|
||||
"""Dots per inch of the image.
|
||||
|
||||
Calculated based on where and how the image is drawn in the PDF.
|
||||
"""
|
||||
return _get_dpi(self._shorthand, (self._width, self._height))
|
||||
|
||||
def __repr__(self):
|
||||
"""Return a string representation of the image."""
|
||||
return (
|
||||
f"<ImageInfo '{self.name}' {self.type_} {self.width}x{self.height} "
|
||||
f"{self.color} {self.comp} {self.bpc} {self.enc} {self.dpi}>"
|
||||
@@ -460,8 +472,7 @@ class ImageInfo:
|
||||
|
||||
|
||||
def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
|
||||
"Find inline images in the contentstream"
|
||||
|
||||
"""Find inline images in the contentstream."""
|
||||
for n, inline in enumerate(contentsinfo.inline_images):
|
||||
yield ImageInfo(
|
||||
name=f'inline-{n:02d}', shorthand=inline.shorthand, inline=inline.iimage
|
||||
@@ -469,7 +480,7 @@ def _find_inline_images(contentsinfo: ContentsInfo) -> Iterator[ImageInfo]:
|
||||
|
||||
|
||||
def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
|
||||
"""Search for all XObject-based images in the container
|
||||
"""Search for all XObject-based images in the container.
|
||||
|
||||
Usually the container is a page, but it could also be a Form XObject
|
||||
that contains images. Filter out the Form XObjects which are dealt with
|
||||
@@ -480,33 +491,29 @@ def _image_xobjects(container) -> Iterator[tuple[Object, str]]:
|
||||
since the object does not know its own name.
|
||||
|
||||
"""
|
||||
|
||||
if '/Resources' not in container:
|
||||
return
|
||||
resources = container['/Resources']
|
||||
if '/XObject' not in resources:
|
||||
return
|
||||
xobjs = resources['/XObject'].as_dict()
|
||||
for xobj in xobjs:
|
||||
candidate: Object = xobjs[xobj]
|
||||
if '/Subtype' not in candidate:
|
||||
for key, candidate in resources['/XObject'].items():
|
||||
if candidate is None or '/Subtype' not in candidate:
|
||||
continue
|
||||
if candidate['/Subtype'] == '/Image':
|
||||
pdfimage = candidate
|
||||
yield (pdfimage, xobj)
|
||||
yield (pdfimage, key)
|
||||
|
||||
|
||||
def _find_regular_images(
|
||||
container: Object, contentsinfo: ContentsInfo
|
||||
) -> Iterator[ImageInfo]:
|
||||
"""Find images stored in the container's /Resources /XObject
|
||||
"""Find images stored in the container's /Resources /XObject.
|
||||
|
||||
Usually the container is a page, but it could also be a Form XObject
|
||||
that contains images.
|
||||
|
||||
Generates images with their DPI at time of drawing.
|
||||
"""
|
||||
|
||||
for pdfimage, xobj in _image_xobjects(container):
|
||||
if xobj not in contentsinfo.name_index:
|
||||
continue
|
||||
@@ -523,7 +530,7 @@ def _find_regular_images(
|
||||
|
||||
|
||||
def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: ContentsInfo):
|
||||
"""Find any images that are in Form XObjects in the container
|
||||
"""Find any images that are in Form XObjects in the container.
|
||||
|
||||
The container may be a page, or a parent Form XObject.
|
||||
|
||||
@@ -536,7 +543,7 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
|
||||
xobjs = resources['/XObject'].as_dict()
|
||||
for xobj in xobjs:
|
||||
candidate = xobjs[xobj]
|
||||
if candidate['/Subtype'] != '/Form':
|
||||
if candidate is None or candidate['/Subtype'] != '/Form':
|
||||
continue
|
||||
|
||||
form_xobject = candidate
|
||||
@@ -557,7 +564,7 @@ def _find_form_xobject_images(pdf: Pdf, container: Object, contentsinfo: Content
|
||||
def _process_content_streams(
|
||||
*, pdf: Pdf, container: Object, shorthand=None
|
||||
) -> Iterator[VectorMarker | TextMarker | ImageInfo]:
|
||||
"""Find all individual instances of images drawn in the container
|
||||
"""Find all individual instances of images drawn in the container.
|
||||
|
||||
Usually the container is a page, but it may also be a Form XObject.
|
||||
|
||||
@@ -574,7 +581,6 @@ def _process_content_streams(
|
||||
downsampling.
|
||||
|
||||
"""
|
||||
|
||||
if container.get('/Type') == '/Page' and '/Contents' in container:
|
||||
initial_shorthand = shorthand or UNIT_SQUARE
|
||||
elif container.get('/Type') == '/XObject' and container['/Subtype'] == '/Form':
|
||||
@@ -606,8 +612,7 @@ def _process_content_streams(
|
||||
|
||||
|
||||
def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) -> bool:
|
||||
"""Smarter text detection that ignores text in margins"""
|
||||
|
||||
"""Smarter text detection that ignores text in margins."""
|
||||
pw, ph = float(page_width), float(page_height) # pylint: disable=invalid-name
|
||||
|
||||
margin_ratio = 0.125
|
||||
@@ -619,10 +624,11 @@ def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) ->
|
||||
)
|
||||
|
||||
def rects_intersect(a: FloatRect, b: FloatRect) -> bool:
|
||||
"""
|
||||
"""Check if two 4-tuple rects intersect.
|
||||
|
||||
Where (a,b) are 4-tuple rects (left-0, top-1, right-2, bottom-3)
|
||||
https://stackoverflow.com/questions/306316/determine-if-two-rectangles-overlap-each-other
|
||||
Formula assumes all boxes are in first quadrant
|
||||
Formula assumes all boxes are in first quadrant.
|
||||
"""
|
||||
return a[0] < b[2] and a[2] > b[0] and a[1] > b[3] and a[3] < b[1]
|
||||
|
||||
@@ -635,7 +641,7 @@ def _page_has_text(text_blocks: Iterable[FloatRect], page_width, page_height) ->
|
||||
|
||||
|
||||
def simplify_textboxes(miner, textbox_getter) -> 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.
|
||||
"""
|
||||
@@ -751,6 +757,7 @@ class PageInfo:
|
||||
check_pages: Container[int],
|
||||
detailed_analysis: bool = False,
|
||||
):
|
||||
"""Initialize a PageInfo object."""
|
||||
self._pageno = pageno
|
||||
self._infile = infile
|
||||
self._detailed_analysis = detailed_analysis
|
||||
@@ -827,40 +834,56 @@ class PageInfo:
|
||||
|
||||
@property
|
||||
def pageno(self) -> int:
|
||||
"""Return page number (0-based)."""
|
||||
return self._pageno
|
||||
|
||||
@property
|
||||
def has_text(self) -> bool:
|
||||
"""Return True if page has text, False if not or unknown."""
|
||||
return bool(self._has_text)
|
||||
|
||||
@property
|
||||
def has_corrupt_text(self) -> bool:
|
||||
"""Return True if page has corrupt text, False if not or unknown."""
|
||||
if not self._detailed_analysis:
|
||||
raise NotImplementedError('Did not do detailed analysis')
|
||||
return any(tbox.is_corrupt for tbox in self._textboxes)
|
||||
|
||||
@property
|
||||
def has_vector(self) -> bool:
|
||||
"""Return True if page has vector graphics, False if not or unknown.
|
||||
|
||||
Vector graphics are sometimes used to draw fonts, so it may not be
|
||||
obvious on visual inspection whether a page has text or not.
|
||||
"""
|
||||
return bool(self._has_vector)
|
||||
|
||||
@property
|
||||
def width_inches(self) -> Decimal:
|
||||
"""Return width of page in inches."""
|
||||
return self._width_inches
|
||||
|
||||
@property
|
||||
def height_inches(self) -> Decimal:
|
||||
"""Return height of page in inches."""
|
||||
return self._height_inches
|
||||
|
||||
@property
|
||||
def width_pixels(self) -> int:
|
||||
"""Return width of page in pixels."""
|
||||
return int(round(float(self.width_inches) * self.dpi.x))
|
||||
|
||||
@property
|
||||
def height_pixels(self) -> int:
|
||||
"""Return height of page in pixels."""
|
||||
return int(round(float(self.height_inches) * self.dpi.y))
|
||||
|
||||
@property
|
||||
def rotation(self) -> int:
|
||||
"""Return rotation of page in degrees.
|
||||
|
||||
Will only be a multiple of 90.
|
||||
"""
|
||||
return self._rotate
|
||||
|
||||
@rotation.setter
|
||||
@@ -871,10 +894,13 @@ class PageInfo:
|
||||
raise ValueError("rotation must be a cardinal angle")
|
||||
|
||||
@property
|
||||
def images(self):
|
||||
def images(self) -> list[ImageInfo]:
|
||||
"""Return images."""
|
||||
return self._images
|
||||
|
||||
def get_textareas(self, visible: bool | None = None, corrupt: bool | None = None):
|
||||
"""Return textareas bounding boxes in PDF coordinates on the page."""
|
||||
|
||||
def predicate(obj, want_visible, want_corrupt):
|
||||
result = True
|
||||
if want_visible is not None:
|
||||
@@ -894,22 +920,26 @@ class PageInfo:
|
||||
|
||||
@property
|
||||
def dpi(self) -> Resolution:
|
||||
"""Return DPI needed to render all images on the page."""
|
||||
if self._dpi is None:
|
||||
return Resolution(0.0, 0.0)
|
||||
return self._dpi
|
||||
|
||||
@property
|
||||
def userunit(self) -> Decimal:
|
||||
"""Return user unit of page."""
|
||||
return self._userunit
|
||||
|
||||
@property
|
||||
def min_version(self) -> str:
|
||||
"""Return minimum PDF version needed to render this page."""
|
||||
if self.userunit is not None:
|
||||
return '1.6'
|
||||
else:
|
||||
return '1.5'
|
||||
|
||||
def __repr__(self):
|
||||
"""Return string representation."""
|
||||
return (
|
||||
f'<PageInfo '
|
||||
f'pageno={self.pageno} {self.width_inches}"x{self.height_inches}" '
|
||||
@@ -921,7 +951,7 @@ DEFAULT_EXECUTOR = SerialExecutor()
|
||||
|
||||
|
||||
class PdfInfo:
|
||||
"""Get summary information about a PDF"""
|
||||
"""Get summary information about a PDF."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -929,10 +959,11 @@ class PdfInfo:
|
||||
*,
|
||||
detailed_analysis: bool = False,
|
||||
progbar: bool = False,
|
||||
max_workers: int = None,
|
||||
max_workers: int | None = None,
|
||||
check_pages=None,
|
||||
executor: Executor = DEFAULT_EXECUTOR,
|
||||
):
|
||||
"""Initialize."""
|
||||
self._infile = infile
|
||||
if check_pages is None:
|
||||
check_pages = range(0, 1_000_000_000)
|
||||
@@ -959,42 +990,56 @@ class PdfInfo:
|
||||
|
||||
@property
|
||||
def pages(self) -> Sequence[PageInfo | None]:
|
||||
"""Return list of PageInfo objects, one per page in the PDF."""
|
||||
return self._pages
|
||||
|
||||
@property
|
||||
def min_version(self) -> str:
|
||||
"""Return minimum PDF version needed to render this PDF."""
|
||||
# The minimum PDF is the maximum version that any particular page needs
|
||||
return max(page.min_version for page in self.pages if page)
|
||||
|
||||
@property
|
||||
def has_userunit(self) -> bool:
|
||||
"""Return True if any page has a user unit."""
|
||||
return any(page.userunit != 1.0 for page in self.pages if page)
|
||||
|
||||
@property
|
||||
def has_acroform(self) -> bool:
|
||||
"""Return True if any page has an AcroForm."""
|
||||
return self._has_acroform
|
||||
|
||||
@property
|
||||
def filename(self) -> str | Path:
|
||||
"""Return filename of PDF."""
|
||||
if not isinstance(self._infile, (str, Path)):
|
||||
raise NotImplementedError("can't get filename from stream")
|
||||
return self._infile
|
||||
|
||||
@property
|
||||
def needs_rendering(self) -> bool:
|
||||
"""Return True if PDF contains XFA forms.
|
||||
|
||||
XFA forms are not supported by most standard PDF renderers, so we
|
||||
need to detect and suppress them.
|
||||
"""
|
||||
return self._needs_rendering
|
||||
|
||||
def __getitem__(self, item) -> PageInfo:
|
||||
"""Return PageInfo object for page number `item`."""
|
||||
return self._pages[item]
|
||||
|
||||
def __len__(self):
|
||||
"""Return number of pages in PDF."""
|
||||
return len(self._pages)
|
||||
|
||||
def __repr__(self):
|
||||
"""Return string representation."""
|
||||
return f"<PdfInfo('...'), page count={len(self)}>"
|
||||
|
||||
|
||||
def main():
|
||||
"""Run as a script."""
|
||||
import argparse # pylint: disable=import-outside-toplevel
|
||||
from pprint import pprint # pylint: disable=import-outside-toplevel
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Detailed text position and layout analysis, building on pdfminer.six."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
@@ -23,45 +25,68 @@ from ocrmypdf.exceptions import EncryptedPdfError, InputFileError
|
||||
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, widths, spec):
|
||||
"""Monkeypatch pdfminer.six PDFSimpleFont.__init__.
|
||||
|
||||
If there is no ToUnicode and no Encoding, pdfminer.six assumes that Unicode
|
||||
conversion is possible. This is incorrect, according to PDF Reference Manual
|
||||
9.10.2. This patch fixes that.
|
||||
"""
|
||||
# Font encoding is specified either by a name of
|
||||
# built-in encoding or a dictionary that describes
|
||||
# the differences.
|
||||
original_PDFSimpleFont_init(self, descriptor, widths, spec)
|
||||
# pdfminer is incorrect. If there is no ToUnicode and no Encoding, do not
|
||||
# assume Unicode conversion is possible. RM 9.10.2
|
||||
original_pdfsimplefont_init(self, descriptor, widths, spec)
|
||||
if not self.unicode_map and 'Encoding' not in spec:
|
||||
self.cid2unicode = {}
|
||||
return
|
||||
|
||||
|
||||
PDFSimpleFont.__init__ = PDFSimpleFont__init__
|
||||
PDFSimpleFont.__init__ = pdfsimplefont__init__
|
||||
|
||||
#
|
||||
# pdfminer patches when creator is PScript5.dll
|
||||
#
|
||||
|
||||
|
||||
def PDFType3Font__PScript5_get_height(self):
|
||||
def pdftype3font__pscript5_get_height(self):
|
||||
"""Monkeypatch for PScript5.dll PDFs.
|
||||
|
||||
The height of Type3 fonts is known to be incorrect in PScript5.dll
|
||||
generated PDFs. This patch attempts to correct the height by
|
||||
using the bbox height if it is available, otherwise using the
|
||||
ascent and descent.
|
||||
"""
|
||||
h = self.bbox[3] - self.bbox[1]
|
||||
if h == 0:
|
||||
h = self.ascent - self.descent
|
||||
return h * copysign(1.0, self.vscale)
|
||||
|
||||
|
||||
def PDFType3Font__PScript5_get_descent(self):
|
||||
def pdftype3font__pscript5_get_descent(self):
|
||||
"""Monkeypatch for PScript5.dll PDFs.
|
||||
|
||||
The descent of Type3 fonts is known to be incorrect in PScript5.dll
|
||||
generated PDFs. This patch attempts to correct the descent by
|
||||
using the vscale.
|
||||
"""
|
||||
return self.descent * copysign(1.0, self.vscale)
|
||||
|
||||
|
||||
def PDFType3Font__PScript5_get_ascent(self):
|
||||
def pdftype3font__pscript5_get_ascent(self):
|
||||
"""Monkeypatch for PScript5.dll PDFs.
|
||||
|
||||
The ascent of Type3 fonts is known to be incorrect in PScript5.dll
|
||||
generated PDFs. This patch attempts to correct the ascent by
|
||||
using the vscale.
|
||||
"""
|
||||
return self.ascent * copysign(1.0, self.vscale)
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
__slots__ = (
|
||||
'rendermode',
|
||||
@@ -94,6 +119,7 @@ class LTStateAwareChar(LTChar):
|
||||
graphicstate,
|
||||
textstate,
|
||||
):
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
matrix,
|
||||
font,
|
||||
@@ -109,7 +135,7 @@ class LTStateAwareChar(LTChar):
|
||||
self.rendermode = textstate.render
|
||||
|
||||
def is_compatible(self, obj):
|
||||
"""Check if characters can be combined into a textline
|
||||
"""Check if characters can be combined into a textline.
|
||||
|
||||
We consider characters compatible if:
|
||||
- the Unicode mapping is known, and both have the same render mode
|
||||
@@ -127,36 +153,41 @@ class LTStateAwareChar(LTChar):
|
||||
return False
|
||||
|
||||
def get_text(self):
|
||||
"""Get text from this character."""
|
||||
if isinstance(self._text, tuple):
|
||||
return '\ufffd' # standard 'Unknown symbol'
|
||||
return self._text
|
||||
|
||||
def __repr__(self):
|
||||
return '<{} {} matrix={} rendermode={!r} font={!r} adv={} text={!r}>'.format(
|
||||
self.__class__.__name__,
|
||||
bbox2str(self.bbox),
|
||||
matrix2str(self.matrix),
|
||||
self.rendermode,
|
||||
self.fontname,
|
||||
self.adv,
|
||||
self.get_text(),
|
||||
"""Return a string representation of this object."""
|
||||
return (
|
||||
f"<{self.__class__.__name__} "
|
||||
f"{bbox2str(self.bbox)} "
|
||||
f"matrix={matrix2str(self.matrix)} "
|
||||
f"rendermode={self.rendermode!r} "
|
||||
f"font={self.fontname!r} "
|
||||
f"adv={self.adv} "
|
||||
f"text={self.get_text()!r}>"
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
"""Initialize the layout analyzer."""
|
||||
super().__init__(rsrcmgr, pageno, laparams)
|
||||
self.textstate = None
|
||||
self.result = None
|
||||
self.cur_item = None # not defined in pdfminer code as it should be
|
||||
|
||||
def begin_page(self, page, ctm):
|
||||
"""Begin processing of a page."""
|
||||
super().begin_page(page, ctm)
|
||||
self.cur_item = LTPage(self.pageno, page.mediabox)
|
||||
|
||||
def end_page(self, page):
|
||||
"""End processing of a page."""
|
||||
assert not self._stack, str(len(self._stack))
|
||||
assert isinstance(self.cur_item, LTPage), str(type(self.cur_item))
|
||||
if self.laparams is not None:
|
||||
@@ -165,12 +196,14 @@ class TextPositionTracker(PDFLayoutAnalyzer):
|
||||
self.receive_layout(self.cur_item)
|
||||
|
||||
def render_string(self, textstate, seq, ncs, graphicstate):
|
||||
"""Respond to render string event by updating text state."""
|
||||
self.textstate = textstate.copy()
|
||||
super().render_string(self.textstate, seq, ncs, graphicstate)
|
||||
|
||||
def render_char(
|
||||
self, matrix, font, fontsize, scaling, rise, cid, ncs, graphicstate
|
||||
):
|
||||
"""Respond to render char event by updating text state."""
|
||||
try:
|
||||
text = font.to_unichr(cid)
|
||||
assert isinstance(text, str), str(type(text))
|
||||
@@ -195,23 +228,23 @@ class TextPositionTracker(PDFLayoutAnalyzer):
|
||||
return item.adv
|
||||
|
||||
def handle_undefined_char(self, font, cid):
|
||||
"""Handle undefined character."""
|
||||
# log.info('undefined: %r, %r', font, cid)
|
||||
return (font.fontname, cid)
|
||||
|
||||
def receive_layout(self, ltpage):
|
||||
"""Receive layout handler."""
|
||||
self.result = ltpage
|
||||
|
||||
def get_result(self):
|
||||
"""Get the result of the analysis."""
|
||||
return self.result
|
||||
|
||||
|
||||
def get_page_analysis(infile, pageno, pscript5_mode):
|
||||
"""Get the page analysis for a given page."""
|
||||
rman = pdfminer.pdfinterp.PDFResourceManager(caching=True)
|
||||
if pdfminer.__version__ < '20200402':
|
||||
# Workaround for https://github.com/pdfminer/pdfminer.six/issues/395
|
||||
disable_boxes_flow = 2
|
||||
else:
|
||||
disable_boxes_flow = None
|
||||
disable_boxes_flow = None
|
||||
dev = TextPositionTracker(
|
||||
rman,
|
||||
laparams=LAParams(
|
||||
@@ -225,9 +258,9 @@ def get_page_analysis(infile, pageno, pscript5_mode):
|
||||
patcher = 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,
|
||||
get_ascent=pdftype3font__pscript5_get_ascent,
|
||||
get_descent=pdftype3font__pscript5_get_descent,
|
||||
get_height=pdftype3font__pscript5_get_height,
|
||||
)
|
||||
patcher.start()
|
||||
|
||||
@@ -250,6 +283,7 @@ def get_page_analysis(infile, pageno, pscript5_mode):
|
||||
|
||||
|
||||
def get_text_boxes(obj):
|
||||
"""Get the text boxes attached to the current node."""
|
||||
for child in obj:
|
||||
if isinstance(child, (LTTextBox)):
|
||||
yield child
|
||||
|
||||
+61
-25
@@ -28,6 +28,7 @@ if TYPE_CHECKING:
|
||||
hookspec = pluggy.HookspecMarker('ocrmypdf')
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
# mypy: disable-error-code=empty-body
|
||||
|
||||
|
||||
@hookspec(firstresult=True)
|
||||
@@ -43,7 +44,7 @@ def get_logging_console() -> Handler:
|
||||
|
||||
|
||||
@hookspec
|
||||
def initialize(plugin_manager: pluggy.PluginManager):
|
||||
def initialize(plugin_manager: pluggy.PluginManager) -> None:
|
||||
"""Called when this plugin is first loaded into OCRmyPDF.
|
||||
|
||||
The primary intended use of this is for plugins to check compatibility with other
|
||||
@@ -99,6 +100,8 @@ def check_options(options: Namespace) -> None:
|
||||
ocrmypdf.exceptions.ExitCodeException: If options are not acceptable
|
||||
and the application should terminate gracefully with an informative
|
||||
message and error code.
|
||||
|
||||
|
||||
Note:
|
||||
This hook will be called from the main process, and may modify global state
|
||||
before child worker processes are forked.
|
||||
@@ -127,6 +130,8 @@ def get_executor(progressbar_class) -> Executor:
|
||||
Note:
|
||||
This hook will be called from the main process, and may modify global state
|
||||
before child worker processes are forked.
|
||||
|
||||
|
||||
Note:
|
||||
This is a :ref:`firstresult hook<firstresult>`.
|
||||
"""
|
||||
@@ -159,7 +164,6 @@ def get_progressbar_class():
|
||||
Here is how OCRmyPDF will use the progress bar:
|
||||
|
||||
Example:
|
||||
|
||||
pbar_class = pm.hook.get_progressbar_class()
|
||||
with pbar_class(**tqdm_kwargs) as pbar:
|
||||
...
|
||||
@@ -181,6 +185,8 @@ def validate(pdfinfo: PdfInfo, options: Namespace) -> None:
|
||||
ocrmypdf.exceptions.ExitCodeException: If options or pdfinfo are not acceptable
|
||||
and the application should terminate gracefully with an informative
|
||||
message and error code.
|
||||
|
||||
|
||||
Note:
|
||||
This hook will be called from the main process, and may modify global state
|
||||
before child worker processes are forked.
|
||||
@@ -197,6 +203,7 @@ def rasterize_pdf_page(
|
||||
page_dpi: Resolution | None,
|
||||
rotation: int | None,
|
||||
filter_vector: bool,
|
||||
stop_on_soft_error: bool,
|
||||
) -> Path:
|
||||
"""Rasterize one page of a PDF at resolution raster_dpi in canvas units.
|
||||
|
||||
@@ -207,17 +214,26 @@ def rasterize_pdf_page(
|
||||
Args:
|
||||
input_file: The PDF to rasterize.
|
||||
output_file: The desired name of the rasterized image.
|
||||
raster_device: Type of image to produce at output_file
|
||||
raster_dpi: Resolution at which to rasterize page
|
||||
pageno: Page number to rasterize (beginning at page 1)
|
||||
page_dpi: Resolution, overriding output image DPI
|
||||
rotation: Cardinal angle, clockwise, to rotate page
|
||||
filter_vector: If True, remove vector graphics objects
|
||||
raster_device: Type of image to produce at output_file.
|
||||
raster_dpi: Resolution in dots per inch at which to rasterize page.
|
||||
pageno: Page number to rasterize (beginning at page 1).
|
||||
page_dpi: Resolution, overriding output image DPI.
|
||||
rotation: Cardinal angle, clockwise, to rotate page.
|
||||
filter_vector: If True, remove vector graphics objects.
|
||||
stop_on_soft_error: If there is an "soft error" such that PDF page image
|
||||
generation can proceed, but may visually differ from the original,
|
||||
the implementer of this hook should raise a detailed exception. If
|
||||
``False``, continue processing and report by logging it. If the hook
|
||||
cannot proceed, it should always raise an exception, regardless of
|
||||
this setting. One "soft error" would be a missing font that is
|
||||
required to properly rasterize the PDF.
|
||||
|
||||
Returns:
|
||||
Path: output_file if successful
|
||||
Note:
|
||||
This hook will be called from child processes. Modifying global state
|
||||
will not affect the main process or other child processes.
|
||||
|
||||
Note:
|
||||
This is a :ref:`firstresult hook<firstresult>`.
|
||||
"""
|
||||
@@ -228,23 +244,32 @@ def filter_ocr_image(page: PageContext, image: Image.Image) -> Image.Image:
|
||||
"""Called to filter the image before it is sent to OCR.
|
||||
|
||||
This is the image that OCR sees, not what the user sees when they view the
|
||||
PDF. If ``redo_ocr`` is enabled, portions of the image will be masked so
|
||||
they are not shown to OCR. The main use of this hook is expected to be hiding
|
||||
content from OCR.
|
||||
PDF. In certain modes such as ``--redo-ocr``, portions of the image may be
|
||||
masked out to hide them from OCR.
|
||||
|
||||
The main uses of this hook are expected to be hiding content from OCR,
|
||||
conditioning images to OCR better with filters, and adjusting images to
|
||||
match any constraints imposed by the OCR engine.
|
||||
|
||||
The input image may be color, grayscale, or monochrome, and the
|
||||
output image may differ. The pixel width and height of the
|
||||
output image must be identical to the input image, or misalignment between
|
||||
the OCR text layer and visual position of the text will occur. Likewise,
|
||||
the output must be a faithful representation of the input, or alignment
|
||||
errors may occurs.
|
||||
output image may differ. For example, if you know that a custom OCR engine
|
||||
does not care about the color of the text, you could convert the image to
|
||||
it to grayscale or monochrome.
|
||||
|
||||
Tesseract OCR only deals with monochrome images, and internally converts
|
||||
non-monochrome images to OCR.
|
||||
Generally speaking, the output image should be a faithful representation of
|
||||
of the input image. You *may* change the pixel width and height of the
|
||||
the input image, but you must not change the aspect ratio, and you must
|
||||
calculate the DPI of the output image based on the new pixel width and
|
||||
height or the OCR text layer will be misaligned with the visual position.
|
||||
|
||||
The built-in Tesseract OCR engine uses this hook itself to downsample
|
||||
very large images to fit its constraints.
|
||||
|
||||
Note:
|
||||
This hook will be called from child processes. Modifying global state
|
||||
will not affect the main process or other child processes.
|
||||
|
||||
|
||||
Note:
|
||||
This is a :ref:`firstresult hook<firstresult>`.
|
||||
"""
|
||||
@@ -269,7 +294,7 @@ def filter_page_image(page: PageContext, image_filename: Path) -> Path:
|
||||
to enforce these constraints; it is up to the plugin to do sensible things.
|
||||
|
||||
OCRmyPDF will create the PDF page based on the image format used (unless the
|
||||
hook is overriden). If you convert the image to a JPEG, the output page will
|
||||
hook is overridden). If you convert the image to a JPEG, the output page will
|
||||
be created as a JPEG, etc. If you change the colorspace, that change will be
|
||||
kept. Note that the OCRmyPDF image optimization stage, if enabled, may
|
||||
ultimately chose a different format.
|
||||
@@ -278,13 +303,11 @@ def filter_page_image(page: PageContext, image_filename: Path) -> Path:
|
||||
will occur. The return value should be a path to a file in the same folder
|
||||
as ``image_filename``.
|
||||
|
||||
Implementation detail: If the value returned is falsy, OCRmyPDF will ignore
|
||||
the return value and assume the input file was unmodified. This is deprecated.
|
||||
To leave the image unmodified, ``image_filename`` should be returned.
|
||||
|
||||
Note:
|
||||
This hook will be called from child processes. Modifying global state
|
||||
will not affect the main process or other child processes.
|
||||
|
||||
|
||||
Note:
|
||||
This is a :ref:`firstresult hook<firstresult>`.
|
||||
"""
|
||||
@@ -327,6 +350,8 @@ def filter_pdf_page(page: PageContext, image_filename: Path, output_pdf: Path) -
|
||||
Note:
|
||||
This hook will be called from child processes. Modifying global state
|
||||
will not affect the main process or other child processes.
|
||||
|
||||
|
||||
Note:
|
||||
This is a :ref:`firstresult hook<firstresult>`.
|
||||
"""
|
||||
@@ -385,7 +410,8 @@ class OcrEngine(ABC):
|
||||
"""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
|
||||
can be any value understood by the OCR engine."""
|
||||
can be any value understood by the OCR engine.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@abstractmethod
|
||||
@@ -417,6 +443,9 @@ class OcrEngine(ABC):
|
||||
a single page PDF with no visible content of any kind, sized
|
||||
to the dimensions implied by the input_file's width, height
|
||||
and DPI. The image will be grafted onto the input PDF page.
|
||||
output_text: The expected name of a text file containing the
|
||||
recognized text.
|
||||
options: The command line options.
|
||||
"""
|
||||
|
||||
|
||||
@@ -441,6 +470,7 @@ def generate_pdfa(
|
||||
pdf_version: str,
|
||||
pdfa_part: str,
|
||||
progressbar_class,
|
||||
stop_on_soft_error: bool,
|
||||
) -> Path:
|
||||
"""Generate a PDF/A.
|
||||
|
||||
@@ -471,6 +501,12 @@ def generate_pdfa(
|
||||
and the name of the work units ("page"). Then ``instance.update()``
|
||||
will be called when a work unit is completed. If ``None``, no
|
||||
progress information is reported.
|
||||
stop_on_soft_error: If there is an "soft error" such that PDF/A generation
|
||||
can proceed and produce a valid PDF/A, but output may be invalid or
|
||||
may not visually resemble the original, the implementer of this hook
|
||||
should raise a detailed exception. If ``False``, continue processing
|
||||
and report by logging it. If the hook cannot proceed, it should always
|
||||
raise an exception, regardless of this setting.
|
||||
|
||||
Returns:
|
||||
Path: If successful, the hook should return ``output_file``.
|
||||
@@ -478,7 +514,7 @@ def generate_pdfa(
|
||||
Note:
|
||||
This is a :ref:`firstresult hook<firstresult>`.
|
||||
|
||||
See also:
|
||||
See Also:
|
||||
https://github.com/tqdm/tqdm
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""Utilities to measure OCR quality"""
|
||||
"""Utilities to measure OCR quality."""
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
"""Wrappers to manage subprocess calls"""
|
||||
"""Wrappers to manage subprocess calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,7 +9,6 @@ import os
|
||||
import re
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from subprocess import PIPE, STDOUT, CalledProcessError, CompletedProcess, Popen
|
||||
from subprocess import run as subprocess_run
|
||||
@@ -35,20 +34,25 @@ def run(
|
||||
check: bool = False,
|
||||
**kwargs,
|
||||
) -> CompletedProcess:
|
||||
"""Wrapper around :py:func:`subprocess.run`
|
||||
"""Wrapper around :py:func:`subprocess.run`.
|
||||
|
||||
The main purpose of this wrapper is to log subprocess output in an orderly
|
||||
fashion that indentifies the responsible subprocess. An additional
|
||||
fashion that identifies the responsible subprocess. An additional
|
||||
task is that this function goes to greater lengths to find possible Windows
|
||||
locations of our dependencies when they are not on the system PATH.
|
||||
|
||||
Arguments should be identical to ``subprocess.run``, except for following:
|
||||
|
||||
Arguments:
|
||||
Args:
|
||||
args: Positional arguments to pass to ``subprocess.run``.
|
||||
env: A set of environment variables. If None, the OS environment is used.
|
||||
logs_errors_to_stdout: If True, indicates that the process writes its error
|
||||
messages to stdout rather than stderr, so stdout should be logged
|
||||
if there is an error. If False, stderr is logged. Could be used with
|
||||
stderr=STDOUT, stdout=PIPE for example.
|
||||
check: If True, raise an exception if the process exits with a non-zero
|
||||
status code. If False, the return value will indicate success or failure.
|
||||
kwargs: Additional arguments to pass to ``subprocess.run``.
|
||||
"""
|
||||
args, env, process_log, _text = _fix_process_args(args, env, kwargs)
|
||||
|
||||
@@ -114,8 +118,6 @@ def run_polling_stderr(
|
||||
def _fix_process_args(
|
||||
args: Args, env: OsEnviron | None, kwargs
|
||||
) -> tuple[Args, OsEnviron, logging.Logger, bool]:
|
||||
assert 'universal_newlines' not in kwargs, "Use text= instead of universal_newlines"
|
||||
|
||||
if not env:
|
||||
env = os.environ
|
||||
|
||||
@@ -135,7 +137,6 @@ def _fix_process_args(
|
||||
return args, env, process_log, text
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def get_version(
|
||||
program: str,
|
||||
*,
|
||||
@@ -143,7 +144,7 @@ def get_version(
|
||||
regex=r'(\d+(\.\d+)*)',
|
||||
env: OsEnviron | None = None,
|
||||
) -> str:
|
||||
"""Get the version of the specified program
|
||||
"""Get the version of the specified program.
|
||||
|
||||
Arguments:
|
||||
program: The program to version check.
|
||||
@@ -316,7 +317,7 @@ def check_external_program(
|
||||
program: The name of the program to test.
|
||||
package: The name of a software package that typically supplies this program.
|
||||
Usually the same as program.
|
||||
version_check: A callable without arguments that retrieves the installed
|
||||
version_checker: A callable without arguments that retrieves the installed
|
||||
version of program.
|
||||
need_version: The minimum required version.
|
||||
required_for: The name of an argument of feature that requires this program.
|
||||
@@ -325,12 +326,8 @@ def check_external_program(
|
||||
version_parser: A class that should be used to parse and compare version
|
||||
numbers. Used when version numbers do not follow standard conventions.
|
||||
"""
|
||||
|
||||
try:
|
||||
if callable(version_checker):
|
||||
found_version = version_checker()
|
||||
else: # deprecated
|
||||
found_version = version_checker
|
||||
found_version = version_checker()
|
||||
except (CalledProcessError, FileNotFoundError) as e:
|
||||
_error_missing_program(program, package, required_for, recommended)
|
||||
if not recommended:
|
||||
|
||||
@@ -6,12 +6,15 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from itertools import chain
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Iterator, TypeVar
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
if sys.version_info >= (3, 10):
|
||||
from typing import TypeAlias
|
||||
else:
|
||||
@@ -29,12 +32,13 @@ else:
|
||||
spec=['HKEYType', 'EnumKey', 'EnumValue', 'HKEY_LOCAL_MACHINE', 'OpenKey']
|
||||
)
|
||||
# mypy does not understand winreg.HKeyType where winreg is a Mock (fair enough!)
|
||||
HKEYType: TypeAlias = Any
|
||||
HKEYType: TypeAlias = Any # type: ignore
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar('T')
|
||||
Tkey = TypeVar('Tkey')
|
||||
|
||||
|
||||
def ghostscript_version_key(s: str) -> tuple[int, int, int]:
|
||||
@@ -99,6 +103,30 @@ def registry_path_tesseract(env=None) -> Iterator[Path]:
|
||||
log.warning(e)
|
||||
|
||||
|
||||
def _gs_version_in_path_key(path: Path) -> tuple[str, Version | None]:
|
||||
"""Key function for comparing Ghostscript and Tesseract paths.
|
||||
|
||||
Ghostscript installs on Windows:
|
||||
%PROGRAMFILES%/gs/gs9.56.1/bin -> ('gs', Version('9.56.1'))
|
||||
%PROGRAMFILES%/gs/9.24/bin -> ('gs', Version('9.24'))
|
||||
|
||||
Tesseract looks like:
|
||||
%PROGRAMFILES%/Tesseract-OCR -> ('Tesseract-OCR', None)
|
||||
|
||||
Thus ensuring the resulting tuple will order the alternatives correctly,
|
||||
e.g. gs10.0 > gs9.99.
|
||||
"""
|
||||
match = re.search(r'gs[/\\]?([0-9.]+)[/\\]bin', str(path))
|
||||
if match:
|
||||
try:
|
||||
version_str = match.group(1)
|
||||
version = Version(version_str)
|
||||
return 'gs', version
|
||||
except InvalidVersion:
|
||||
pass
|
||||
return path.name, None
|
||||
|
||||
|
||||
def program_files_paths(env=None) -> Iterator[Path]:
|
||||
if not env:
|
||||
env = os.environ
|
||||
@@ -116,7 +144,7 @@ def program_files_paths(env=None) -> Iterator[Path]:
|
||||
return iter(
|
||||
sorted(
|
||||
(p for p in path_walker()),
|
||||
key=lambda p: (p.name, p.parent.name),
|
||||
key=_gs_version_in_path_key,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
@@ -141,13 +169,7 @@ SHIMS = [
|
||||
|
||||
|
||||
def fix_windows_args(program: str, args, env):
|
||||
"""Adjust our desired program and command line arguments for use on Windows"""
|
||||
|
||||
if sys.version_info < (3, 8):
|
||||
# bpo-33617 - Windows needs manual Path -> str conversion
|
||||
args = [os.fspath(arg) for arg in args]
|
||||
program = os.fspath(program)
|
||||
|
||||
"""Adjust our desired program and command line arguments for use on Windows."""
|
||||
# If we are running a .py on Windows, ensure we call it with this Python
|
||||
# (to support test suite shims)
|
||||
if program.lower().endswith('.py'):
|
||||
@@ -164,11 +186,11 @@ def fix_windows_args(program: str, args, env):
|
||||
return args
|
||||
|
||||
|
||||
def unique_everseen(iterable: Iterable[T], key: Callable[[T], T]) -> Iterator[T]:
|
||||
"List unique elements, preserving order."
|
||||
def unique_everseen(iterable: Iterable[T], key: Callable[[T], Tkey]) -> Iterator[T]:
|
||||
"""List unique elements, preserving order."""
|
||||
# unique_everseen('AAAABBBCCDAABBB') --> A B C D
|
||||
# unique_everseen('ABBCcAD', str.lower) --> A B C D
|
||||
seen: set[T] = set()
|
||||
seen: set[Tkey] = set()
|
||||
seen_add = seen.add
|
||||
for element in iterable:
|
||||
k = key(element)
|
||||
@@ -177,11 +199,15 @@ def unique_everseen(iterable: Iterable[T], key: Callable[[T], T]) -> Iterator[T]
|
||||
yield element
|
||||
|
||||
|
||||
def _casefold_path(path: Path) -> str:
|
||||
return str.casefold(str(path))
|
||||
|
||||
|
||||
def shim_env_path(env=None):
|
||||
if env is None:
|
||||
env = os.environ
|
||||
|
||||
shim_paths = chain.from_iterable(shim(env) for shim in SHIMS)
|
||||
return os.pathsep.join(
|
||||
str(p) for p in unique_everseen(shim_paths, key=lambda p: str.casefold(str(p)))
|
||||
str(p) for p in unique_everseen(shim_paths, key=_casefold_path)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
"""Tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
+12
-8
@@ -3,12 +3,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from subprocess import PIPE, CompletedProcess, run
|
||||
from typing import List
|
||||
from subprocess import CompletedProcess, run
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -44,7 +42,7 @@ TESTS_ROOT = Path(__file__).parent.resolve()
|
||||
PROJECT_ROOT = TESTS_ROOT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def resources() -> Path:
|
||||
return Path(TESTS_ROOT) / 'resources'
|
||||
|
||||
@@ -71,13 +69,21 @@ def outtxt(tmp_path) -> Path:
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def no_outpdf(tmp_path) -> Path:
|
||||
"""This just documents the fact that a test is not expected to produce
|
||||
"""Document fact that a test is not expected to produce output.
|
||||
|
||||
This just documents the fact that a test is not expected to produce
|
||||
output. Unfortunately an assertion failure inside a test fixture produces
|
||||
an error rather than a test failure, so no testing is done. It's up to
|
||||
the test to confirm that no output file was created."""
|
||||
the test to confirm that no output file was created.
|
||||
"""
|
||||
return tmp_path / 'no_output.pdf'
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def multipage(resources):
|
||||
return resources / 'multipage.pdf'
|
||||
|
||||
|
||||
def check_ocrmypdf(input_file: Path, output_file: Path, *args) -> Path:
|
||||
"""Run ocrmypdf and confirm that a valid plausible PDF was created."""
|
||||
api_args = [str(input_file), str(output_file)] + [
|
||||
@@ -105,7 +111,6 @@ def run_ocrmypdf_api(input_file: Path, output_file: Path, *args) -> ExitCode:
|
||||
The return code must always be checked or the test may declare a failure
|
||||
to be pass.
|
||||
"""
|
||||
|
||||
api_args = [str(input_file), str(output_file)] + [
|
||||
str(arg) for arg in args if arg is not None
|
||||
]
|
||||
@@ -123,7 +128,6 @@ def run_ocrmypdf(
|
||||
If an exception is thrown this fact will be returned as part of the result
|
||||
text and return code rather than exception objects.
|
||||
"""
|
||||
|
||||
p_args = (
|
||||
[sys.executable, '-m', 'ocrmypdf']
|
||||
+ [str(arg) for arg in args if arg is not None]
|
||||
|
||||
@@ -9,13 +9,13 @@ from ocrmypdf import hookimpl
|
||||
from ocrmypdf.builtin_plugins import ghostscript
|
||||
from ocrmypdf.subprocess import run_polling_stderr
|
||||
|
||||
elision_warning = """GPL Ghostscript 9.20: Setting Overprint Mode to 1
|
||||
ELISION_WARNING = """GPL Ghostscript 9.50: Setting Overprint Mode to 1
|
||||
not permitted in PDF/A-2, overprint mode not set"""
|
||||
|
||||
|
||||
def run_append_stderr(*args, **kwargs):
|
||||
proc = run_polling_stderr(*args, **kwargs)
|
||||
proc.stderr += '\n' + elision_warning + '\n'
|
||||
proc.stderr += '\n' + ELISION_WARNING + '\n'
|
||||
return proc
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdf
|
||||
pdf_version=pdf_version,
|
||||
pdfa_part=pdfa_part,
|
||||
progressbar_class=None,
|
||||
stop_on_soft_error=True,
|
||||
)
|
||||
mock.assert_called_once()
|
||||
return output_file
|
||||
|
||||
@@ -33,6 +33,7 @@ def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdf
|
||||
pdf_version=pdf_version,
|
||||
pdfa_part=pdfa_part,
|
||||
progressbar_class=None,
|
||||
stop_on_soft_error=True,
|
||||
)
|
||||
mock.assert_called()
|
||||
return output_file
|
||||
|
||||
@@ -39,6 +39,7 @@ def rasterize_pdf_page(
|
||||
page_dpi=page_dpi,
|
||||
rotation=rotation,
|
||||
filter_vector=filter_vector,
|
||||
stop_on_soft_error=True,
|
||||
)
|
||||
mock.assert_called()
|
||||
return output_file
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from subprocess import CalledProcessError
|
||||
from unittest.mock import patch
|
||||
|
||||
from ocrmypdf import hookimpl
|
||||
from ocrmypdf.builtin_plugins import ghostscript
|
||||
from ocrmypdf.subprocess import run
|
||||
|
||||
|
||||
def fail_if_stoponerror(args, **kwargs):
|
||||
if '-dPDFSTOPONERROR' in args:
|
||||
raise CalledProcessError(1, 'gs', output=b"", stderr=b"PDF STOP ON ERROR")
|
||||
return run(args, **kwargs)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def rasterize_pdf_page(
|
||||
input_file,
|
||||
output_file,
|
||||
raster_device,
|
||||
raster_dpi,
|
||||
pageno,
|
||||
page_dpi,
|
||||
rotation,
|
||||
filter_vector,
|
||||
stop_on_soft_error,
|
||||
) -> Path:
|
||||
with patch('ocrmypdf._exec.ghostscript.run') as mock:
|
||||
mock.side_effect = fail_if_stoponerror
|
||||
ghostscript.rasterize_pdf_page(
|
||||
input_file=input_file,
|
||||
output_file=output_file,
|
||||
raster_device=raster_device,
|
||||
raster_dpi=raster_dpi,
|
||||
pageno=pageno,
|
||||
page_dpi=page_dpi,
|
||||
rotation=rotation,
|
||||
filter_vector=filter_vector,
|
||||
stop_on_soft_error=stop_on_soft_error,
|
||||
)
|
||||
mock.assert_called()
|
||||
return output_file
|
||||
@@ -28,6 +28,7 @@ def generate_pdfa(pdf_pages, pdfmark, output_file, compression, pdf_version, pdf
|
||||
pdf_version=pdf_version,
|
||||
pdfa_part=pdfa_part,
|
||||
progressbar_class=None,
|
||||
stop_on_soft_error=True,
|
||||
)
|
||||
mock.assert_called()
|
||||
return output_file
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from subprocess import CalledProcessError
|
||||
from unittest.mock import patch
|
||||
|
||||
from ocrmypdf import hookimpl
|
||||
from ocrmypdf.builtin_plugins import ghostscript
|
||||
from ocrmypdf.subprocess import run_polling_stderr
|
||||
|
||||
|
||||
def fail_if_stoponerror(args, **kwargs):
|
||||
if '-dPDFSTOPONERROR' in args:
|
||||
raise CalledProcessError(1, 'gs', output=b"", stderr=b"PDF STOP ON ERROR")
|
||||
return run_polling_stderr(args, **kwargs)
|
||||
|
||||
|
||||
@hookimpl
|
||||
def generate_pdfa(
|
||||
pdf_pages,
|
||||
pdfmark,
|
||||
output_file,
|
||||
compression,
|
||||
pdf_version,
|
||||
pdfa_part,
|
||||
stop_on_soft_error,
|
||||
):
|
||||
with patch('ocrmypdf._exec.ghostscript.run_polling_stderr') as mock:
|
||||
mock.side_effect = fail_if_stoponerror
|
||||
ghostscript.generate_pdfa(
|
||||
pdf_pages=pdf_pages,
|
||||
pdfmark=pdfmark,
|
||||
output_file=output_file,
|
||||
compression=compression,
|
||||
pdf_version=pdf_version,
|
||||
pdfa_part=pdfa_part,
|
||||
progressbar_class=None,
|
||||
stop_on_soft_error=stop_on_soft_error,
|
||||
)
|
||||
mock.assert_called()
|
||||
return output_file
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""Tesseract bad utf8
|
||||
"""Tesseract bad utf8.
|
||||
|
||||
In some cases, some versions of Tesseract can output binary gibberish or data
|
||||
that is not UTF-8 compatible, so we are forced to check that we can convert it
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Cache output of tesseract to speed up test suite
|
||||
"""Cache output of tesseract to speed up test suite.
|
||||
|
||||
The cache is keyed by by the input test file The input arguments are slugged
|
||||
into a hideous filename that more or less represents them literally. Joined
|
||||
@@ -21,7 +21,7 @@ were produced.
|
||||
|
||||
Certain operations are not cached and routed to Tesseract OCR directly.
|
||||
|
||||
Assumes Tesseract 4.0.0-alpha or higher.
|
||||
Assumes Tesseract 4+.
|
||||
|
||||
"""
|
||||
|
||||
@@ -140,18 +140,19 @@ def cached_run(options, run_args, **run_kwargs):
|
||||
tessfile = args.outputbase + '.' + configfile
|
||||
shutil.copy(tessfile, str(cache_folder / configfile) + '.bin')
|
||||
|
||||
manifest = {}
|
||||
manifest['tesseract_version'] = TesseractOcrEngine.version().replace('\n', ' ')
|
||||
manifest['system'] = platform.system()
|
||||
manifest['python'] = platform.python_version()
|
||||
manifest['argv_slug'] = cache_folder.name
|
||||
manifest['sourcefile'] = str(Path(source_file).relative_to(TESTS_ROOT))
|
||||
|
||||
def clean_sys_argv():
|
||||
for arg in run_args[1:]:
|
||||
yield re.sub(r'.*/ocrmypdf[.]io[.][^/]+[/](.*)', r'$TMPDIR/\1', arg)
|
||||
|
||||
manifest['args'] = list(clean_sys_argv())
|
||||
manifest = {
|
||||
'tesseract_version': TesseractOcrEngine.version().replace('\n', ' '),
|
||||
'system': platform.system(),
|
||||
'python': platform.python_version(),
|
||||
'argv_slug': cache_folder.name,
|
||||
'sourcefile': str(Path(source_file).relative_to(TESTS_ROOT)),
|
||||
'args': list(clean_sys_argv()),
|
||||
}
|
||||
|
||||
with (Path(CACHE_ROOT) / 'manifest.jsonl').open('a') as f:
|
||||
json.dump(manifest, f)
|
||||
f.write('\n')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Tesseract no-op/fixed rotate plugin
|
||||
"""Tesseract no-op/fixed rotate plugin.
|
||||
|
||||
To quickly run tests where getting OCR output is not necessary and we want to test
|
||||
the rotation pipeline.
|
||||
@@ -27,14 +27,16 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<head>
|
||||
<title></title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name='ocr-system' content='tesseract 4.0.0' />
|
||||
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/>
|
||||
<meta name='ocr-system' content='tesseract 4.1.1' />
|
||||
<meta name='ocr-capabilities'
|
||||
content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/>
|
||||
</head>
|
||||
<body>
|
||||
<div class='ocr_page' id='page_1' title='image "x.tif"; bbox 0 0 {0} {1}; ppageno 0'>
|
||||
<div class='ocr_carea' id='block_1_1' title="bbox 0 1 {0} {1}">
|
||||
<p class='ocr_par' dir='ltr' id='par_1' title="bbox 0 1 {0} {1}">
|
||||
<span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}"><span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span>
|
||||
<span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}">
|
||||
<span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -46,7 +48,7 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
class FixedRotateNoopOcrEngine(OcrEngine):
|
||||
@staticmethod
|
||||
def version():
|
||||
return '4.0.0'
|
||||
return '4.1.1'
|
||||
|
||||
@staticmethod
|
||||
def creator_tag(options):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
# SPDX-License-Identifier: MIT
|
||||
"""Tesseract no-op plugin
|
||||
"""Tesseract no-op plugin.
|
||||
|
||||
To quickly run tests where getting OCR output is not necessary.
|
||||
|
||||
@@ -25,14 +25,16 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<head>
|
||||
<title></title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||
<meta name='ocr-system' content='tesseract 4.0.0' />
|
||||
<meta name='ocr-capabilities' content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/>
|
||||
<meta name='ocr-system' content='tesseract 4.1.1' />
|
||||
<meta name='ocr-capabilities'
|
||||
content='ocr_page ocr_carea ocr_par ocr_line ocrx_word'/>
|
||||
</head>
|
||||
<body>
|
||||
<div class='ocr_page' id='page_1' title='image "x.tif"; bbox 0 0 {0} {1}; ppageno 0'>
|
||||
<div class='ocr_carea' id='block_1_1' title="bbox 0 1 {0} {1}">
|
||||
<p class='ocr_par' dir='ltr' id='par_1' title="bbox 0 1 {0} {1}">
|
||||
<span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}"><span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span>
|
||||
<span class='ocr_line' id='line_1' title="bbox 0 1 {0} {1}">
|
||||
<span class='ocrx_word' id='word_1' title="bbox 0 1 {0} {1}"> </span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -44,7 +46,7 @@ HOCR_TEMPLATE = '''<?xml version="1.0" encoding="UTF-8"?>
|
||||
class NoopOcrEngine(OcrEngine):
|
||||
@staticmethod
|
||||
def version():
|
||||
return '4.0.0'
|
||||
return '4.1.1'
|
||||
|
||||
@staticmethod
|
||||
def creator_tag(options):
|
||||
|
||||
+41
-106
@@ -1,168 +1,105 @@
|
||||
.. SPDX-FileCopyrightText: 2022 James R. Barlow
|
||||
.. SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
|
||||
These test files are used in OCRmyPDF's test suite. They do not necessarily produce OCR results
|
||||
at all and are not meant as examples of OCR output. Some are even invalid PDFs that might
|
||||
at all and are not necessarily meant as examples of OCR output. Some are even invalid PDFs that might
|
||||
crash certain PDF viewers.
|
||||
|
||||
|
||||
Files derived from free sources
|
||||
===============================
|
||||
|
||||
These test resources come from free sources, under either public domain or Creative Commons licenses.
|
||||
In some cases they were converted from one image format to another without other changes.
|
||||
Some of these images were obtained from the public domain. Others are copyrighted and may have
|
||||
licenses associated. Refer to ``.reuse/dep5`` file in OCRmyPDF's Git repository for information on
|
||||
the copyright holder(s) and license(s) applicable to these resources.
|
||||
|
||||
.. list-table::
|
||||
:widths: 20 50 30
|
||||
:widths: 15 35 50
|
||||
:header-rows: 1
|
||||
|
||||
* - File
|
||||
- Source
|
||||
- License
|
||||
- Purpose
|
||||
* - c02-22.pdf
|
||||
- `Project Gutenberg`_, Adventures of Huckleberry Finn, page 22
|
||||
- Public Domain
|
||||
- difficult OCR image (obscure fonts and illustrations)
|
||||
* - congress.jpg
|
||||
- `US Congressional Records`_
|
||||
- Public Domain
|
||||
- difficult OCR image (color background)
|
||||
* - graph.pdf
|
||||
- `Wikimedia: Pandas text analysis.png`_
|
||||
- Public Domain
|
||||
- `Wikimedia:Simple_line_graph_of_ACE_2012_results_by_candidate_sj01.png`_
|
||||
- image with slanted text
|
||||
* - lichtenstein.pdf
|
||||
- `Wikimedia: JPEG2000 Lichtenstein`_
|
||||
- Creative Commons BY-SA 3.0
|
||||
- JPEG2000 image
|
||||
* - linn.png, linn.pdf, linn.txt
|
||||
- `Wikimedia: LinnSequencer`_
|
||||
- Creative Commons BY-SA 3.0
|
||||
- image with two columns
|
||||
* - typewriter.png, 2400dpi.pdf
|
||||
- `Wikimedia: Triumph typewrtier text Linzensoep`_
|
||||
- Creative Commons BY-SA 2.5
|
||||
- simple text
|
||||
* - baiona.png
|
||||
- `Wikimedia: Baionako udalerri mugakideak`_
|
||||
- Creative Commons BY-SA 4.0
|
||||
* - enron1.pdf
|
||||
- EnronData.org
|
||||
- Creative Commons BY 3.0
|
||||
|
||||
|
||||
Files generated for this project
|
||||
================================
|
||||
|
||||
The following test resources were crafted specifically for this project, and are
|
||||
licensed under the specified license.
|
||||
|
||||
.. list-table::
|
||||
:widths: 20 40 15 15 10
|
||||
:header-rows: 1
|
||||
|
||||
* - File
|
||||
- Purpose
|
||||
- Contributor
|
||||
- Copyright Holder
|
||||
- License
|
||||
- multilingual text and images
|
||||
* - aspect.pdf
|
||||
- synthetic
|
||||
- test image with 200 x 100 DPI resolution
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - blank.pdf
|
||||
- synthetic
|
||||
- blank PDF generated by Adobe Illustrator CC 17, containing a lot of application-specific metadata/bloat
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - cmyk.pdf
|
||||
- synthetic
|
||||
- a CMYK image created in Photoshop
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - crom.png
|
||||
- synthetic
|
||||
- test for non-dictionary words
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - enormous.pdf
|
||||
- synthetic
|
||||
- very large PDF page
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - epson.pdf
|
||||
- synthetic
|
||||
- a linearized PDF containing some unusual indirect objects, created by an Epson printer; printout of a Wikipedia article (CC-BY-SA)
|
||||
- @lowesjam
|
||||
- Wikipedia authors
|
||||
- CC-BY-SA 3.0
|
||||
* - formxobject.pdf
|
||||
- synthetic
|
||||
- hand-crafted PDF containing an image inside a Form XObject
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - francais.pdf
|
||||
- synthetic
|
||||
- a page containing French accents (diacritics)
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - hugemono.pdf
|
||||
- synthetic
|
||||
- large monochrome 35000x35000 image in JBIG2 encoding
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - invalid.pdf
|
||||
- synthetic
|
||||
- a PDF file header followed by EOF marker
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - kcs.pdf
|
||||
- synthetic
|
||||
- PDF file generated by Kodak Capture Desktop Software 1.2; has invalid table of contents
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - livecycle.pdf
|
||||
- synthetic
|
||||
- a minimal PDF that claims to use dynamic XFA forms
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - masks.pdf
|
||||
- synthetic
|
||||
- file containing explicit masks and a stencil mask drawn without a proper transformation matrix; printout of a German Wikipedia article (CC-BY-SA)
|
||||
- @supergrobi
|
||||
- Wikipedia authors
|
||||
- CC-BY-SA 3.0
|
||||
* - missing_docinfo.pdf
|
||||
- synthetic
|
||||
- PDF file with no /DocumentInfo section
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - overlay.pdf
|
||||
- synthetic
|
||||
- PDF file generated by PDFPen pro that triggered content stream parse errors
|
||||
- @maxandersen
|
||||
- @maxandersen
|
||||
- CC-BY-SA 4.0
|
||||
* - negzero.pdf
|
||||
- synthetic
|
||||
- copy of formxobject.pdf with token that qpdf doesn't like
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - no_contents.pdf
|
||||
- synthetic
|
||||
- synthetic PDF with a blank page that has no /Contents entry
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - truetype_font_nomapping.pdf
|
||||
- synthetic
|
||||
- example of a PDF with an embedded subsetted TrueType font with no Unicode mapping
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - trivial.pdf
|
||||
- synthetic
|
||||
- smallest possible valid PDF-1.3 with all required fields
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - type3_font_nomapping.pdf
|
||||
- synthetic
|
||||
- example of a PDF with an embedded subsetted TrueType font with no Unicode mapping
|
||||
- @jbarlow83
|
||||
- @jbarlow83
|
||||
- CC-BY-SA 4.0
|
||||
* - vector.pdf
|
||||
- synthetic
|
||||
- a PDF with vector art and text rendered as curves with no fonts
|
||||
- @Catscratch
|
||||
- @Catscratch
|
||||
- CC-BY-SA 4.0
|
||||
|
||||
|
||||
Assemblies
|
||||
==========
|
||||
@@ -174,14 +111,12 @@ These test resources are assemblies or derivatives from other previously mention
|
||||
- baiona_alpha.png (from baiona.png, RGB+A version)
|
||||
- cardinal.pdf (four cardinal directions, baked-in rotated copies of linn.png)
|
||||
- ccitt.pdf (linn.png, converted to CCITT encoding)
|
||||
- encrypted_algo4.pdf (congress.jpg, encrypted with algorithm 4 - not supported by PyPDF2)
|
||||
- graph_ocred.pdf (from graph.pdf)
|
||||
- jbig2.pdf (congress.jpg, converted to JBIG2 encoding)
|
||||
- jbig2.pdf (from linn.png)
|
||||
- multipage.pdf (from several other files)
|
||||
- palette.pdf (congress.jpg, converted to a 256-color palette)
|
||||
- palette.pdf (from baiona_colormapped.png)
|
||||
- poster.pdf (from linn.png)
|
||||
- rotated_skew.pdf (a /Rotate'd and skewed document from linn.png)
|
||||
- skew-encrypted.pdf (skew.pdf with encryption - access supported by PyPDF2, password is "password")
|
||||
- skew.pdf (from linn.png, skew simulated by adjusting the transformation matrix)
|
||||
- toc.pdf (from formxobject.pdf, trivial.pdf)
|
||||
|
||||
@@ -192,7 +127,7 @@ These test resources are assemblies or derivatives from other previously mention
|
||||
|
||||
.. _`US Congressional Records`: http://www.baxleystamps.com/litho/meiji/courts_1871.jpg
|
||||
|
||||
.. _`Wikimedia: Pandas text analysis.png`: https://en.wikipedia.org/wiki/File:Pandas_text_analysis.png
|
||||
.. _`Wikimedia: Simple_line_graph_of_ACE_2012_results_by_candidate_sj01.png`: https://en.wikipedia.org/wiki/File:Simple_line_graph_of_ACE_2012_results_by_candidate_sj01.png
|
||||
|
||||
.. _`Wikimedia: JPEG2000 Lichtenstein`: https://en.wikipedia.org/wiki/JPEG_2000#/media/File:Jpeg2000_2-level_wavelet_transform-lichtenstein.png
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user